Merge pull request #79 from storytold/pr/m4-pdf-editing-presets

M4: PDF and .ai reopen editable, Save As .ai; PDF presets and Edit > PDF Presets (M4.43, M4.44)
This commit is contained in:
BFlat 2026-10-05 10:42:27 +07:00 committed by GitHub
commit a560f74e17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1825 additions and 146 deletions

View File

@ -113,7 +113,14 @@ fn services() -> Services {
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
// Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz).
pick_save: Some(Box::new(|name: &str| {
fileio::save_filters(name)
.into_iter()
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts))
.save_file()
.map(|p| p.to_string_lossy().to_string())
})),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
// Menu-bar Paste never sees egui's Paste event, so read the clipboard directly.

View File

@ -70,7 +70,7 @@ pub struct Encoded {
}
impl Encoded {
fn one(bytes: Vec<u8>) -> Self {
pub(super) fn one(bytes: Vec<u8>) -> Self {
Self { files: vec![(None, bytes)], ..Self::default() }
}

View File

@ -16,6 +16,8 @@ use super::{
pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?;
let expanded = super::pdf::expand_preset(s, "document.serialize", p)?;
let p = &*expanded;
let doc = &s.doc()?.doc;
let enc = encode_all(doc, f.id, p)?;
let files = enc.named(doc, &default_name(doc, f.extensions[0]));
@ -38,6 +40,8 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path");
let f = writable("document.export", str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, "document.export", p)?;
let p = &*expanded;
let doc = &s.doc()?.doc;
let enc = encode_all(doc, f.id, p)?;
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
@ -63,6 +67,8 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "document.exportSelection";
let path = str_param(p, "path");
let f = writable(C, str_param(p, "format"), path)?;
let expanded = super::pdf::expand_preset(s, C, p)?;
let p = &*expanded;
let ids = edit::selected_roots(s)?;
let st = s.doc()?;
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
@ -114,6 +120,8 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
let mut options = without_artboards(row);
if let Some(o) = options.as_object_mut() {
o.insert("scale".into(), json!(scale));
// A file per artboard is for viewing: it doesn't carry the whole document to edit.
o.entry("preserveEditing").or_insert(json!(false));
}
Ok(ScreenFormat { format, options, suffix })
}

View File

@ -16,6 +16,8 @@ pub struct Loaded {
pub doc: Document,
pub format: &'static Format,
pub warnings: Vec<String>,
/// The document is the native one the file carries (SVG or PDF saved with Preserve Editing).
pub restored: bool,
}
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
@ -67,10 +69,34 @@ fn image_format(f: image::ImageFormat) -> Option<&'static Format> {
f.extensions_str().iter().find_map(|e| format(e)).filter(|f| f.read && f.raster)
}
/// Why an SVG carrying editing data opened as plain SVG: it was changed after it was saved.
pub const EDITING_STALE: &str = "this SVG was changed in another app after it was saved with its editing data: it opened as plain SVG";
/// Why an SVG carrying editing data opened as plain SVG: the data can't be read.
pub const EDITING_DAMAGED: &str = "this SVG's editing data can't be read: it opened as plain SVG";
/// Why a file carrying editing data (an SVG or PDF saved with Preserve Editing) opened as plain
/// artwork: it was changed in another app after it was saved.
pub const EDITING_STALE: &str = "this file was changed in another app after it was saved with its editing data: it opened as plain artwork";
/// Why a file carrying editing data opened as plain artwork: the data can't be read.
pub const EDITING_DAMAGED: &str = "this file's editing data can't be read: it opened as plain artwork";
/// Preserve Editing, for every format that carries the native document: that document when the
/// file around it is unchanged (`editing`: whether it is, and how to get the document's bytes) and
/// it reads, else `import()`'s document with a first warning saying why the editing data wasn't
/// used → (document, warnings, restored).
fn restore_or_import(
editing: Option<(bool, impl FnOnce() -> Option<Vec<u8>>)>,
import: impl FnOnce() -> Result<(Document, Vec<String>)>,
) -> Result<(Document, Vec<String>, bool)> {
let mut fallback = None;
if let Some((intact, bytes)) = editing {
let native = if intact { bytes() } else { None };
match native.and_then(|b| vectorcraft_format::load(&b).ok()) {
Some(doc) => return Ok((doc, vec![], true)),
None => fallback = Some(if intact { EDITING_DAMAGED } else { EDITING_STALE }),
}
}
let (doc, mut warnings) = import()?;
if let Some(why) = fallback {
warnings.insert(0, why.to_string());
}
Ok((doc, warnings, false))
}
/// Read a file of any readable format (`name`: its file name or path, for the extension and title).
pub fn load(name: &str, bytes: &[u8]) -> Result<Loaded> {
@ -86,37 +112,19 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
let title = file_name(name);
let mut warnings = vec![];
let mut doc = match format.id {
"vectorcraft" => vectorcraft_format::load(bytes).map_err(err)?,
let (mut doc, warnings, restored) = match format.id {
"vectorcraft" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false),
"svg" | "svgz" => {
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
// An SVG saved with Preserve Editing carries the native document: open that, unless
// the SVG was edited since (or the data is damaged).
let editing = vectorcraft_svg::editing(&text);
let native = editing
.as_ref()
.filter(|e| e.intact)
.and_then(|e| vectorcraft_format::base64_decode(&e.data))
.and_then(|b| vectorcraft_format::load(&b).ok());
match native {
Some(d) => d,
None => {
let (d, w) = vectorcraft_svg::import_with_report(&text).map_err(err)?;
warnings = w;
if let Some(e) = editing {
warnings.insert(0, (if e.intact { EDITING_DAMAGED } else { EDITING_STALE }).to_string());
}
d
}
}
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
restore_or_import(editing, || vectorcraft_svg::import_with_report(&text).map_err(err))?
}
"pdf" | "ai" | "ait" => {
let (d, w) = super::pdfimport::import(bytes, opts)?;
warnings = w;
d
// Saved with Preserve Editing: the document it carries, unless some pages are picked.
let editing = opts.pages.is_none().then(|| vectorcraft_pdf::editing(bytes)).flatten().map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
}
_ if format.raster => raster_doc(&title, bytes)?,
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
};
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
@ -127,13 +135,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
if format.id != "vectorcraft" || doc.title.is_empty() {
doc.title = title;
}
Ok(Loaded { doc, format, warnings })
Ok(Loaded { doc, format, warnings, restored })
}
/// Open a file's bytes as the new active document (what `document.open` does) →
/// `{index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks}`. `path` is kept
/// for Save only for a native, non-template file; the document's linked files are looked for from
/// it ([`crate::cmd::links::resolve`]).
/// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. `path`
/// is kept for Save only for a non-template native file or a `.ai` file whose native document came
/// back (Save writes both again); the document's linked files are looked for from it
/// ([`crate::cmd::links::resolve`]).
pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>) -> Result<Value> {
open_bytes_with(s, name, bytes, path, &Value::Null)
}
@ -141,7 +150,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option<String
/// [`open_bytes`] with the `document.open` options in `p` ([`LoadOptions::from_params`]).
pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<String>, p: &Value) -> Result<Value> {
let opts = LoadOptions::from_params("document.open", p)?;
let Loaded { mut doc, format, warnings } = load_with(name, bytes, &opts)?;
let Loaded { mut doc, format, warnings, restored } = load_with(name, bytes, &opts)?;
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
// A template (saved by Save as Template, or an .ait file) opens as a new untitled document.
let template = doc.template || format.id == "ait";
@ -149,10 +158,10 @@ pub fn open_bytes_with(s: &mut Session, name: &str, bytes: &[u8], path: Option<S
doc.template = false;
doc.title = s.next_untitled();
}
let keep_path = format.id == "vectorcraft" && !template;
let keep_path = (format.id == "vectorcraft" || (format.id == "ai" && restored)) && !template;
let index = s.add_document(doc, path.filter(|_| keep_path));
let title = s.documents()[index].title();
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings }), links.to_json()))
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
}
/// A file named by a command's params: `{path}` (read from disk) or `{name, dataBase64}`.

View File

@ -31,7 +31,7 @@ use load::err;
pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, raster_image};
pub use pdfimport::{LoadOptions, page_document};
pub use save::{save_encoding, save_format, stamp_save_dates};
pub use save::{SAVE_FORMATS, save_encoding, save_filters, save_format, stamp_save_dates};
pub use svg::options_map as svg_options;
use super::*;
@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). Templates (native templates, .ait) open as a new untitled document. Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)",
always,
load::open
),
@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}} (default path: the document's) → {path, linked?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A never-saved document without path → {dataBase64} (stays modified)",
"{path?, format?: vectorcraft|ai|svg|svgz (default: from the path's extension, else vectorcraft), svg?: {…SVG options, see document.formats}, …document.exportPdf options (.ai)} (default path: the document's) → {path, linked?, warnings?}; the document takes the path. An SVG save uses the given SVG options, else the ones this document was last saved with, and keeps hidden layers (display:none) unless hiddenLayers is false. A .ai save is a PDF-compatible file: every artboard as a PDF page with the native document embedded (preserveEditing is always on), so document.open restores it exactly. A never-saved document without path → {dataBase64} (stays modified)",
has_doc,
save::save
),
@ -306,12 +306,13 @@ fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static st
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel) and flattener presets (imported).
/// libraries (which open in the library panel), flattener presets and PDF presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
.chain(std::iter::once(("PDF presets", super::pdfcmds::PRESET_EXTS)))
}
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
@ -513,6 +514,8 @@ mod tests_pdf;
#[cfg(test)]
mod tests_pdfcolor;
#[cfg(test)]
mod tests_pdfedit;
#[cfg(test)]
mod tests_pdfimport;
#[cfg(test)]
mod tests_svg;

View File

@ -1,11 +1,13 @@
//! 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, the
//! warnings that come back, and `document.pdfSettings` (the dialog's Summary).
//! `document.exportPdf` and the Save PDF dialog): presets (built-in and saved), the options parsed
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
use std::borrow::Cow;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfSettings};
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
@ -14,7 +16,7 @@ use crate::EngineError;
const C: &str = "document.exportPdf";
/// The built-in preset every PDF export starts from.
pub const DEFAULT_PRESET: &str = "VectorCraft Default";
pub use vectorcraft_pdf::DEFAULT_PRESET;
/// The PDF options `document.formats` lists; `document.exportPdf` documents every field.
pub(super) const OPTIONS: &[FormatOption] = &[
@ -22,9 +24,20 @@ pub(super) const OPTIONS: &[FormatOption] = &[
super::ARTBOARDS,
super::RANGE,
super::USE_ARTBOARDS,
FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" },
FormatOption {
name: "preset",
ty: "string",
default: "\"VectorCraft Default\"",
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
},
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: "preserveEditing",
ty: "boolean",
default: "true",
description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)",
},
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" },
@ -40,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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",
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), 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? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), 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
),
@ -49,23 +62,38 @@ pub fn specs() -> Vec<CommandSpec> {
"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…)",
"{preset?, includeDocument?: false, …document.exportPdf options} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (pattern strokes, effects left out…)",
always,
pdf_settings
),
]
}
/// Every preset name (built-in first).
pub fn presets() -> Vec<String> {
vec![DEFAULT_PRESET.to_string()]
/// Every preset name: the built-in ones, then the saved ones ([`crate::Prefs::pdf_presets`]).
pub fn presets(s: &Session) -> Vec<String> {
names(&s.prefs.pdf_presets)
}
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(", "))))
/// The built-in preset names, then those of `saved`.
fn names(saved: &[PdfPreset]) -> Vec<String> {
vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect()
}
/// The preset `name` names: a built-in one (any case; `default` is the app default) or one of
/// `saved`.
pub fn find_preset(name: &str, saved: &[PdfPreset]) -> Option<PdfPreset> {
vectorcraft_pdf::builtin_preset(name).or_else(|| saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())).cloned())
}
fn preset(cmd: &str, name: &str, saved: &[PdfPreset]) -> Result<PdfSettings> {
find_preset(name, saved)
.map(|p| p.settings)
.ok_or_else(|| bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names(saved).join(", "))))
}
/// The settings of the preset `p` names (default: [`DEFAULT_PRESET`]), before `p`'s options.
pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
preset(cmd, str_param(p, "preset").unwrap_or(DEFAULT_PRESET), saved)
}
/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the
@ -89,23 +117,55 @@ fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
}
}
/// 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(),
};
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
/// aren't PDF options (path, format…) are ignored.
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
let base = preset_settings(cmd, p, saved)?;
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}")))?;
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
s.preserve_editing = false;
}
s.check_values().map_err(|e| pdf_error(cmd, e))?;
Ok(s)
}
/// [`resolve`], refusing what the writer can't honour ([`PdfSettings::check`]).
pub fn settings_with(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
let s = resolve(cmd, p, saved)?;
s.check().map_err(|e| pdf_error(cmd, e))?;
Ok(s)
}
/// [`settings_with`] the built-in presets only (what the encoders know: see [`expand_preset`]).
pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
settings_with(cmd, p, &[])
}
/// `p` with a saved preset it names written out as options, for the encoders, which know only the
/// built-in presets. Params naming no preset, or a built-in one, come back as they are.
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
match str_param(p, "preset") {
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
let mut q = p.clone();
merge(&mut q, &serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))?);
if let Some(o) = q.as_object_mut() {
o.remove("preset");
}
Ok(Cow::Owned(q))
}
_ => Ok(Cow::Borrowed(p)),
}
}
/// 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() };
@ -113,12 +173,26 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() })
}
/// Why a PDF of some artboards carries no editing data.
pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
"Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)";
/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered
/// to images at the document's raster effects resolution.
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
/// when the PDF has every artboard (a PDF of some reopens as just those).
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
let opts = options(cmd, doc, p)?;
let mut opts = options(cmd, doc, p)?;
let mut warnings = vec![];
if opts.settings.preserve_editing {
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
opts.native = Some(vectorcraft_format::save(doc, false));
} else {
opts.settings.preserve_editing = false;
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
}
}
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
let mut warnings = r.warnings;
warnings.extend(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());
}
@ -126,6 +200,8 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?;
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
}
@ -144,16 +220,24 @@ fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec<Value>) {
}
}
/// `[{option, value}]` for every setting of `set` that differs from `base`
/// (`compression.color.ppi`).
pub fn changes(set: &PdfSettings, base: &PdfSettings) -> Vec<Value> {
let (Ok(v), Ok(base)) = (serde_json::to_value(set), serde_json::to_value(base)) else { return vec![] };
let mut diff = vec![];
changed("", &v, &base, &mut diff);
diff
}
fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> {
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 saved = &s.prefs.pdf_presets;
let set = settings_with(Q, p, saved)?;
let diff = changes(&set, &preset_settings(Q, p, saved)?);
let warnings = match s.active() {
Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, p)?.1,
Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1,
_ => set.warnings(),
};
Ok(json!({ "settings": v, "presets": presets(), "changed": diff, "warnings": warnings }))
let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings }))
}

View File

@ -1,4 +1,4 @@
//! Saves: `document.save` (native or SVG) and `file.saveAsTemplate`.
//! Saves: `document.save` (native, SVG or PDF-compatible .ai) and `file.saveAsTemplate`.
use serde_json::{Value, json};
@ -6,29 +6,53 @@ use super::super::*;
use super::{Encoded, Format, default_name, encode_all, format_for_name, writable_format, write_encoded, write_or_return};
use crate::DocState;
/// The formats Save writes, in the order save dialogs offer them; other formats are exports.
pub const SAVE_FORMATS: [&str; 4] = ["vectorcraft", "ai", "svg", "svgz"];
/// The format Save writes: `format` (an id or extension), else the format the path's extension
/// names, else native. Save writes .vectorcraft, SVG and SVGZ; other formats are exports.
/// names, else native (see [`SAVE_FORMATS`]). A `.ai` file is a PDF carrying the native document.
pub fn save_format(format: Option<&str>, path: Option<&str>) -> std::result::Result<&'static Format, String> {
let f = match (format, path.and_then(format_for_name)) {
(Some(_), _) => writable_format(format, None)?,
(Some(id), _) => super::format(id).filter(|f| SAVE_FORMATS.contains(&f.id)).map_or_else(|| writable_format(format, None), Ok)?,
(None, Some(f)) => f,
(None, None) => super::format("vectorcraft").ok_or("no native format")?,
};
match f.id {
"vectorcraft" | "svg" | "svgz" => Ok(f),
_ => Err(format!("Save writes .{}, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label)),
if SAVE_FORMATS.contains(&f.id) {
Ok(f)
} else {
Err(format!("Save writes .{}, .ai, .svg or .svgz files: write {} with document.export", vectorcraft_format::EXTENSION, f.label))
}
}
/// Save dialog filters for a file named `name`: when Save writes its format, every format Save
/// writes (that one first), else none.
pub fn save_filters(name: &str) -> Vec<(&'static str, &'static [&'static str])> {
let Some(first) = format_for_name(name).filter(|f| SAVE_FORMATS.contains(&f.id)) else { return vec![] };
let others = SAVE_FORMATS.iter().filter_map(|id| super::format(id)).filter(|f| f.id != first.id);
std::iter::once(first).chain(others).map(|f| (f.label, f.extensions)).collect()
}
/// What Save writes for `st` in format `f` (see [`save_format`]) to `path` → the encoded file and
/// the options to remember for the next Save. An SVG save takes the SVG options in `p`, else the
/// ones the document was last saved with, and keeps hidden layers (hidden) unless they say
/// otherwise. A native file records its links' paths relative to `path`.
/// otherwise. A native file (and the document a `.ai` file carries) records its links' paths
/// relative to `path`.
pub fn save_encoding(st: &DocState, f: &Format, p: &Value, path: Option<&str>) -> Result<(Encoded, Value)> {
const C: &str = "document.save";
if f.id == "vectorcraft" {
let relative = path.and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path));
return Ok((encode_all(relative.as_ref().unwrap_or(&st.doc), f.id, p)?, Value::Null));
let relative = path.filter(|_| matches!(f.id, "vectorcraft" | "ai")).and_then(|path| crate::cmd::links::with_relative_paths(&st.doc, path));
let doc = relative.as_ref().unwrap_or(&st.doc);
match f.id {
"vectorcraft" => return Ok((encode_all(doc, f.id, p)?, Value::Null)),
// A PDF of every artboard with the PDF options in `p`, always carrying the native document.
"ai" => {
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let Some(o) = q.as_object_mut() {
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
}
let (bytes, warnings) = super::pdf::encode(C, &doc.without_edit_modes(), &q)?;
return Ok((Encoded { warnings, ..Encoded::one(bytes) }, Value::Null));
}
_ => {}
}
let given = super::svg_options(p).map_err(|e| bad(C, e))?;
let opts = if given.is_empty() { st.save_options.clone() } else { Value::Object(given) };
@ -57,18 +81,22 @@ pub(super) fn save(s: &mut Session, p: &Value) -> Result<Value> {
if path.is_some() {
stamp_save_dates(s.doc_mut()?);
}
let expanded = super::pdf::expand_preset(s, C, p)?;
let p = &*expanded;
let st = s.doc()?;
let (enc, opts) = save_encoding(st, f, p, path.as_deref())?;
// The encoder's notes (a .ai file's PDF options not applied yet…).
let notes = if enc.warnings.is_empty() { json!({}) } else { json!({ "warnings": enc.warnings }) };
let Some(path) = path else {
// Nowhere to save to (web, agents): hand the bytes back; the document stays modified.
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, json!({}));
return write_encoded(None, &default_name(&st.doc, f.extensions[0]), &st.doc, &enc, notes);
};
let out = write_encoded(Some(&path), &path, &st.doc, &enc, json!({}))?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.save_options = opts;
st.mark_saved();
let mut r = json!({ "path": path });
let mut r = super::merge(json!({ "path": path }), notes);
if let Some(linked) = out.get("linked") {
r["linked"] = linked.clone();
}

View File

@ -146,7 +146,7 @@ fn open_exts_cover_every_readable_format() {
}
let filters: Vec<_> = open_filters().collect();
assert_eq!(filters[0], ("All readable files", OPEN_EXTS));
assert_eq!(filters.len(), 3 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries and flattener presets");
assert_eq!(filters.len(), 4 + FORMATS.iter().filter(|f| f.read).count(), "and swatch libraries, flattener presets and PDF presets");
}
#[test]

View File

@ -0,0 +1,170 @@
//! PDF and .ai files that reopen as they were: Preserve Editing embeds the native document, which
//! `document.open` restores unless the pages changed elsewhere; `.ai` saves always carry it and
//! `.ait` templates open from it untitled.
use serde_json::{Value, json};
use super::tests_svgedit::{comparable, rich};
use super::*;
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap()
}
fn open(s: &mut Session, name: &str, bytes: &[u8]) -> Value {
s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap()
}
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vc-pdfedit-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().to_string()
}
/// `pdf` with `from` replaced by `to` (the same length).
fn patched(pdf: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
assert_eq!(from.len(), to.len());
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from)));
let mut out = pdf.to_vec();
out[at..at + to.len()].copy_from_slice(to);
out
}
#[test]
fn a_pdf_saved_with_preserve_editing_reopens_as_the_same_document() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
// On in the default preset.
let v = s.execute("document.exportPdf", &json!({})).unwrap();
assert_eq!(v["warnings"].as_array().map(|w| w.iter().any(|w| w.as_str().unwrap().contains("Preserve"))), Some(false), "{v}");
let pdf = b64(&v);
let r = open(&mut s, "rich.pdf", &pdf);
assert_eq!((&r["warnings"], &r["restored"]), (&json!([]), &json!(true)), "{r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before, "the document came back exactly");
assert_eq!(s.doc().unwrap().path, None, "a PDF is not saved over");
// Picking pages imports them as artwork.
let r = s.execute("document.open", &json!({"name": "rich.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf), "pages": "1"})).unwrap();
assert_eq!(r["restored"], false, "{r}");
// Off: the pages import as artwork.
let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap());
assert!(vectorcraft_pdf::editing(&plain).is_none());
assert_eq!(open(&mut s, "plain.pdf", &plain)["restored"], false);
// document.export and serialize take the same option.
let v = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap();
assert!(vectorcraft_pdf::editing(&b64(&v)).is_some_and(|e| e.intact));
}
#[test]
fn pages_changed_elsewhere_open_as_artwork_with_a_warning() {
let mut s = rich();
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
// Another app changes the artboard's height (300 × 200 pt).
let edited = patched(&pdf, b"/MediaBox[0 0 300 200]", b"/MediaBox[0 0 300 201]");
let r = open(&mut s, "edited.pdf", &edited);
assert_eq!((&r["warnings"][0], &r["restored"]), (&json!(load::EDITING_STALE), &json!(false)), "{r}");
assert!(s.doc().unwrap().doc.symbols.is_empty(), "plain import: no symbols");
// Editing data that isn't a native document can't be read.
let damaged = patched(&pdf, b"{\"format\":\"vectorcraft\"", b"{\"format\":\"vectorcrafX\"");
let r = open(&mut s, "damaged.pdf", &damaged);
assert_eq!(r["warnings"][0], load::EDITING_DAMAGED, "{r}");
}
#[test]
fn the_legacy_payload_name_is_accepted() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
let pdf = b64(&s.execute("document.exportPdf", &json!({"compression": {"compressText": false}})).unwrap());
let (name, legacy) = (format!("({})", vectorcraft_pdf::EDITING_FILE), format!("({})", vectorcraft_pdf::LEGACY_EDITING_FILE));
let legacy = format!("{legacy}{}", " ".repeat(name.len() - legacy.len()));
let mut old = pdf.clone();
while let Some(at) = old.windows(name.len()).position(|w| w == name.as_bytes()) {
old[at..at + name.len()].copy_from_slice(legacy.as_bytes());
}
assert!(!String::from_utf8_lossy(&old).contains(vectorcraft_pdf::EDITING_FILE));
assert_eq!(open(&mut s, "old.pdf", &old)["restored"], true);
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
#[test]
fn ai_files_are_pdfs_that_save_and_reopen_editable() {
let mut s = rich();
let path = tmp("art.ai");
let r = s.execute("document.save", &json!({"path": path, "preserveEditing": false, "range": "1"})).unwrap();
assert_eq!(r["path"], path.as_str());
// As saved (Save stamps the File Info dates).
let before = comparable(&s.doc().unwrap().doc);
let bytes = std::fs::read(&path).unwrap();
assert!(bytes.starts_with(b"%PDF"), ".ai is PDF-compatible");
assert!(vectorcraft_pdf::editing(&bytes).is_some_and(|e| e.intact), "always carries the document");
assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.as_str()), "the document takes the path");
// Reopening restores it and keeps the path: Save writes .ai again.
let r = s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!((&r["format"], &r["restored"]), (&json!("ai"), &json!(true)), "{r}");
assert_eq!(comparable(&s.doc().unwrap().doc), before);
assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.as_str()));
s.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 5, "height": 5})).unwrap();
s.execute("document.save", &json!({})).unwrap();
let again = vectorcraft_pdf::editing(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(vectorcraft_format::load(&again.data).unwrap().layers, s.doc().unwrap().doc.layers);
// PDF stays an export.
assert!(s.execute("document.save", &json!({"path": tmp("x.pdf")})).is_err(), "PDF is written with document.exportPdf");
// A plain PDF named .ai opens as artwork, without a path to save over.
let plain = b64(&s.execute("document.exportPdf", &json!({"preserveEditing": false})).unwrap());
let plain_path = tmp("plain.ai");
std::fs::write(&plain_path, plain).unwrap();
let r = s.execute("document.open", &json!({"path": plain_path})).unwrap();
assert_eq!(r["restored"], false);
assert_eq!(s.doc().unwrap().path, None);
}
#[test]
fn ait_templates_open_their_document_untitled() {
let mut s = rich();
let before = comparable(&s.doc().unwrap().doc);
// A never-saved document's .ai comes back as bytes.
let ai = b64(&s.execute("document.save", &json!({"format": "ai"})).unwrap());
assert!(ai.starts_with(b"%PDF"));
let r = open(&mut s, "brochure.ait", &ai);
assert_eq!((&r["format"], &r["restored"]), (&json!("ait"), &json!(true)), "{r}");
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(s.doc().unwrap().path, None);
assert_eq!(comparable(&s.doc().unwrap().doc), before);
}
#[test]
fn standards_and_screens_leave_the_editing_data_out() {
let mut s = rich();
// Choosing PDF/A turns Preserve Editing off unless it is asked for, which PDF/A refuses.
let pdf_a = b64(&s.execute("document.exportPdf", &json!({"standard": "pdfA2b"})).unwrap());
assert!(vectorcraft_pdf::editing(&pdf_a).is_none());
let e = s.execute("document.exportPdf", &json!({"standard": "pdfA2b", "preserveEditing": true})).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{e}");
// Export for Screens writes a PDF per artboard for viewing.
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "pdf"}]})).unwrap();
let file = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap();
assert!(vectorcraft_pdf::editing(&file).is_none());
}
#[test]
fn save_dialogs_offer_every_format_save_writes() {
let labels = |name: &str| save_filters(name).into_iter().map(|(l, _)| l).collect::<Vec<_>>();
assert_eq!(labels("a.ai")[0], "PDF-compatible .ai");
assert_eq!(labels("a.vectorcraft").len(), SAVE_FORMATS.len());
assert_eq!(labels("a.svg")[0], "SVG");
assert!(labels("a.png").is_empty() && labels("noext").is_empty(), "exports pick their own name");
assert_eq!(save_format(Some("ai"), None).unwrap().id, "ai");
assert_eq!(save_format(None, Some("x.AI")).unwrap().id, "ai");
assert!(save_format(Some("png"), None).is_err());
}
#[test]
fn a_pdf_of_some_artboards_carries_no_editing_data() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": 3})).unwrap();
for (range, kept) in [("2", false), ("1,3", false), ("3,2,1", false), ("1-3", true), ("all", true)] {
let v = s.execute("document.exportPdf", &json!({ "range": range })).unwrap();
assert_eq!(vectorcraft_pdf::editing(&b64(&v)).is_some(), kept, "{range}");
let warned = v["warnings"].as_array().unwrap().iter().any(|w| w == super::pdf::EDITING_NEEDS_EVERY_ARTBOARD);
assert_eq!(warned, !kept, "{range}: {v}");
}
}

View File

@ -21,7 +21,7 @@ fn select(s: &mut Session, ids: &[u64]) {
}
/// A roughened square, three instances of a symbol and a square filled with a pattern.
fn rich() -> Session {
pub(super) fn rich() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
let rough = rect(&mut s, 10.0, 10.0);
@ -46,7 +46,7 @@ fn open(s: &mut Session, name: &str, text: &str) -> Value {
}
/// `d` without what opening a file sets anew.
fn comparable(d: &Document) -> Document {
pub(super) fn comparable(d: &Document) -> Document {
let mut d = d.clone();
d.title.clear();
d

View File

@ -40,6 +40,7 @@ mod panelcmds;
mod path;
mod pathops;
mod patterncmds;
pub mod pdfcmds;
mod place;
pub mod prefscmds;
pub mod rasterfx;
@ -220,6 +221,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(newdoc::specs());
v.extend(place::specs());
v.extend(links::specs());
v.extend(pdfcmds::specs());
v
})
}

View File

@ -0,0 +1,210 @@
//! Edit → PDF Presets: `pdf.preset.list`, `save`, `delete`, `export` and `import`. The built-in
//! presets are generated in code ([`vectorcraft_pdf::builtin_presets`]) and read-only; the user's
//! live in [`crate::Prefs::pdf_presets`]. Any of their names works as `preset` wherever PDF
//! options are taken (`document.exportPdf`, `document.export`, a `.ai` save…).
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vectorcraft_pdf::{PdfPreset, builtin_preset, builtin_presets};
use super::fileio::pdf::{find_preset, resolve};
use super::*;
/// The `format` of a PDF presets file, also its extension.
pub const PRESET_FORMAT: &str = "vcpdfpresets";
/// The extensions of PDF presets files (opening one imports it).
pub const PRESET_EXTS: &[&str] = &[PRESET_FORMAT];
/// What `pdf.preset.export` writes and `pdf.preset.import` reads.
#[derive(Serialize, Deserialize)]
struct PresetFile {
format: String,
#[serde(default)]
presets: Vec<PdfPreset>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "pdf.preset.list",
"PDF Presets",
[],
None,
"{} → {presets: [{name, builtIn, supported (the writer produces its standard), description, settings (as document.pdfSettings)}]} the built-in presets (VectorCraft Default first), then the saved ones; any of these names works as `preset` wherever PDF options are taken",
always,
list
),
cmd!(
query "pdf.preset.save",
"Save PDF Preset",
[],
None,
"{name?: (default: a new \"PDF Preset N\"), newName?: rename it, description?, preset?: the preset to start from (default: the saved preset `name`, else VectorCraft Default), …document.exportPdf options} create or change a saved preset (built-in presets are read-only; passwords are never stored) → {name, created, settings}",
always,
save
),
cmd!(query "pdf.preset.delete", "Delete PDF Preset", [], None, "{name} delete a saved preset (built-in ones stay) → {deleted: name}", always, delete),
cmd!(
query "pdf.preset.export",
"Export PDF Presets",
[],
None,
"{names?: [preset names, built-in ones too] (default: every saved preset), path?} write the presets as a .vcpdfpresets file (JSON) → {path, count}; without path → {data: the file's text, count}",
always,
export
),
cmd!(
query "pdf.preset.import",
"Import PDF Presets",
[],
None,
"{path? | data?: file text | dataBase64?, replace?: false (replace saved presets of the same names; else imported ones whose name is taken get a number)} add the presets of a .vcpdfpresets file (as pdf.preset.export writes) to the saved ones → {imported: [names]}",
always,
import
),
]
}
impl Session {
/// Every PDF preset: the built-in ones, then the saved ones.
pub fn pdf_presets(&self) -> Vec<PdfPreset> {
let mut v = builtin_presets();
v.extend(self.prefs.pdf_presets.iter().cloned());
v
}
/// The first free "PDF Preset N": the name a new preset gets.
pub fn new_pdf_preset_name(&self) -> String {
(1..).map(|i| format!("PDF Preset {i}")).find(|n| !self.pdf_preset_taken(n, None)).unwrap_or_default()
}
/// The index of the saved PDF preset `name` (any case).
fn pdf_saved_index(&self, name: &str) -> Option<usize> {
self.prefs.pdf_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name.trim()))
}
/// Whether `name` is taken by a built-in PDF preset or a saved one other than the one at
/// `except`.
fn pdf_preset_taken(&self, name: &str, except: Option<usize>) -> bool {
builtin_preset(name).is_some() || self.pdf_saved_index(name).is_some_and(|i| Some(i) != except)
}
}
fn row(p: PdfPreset, built_in: bool) -> Value {
json!({ "name": p.name, "builtIn": built_in, "supported": p.settings.check().is_ok(), "description": p.description, "settings": p.settings })
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let rows = builtin_presets().into_iter().map(|p| row(p, true)).chain(s.prefs.pdf_presets.iter().cloned().map(|p| row(p, false)));
Ok(json!({ "presets": rows.collect::<Vec<_>>() }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pdf.preset.save";
let name = match str_param(p, "name").map(str::trim) {
Some("") => return Err(bad(C, "`name` is empty")),
Some(n) => n.to_string(),
None => s.new_pdf_preset_name(),
};
if builtin_preset(&name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and is read-only: save it under another name")));
}
let at = s.pdf_saved_index(&name);
let saved = at.and_then(|i| s.prefs.pdf_presets.get(i)).cloned();
// A saved preset changes from its own settings unless `preset` says where to start.
let mut q = if p.is_object() { p.clone() } else { json!({}) };
if let (Some(old), None) = (&saved, str_param(p, "preset")) {
q["preset"] = json!(old.name);
}
let mut settings = resolve(C, &q, &s.prefs.pdf_presets)?;
settings.clear_passwords();
let name = match str_param(p, "newName").map(str::trim) {
Some("") => return Err(bad(C, "`newName` is empty")),
Some(n) if s.pdf_preset_taken(n, at) => return Err(bad(C, format!("a preset named `{n}` exists"))),
Some(n) => n.to_string(),
None => saved.as_ref().map_or(name, |old| old.name.clone()),
};
let description = str_param(p, "description").map(str::to_string).or_else(|| saved.map(|old| old.description)).unwrap_or_default();
let preset = PdfPreset { name: name.clone(), description, settings: settings.clone() };
match at.and_then(|i| s.prefs.pdf_presets.get_mut(i)) {
Some(slot) => *slot = preset,
None => s.prefs.pdf_presets.push(preset),
}
Ok(json!({ "name": name, "created": at.is_none(), "settings": settings }))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pdf.preset.delete";
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
if builtin_preset(name).is_some() {
return Err(bad(C, format!("`{name}` is a built-in preset and stays")));
}
let i = s.pdf_saved_index(name).ok_or_else(|| bad(C, format!("no saved PDF preset named `{name}`")))?;
Ok(json!({ "deleted": s.prefs.pdf_presets.remove(i).name }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pdf.preset.export";
let presets = match p.get("names").and_then(Value::as_array) {
Some(names) => {
let find = |n: &Value| {
let n = n.as_str().unwrap_or_default();
find_preset(n, &s.prefs.pdf_presets).ok_or_else(|| bad(C, format!("no PDF preset named `{n}`")))
};
names.iter().map(find).collect::<Result<Vec<_>>>()?
}
None => s.prefs.pdf_presets.clone(),
};
if presets.is_empty() {
return Err(bad(C, "no saved presets to export (name built-in ones in `names`)"));
}
let count = presets.len();
let text = serde_json::to_string_pretty(&PresetFile { format: PRESET_FORMAT.into(), presets }).map_err(|e| bad(C, e.to_string()))?;
match str_param(p, "path") {
Some(path) => {
super::fileio::write_file(path, text.as_bytes())?;
Ok(json!({"path": path, "count": count}))
}
None => Ok(json!({"data": text, "count": count})),
}
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "pdf.preset.import";
let bytes = match (str_param(p, "path"), str_param(p, "data"), str_param(p, "dataBase64")) {
(Some(path), ..) => super::fileio::read_file(path)?,
(None, Some(text), _) => text.as_bytes().to_vec(),
(None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "bad dataBase64"))?,
_ => return Err(bad(C, "give `path`, `data` or `dataBase64`")),
};
let file: PresetFile = serde_json::from_slice(&bytes).map_err(|e| bad(C, format!("not a PDF presets file: {e}")))?;
if file.format != PRESET_FORMAT {
return Err(bad(C, format!("not a PDF presets file (format `{}`)", file.format)));
}
// Values a hand-edited file may carry are refused as the export options refuse them.
for q in &file.presets {
q.settings.check_values().map_err(|e| bad(C, format!("`{}`: {e}", q.name)))?;
}
let replace = bool_or(p, "replace", false);
let mut imported = vec![];
for mut preset in file.presets {
preset.settings.clear_passwords();
let base = Some(preset.name.trim()).filter(|n| !n.is_empty()).unwrap_or("PDF Preset").to_string();
match s.pdf_saved_index(&base).filter(|_| replace) {
Some(i) => {
if let Some(slot) = s.prefs.pdf_presets.get_mut(i) {
preset.name = slot.name.clone();
imported.push(preset.name.clone());
*slot = preset;
}
}
None => {
preset.name = unique_name(&base, |n| s.pdf_preset_taken(n, None));
imported.push(preset.name.clone());
s.prefs.pdf_presets.push(preset);
}
}
}
Ok(json!({ "imported": imported }))
}

View File

@ -364,6 +364,10 @@ pub struct Prefs {
/// New Document → Recent: the settings of the last documents made (newest first).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recent_new_docs: Vec<cmd::newdoc::DocSettings>,
/// Edit → PDF Presets: the user's presets (the built-in ones aren't stored). A local library,
/// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub pdf_presets: Vec<vectorcraft_pdf::PdfPreset>,
}
impl Default for Prefs {
@ -489,6 +493,7 @@ impl Default for Prefs {
color_themes: vec![],
new_doc_presets: vec![],
recent_new_docs: vec![],
pdf_presets: vec![],
}
}
}
@ -991,6 +996,8 @@ mod tests_pathops;
#[cfg(test)]
mod tests_pattern;
#[cfg(test)]
mod tests_pdfpresets;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_prefs;

View File

@ -0,0 +1,137 @@
//! Edit → PDF Presets: built-in presets are read-only, saved ones apply wherever a preset is named,
//! and presets files round-trip.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::fileio::pdf::DEFAULT_PRESET;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
s
}
fn names(s: &mut Session) -> Vec<String> {
let v = s.execute("pdf.preset.list", &json!({})).unwrap();
v["presets"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect()
}
fn pdf(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
}
fn is_bad_params(r: crate::Result<Value>) -> bool {
matches!(r, Err(crate::EngineError::BadParams { .. }))
}
#[test]
fn built_in_presets_are_listed_and_read_only() {
let mut s = session();
let v = s.execute("pdf.preset.list", &json!({})).unwrap();
let rows = v["presets"].as_array().unwrap();
assert_eq!(rows[0]["name"], DEFAULT_PRESET);
assert!(rows.iter().all(|r| r["builtIn"] == true && !r["description"].as_str().unwrap().is_empty()));
let row = |n: &str| rows.iter().find(|r| r["name"] == n).unwrap_or_else(|| panic!("{n}"));
for n in ["High Quality Print", "Press Quality", "Smallest File Size", "PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert_eq!(row(n)["builtIn"], true);
}
assert_eq!(row("PDF/X-4:2010")["supported"], false, "PDF/X is not written yet");
assert_eq!(row(DEFAULT_PRESET)["settings"]["preserveEditing"], true);
// Read-only: neither changed nor deleted, in any case.
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "press quality", "compatibility": "1.4"}))));
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "default"}))));
assert!(is_bad_params(s.execute("pdf.preset.delete", &json!({"name": "Smallest File Size"}))));
assert_eq!(names(&mut s).len(), 7);
// Built-in presets apply as named.
let v = s.execute("document.pdfSettings", &json!({"preset": "Smallest File Size"})).unwrap();
assert_eq!((&v["settings"]["fastWebView"], &v["changed"]), (&json!(true), &json!([])), "{v}");
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"}))), "PDF/X is refused on export");
}
#[test]
fn saved_presets_apply_wherever_a_preset_is_named() {
let mut s = session();
let r = s
.execute("pdf.preset.save", &json!({"name": "Web", "preset": "Smallest File Size", "compatibility": "1.5", "description": "For the site"}))
.unwrap();
assert_eq!((&r["name"], &r["created"]), (&json!("Web"), &json!(true)));
assert_eq!(names(&mut s).last().map(String::as_str), Some("Web"));
// document.exportPdf, document.export, serialize and pdfSettings take it.
let v = s.execute("document.exportPdf", &json!({"preset": "web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"), "the preset's compatibility");
assert!(v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("fast web view")), "{v}");
let v = s.execute("document.export", &json!({"format": "pdf", "preset": "Web"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.5"));
let v = s.execute("document.serialize", &json!({"format": "pdf", "preset": "Web", "compatibility": "1.6"})).unwrap();
assert!(pdf(&v).starts_with(b"%PDF-1.6"), "options apply over it");
let v = s.execute("document.pdfSettings", &json!({"preset": "Web", "thumbnails": true})).unwrap();
assert_eq!(v["changed"], json!([{"option": "thumbnails", "value": true}]), "{v}");
assert!(v["presets"].as_array().unwrap().contains(&json!("Web")));
// Saving it again changes it from its own settings; it can be renamed but not to a taken name.
let r = s.execute("pdf.preset.save", &json!({"name": "Web", "thumbnails": true})).unwrap();
assert_eq!((&r["created"], &r["settings"]["compatibility"], &r["settings"]["thumbnails"]), (&json!(false), &json!("1.5"), &json!(true)));
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Press Quality"}))));
assert_eq!(s.execute("pdf.preset.save", &json!({"name": "Web", "newName": "Site"})).unwrap()["name"], "Site");
assert_eq!(s.prefs.pdf_presets[0].description, "For the site", "kept");
// Passwords are never stored; new presets get a free name.
s.execute("pdf.preset.save", &json!({"name": "Locked", "security": {"openPassword": "x"}})).unwrap();
assert!(s.prefs.pdf_presets[1].settings.security.open_password.is_empty());
assert_eq!(s.execute("pdf.preset.save", &json!({})).unwrap()["name"], "PDF Preset 1");
assert_eq!(s.execute("pdf.preset.delete", &json!({"name": "locked"})).unwrap()["deleted"], "Locked");
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "Locked"}))), "deleted");
}
#[test]
fn presets_export_and_import_round_trip() {
let mut s = session();
s.execute("pdf.preset.save", &json!({"name": "Proof", "preset": "High Quality Print", "marks": {"trim": true}})).unwrap();
let saved = s.prefs.pdf_presets.clone();
let data = s.execute("pdf.preset.export", &json!({"names": ["Proof", "Press Quality"]})).unwrap();
assert_eq!(data["count"], 2);
let text = data["data"].as_str().unwrap().to_string();
s.execute("pdf.preset.delete", &json!({"name": "Proof"})).unwrap();
let r = s.execute("pdf.preset.import", &json!({"data": text})).unwrap();
assert_eq!(r["imported"], json!(["Proof", "Press Quality 2"]), "a built-in name gets a number");
assert_eq!(s.prefs.pdf_presets[0], saved[0], "the same preset comes back");
let press = vectorcraft_pdf::builtin_preset("Press Quality").unwrap();
assert_eq!(s.prefs.pdf_presets[1].settings, press.settings);
// replace: saved presets of the same names are overwritten.
let r = s.execute("pdf.preset.import", &json!({"data": text, "replace": true})).unwrap();
assert_eq!(r["imported"], json!(["Proof", "Press Quality 3"]));
assert_eq!(s.prefs.pdf_presets.len(), 3);
// Through a file, and every saved preset by default.
let path = std::env::temp_dir().join(format!("vc-pdfpresets-{}.vcpdfpresets", std::process::id())).to_string_lossy().to_string();
assert_eq!(s.execute("pdf.preset.export", &json!({"path": path})).unwrap()["count"], 3);
let mut t = session();
assert_eq!(t.execute("pdf.preset.import", &json!({"path": path})).unwrap()["imported"].as_array().unwrap().len(), 3);
assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets);
let _ = std::fs::remove_file(&path);
// Not a presets file, or values out of range: refused, nothing added.
for bad in [
json!({"data": "{}"}),
json!({"data": "{\"format\": \"vcflattener\", \"presets\": []}"}),
json!({"data": "{\"format\": \"vcpdfpresets\", \"presets\": [{\"name\": \"X\", \"settings\": {\"marks\": {\"weight\": 99}}}]}"}),
json!({"dataBase64": "!!"}),
json!({}),
] {
assert!(is_bad_params(t.execute("pdf.preset.import", &bad)), "{bad}");
}
assert_eq!(t.prefs.pdf_presets.len(), 3);
assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export");
}
#[test]
fn saved_presets_persist_with_the_preferences() {
let mut s = session();
s.execute("pdf.preset.save", &json!({"name": "Kept", "compatibility": "1.4"})).unwrap();
let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap();
assert_eq!(back.pdf_presets, s.prefs.pdf_presets);
// Resetting the preferences keeps them (a library, not a preference).
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.pdf_presets.len(), 1);
// Preferences saved before PDF presets existed still load.
let old: crate::Prefs = serde_json::from_value(json!({"keyboardIncrement": 2.0})).unwrap();
assert!(old.pdf_presets.is_empty());
}

View File

@ -533,3 +533,60 @@ proptest! {
survive("mutated saved svg", || vectorcraft_engine::cmd::fileio::load("saved.svg", svg.as_bytes()).ok().map(|l| l.doc))?;
}
}
// ---------- PDF saved with editing data ----------
/// The rich document saved as a PDF-compatible .ai (the native document embedded, uncompressed).
fn saved_ai() -> Vec<u8> {
static AI: std::sync::OnceLock<Vec<u8>> = std::sync::OnceLock::new();
AI.get_or_init(|| {
let r = rich_session().execute("document.save", &json!({"format": "ai", "compression": {"compressText": false}})).unwrap();
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
})
.clone()
}
proptest! {
#![proptest_config(config())]
#[test]
fn ai_mutated_save_never_panics(
cut in 0usize..200_000,
flips in prop::collection::vec((0usize..200_000, prop::sample::select(vec![b'0', b'9', b'-', b'.', b' ', b'[', b']', b'<', b'>', b'/', b'(', b'{', b'"', 0u8, 0xff])), 0..10),
) {
let mut b = saved_ai();
for (i, c) in flips {
let n = b.len();
b[i % n] = c;
}
b.truncate(cut.min(b.len()).max(9));
survive("mutated ai", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc))?;
}
}
// ---------- PDF presets files ----------
/// Import PDF presets from `data`, then export with each one imported (and save the .ai).
fn pdf_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "pdf.preset.import", data, |s, r| {
for name in r["imported"].as_array().into_iter().flatten() {
let _ = s.execute("document.exportPdf", &json!({"preset": name}));
let _ = s.execute("document.save", &json!({"format": "ai", "preset": name}));
}
})
}
proptest! {
#![proptest_config(config())]
#[test]
fn pdf_presets_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "{\"format\": \"vcpdfpresets\", ", "{\"format\": \"vcpdfpresets\", \"presets\": [{\"name\": \"x\", \"settings\": "])) {
pdf_presets("pdf presets garbage", &format!("{head}{s}"))?;
}
#[test]
fn mutated_pdf_presets_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) {
let text = saved("pdf.preset.export", json!({"names": ["VectorCraft Default", "Smallest File Size", "PDF/X-4:2010"]}));
pdf_presets("mutated pdf presets", &mutate_text(&text, cut, &edits))?;
}
}

View File

@ -26,8 +26,12 @@ impl Default for Headless {
/// `run_command` behaves the same in both modes.
const HOST_COMMANDS: &[(&str, &str, &str)] = &[
("file.open", "Open…", "{path} open any readable file (see document.formats) as a new document; templates open untitled"),
("file.save", "Save", "{path?} save as .vectorcraft (default: the document's path)"),
("file.saveAs", "Save As…", "{path}"),
(
"file.save",
"Save",
"{path?, format?} = document.save: .vectorcraft, .ai (a PDF that reopens editable), .svg or .svgz (default: the document's path)",
),
("file.saveAs", "Save As…", "{path, format?, …the format's options} = document.save"),
("file.export", "Export…", "{path?, format?, artboard?, range?, scale?, …} = document.export (no path → dataBase64)"),
("file.exportForScreens", "Export for Screens…", "{folder?, artboards? | range?, formats?, prefix?} = document.exportForScreens"),
("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"),

123
crates/pdf/src/editing.rs Normal file
View File

@ -0,0 +1,123 @@
//! Preserve Editing: the native document travels inside the PDF as an embedded file, with a hash
//! of the pages it was written with, so a PDF whose pages another app changed is noticed (and
//! opens as its artwork instead of the stale native document).
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, Object, Stream, String as PdfString};
use krilla::embed::{AssociationKind, EmbeddedFile, MimeType};
use krilla::metadata::DateTime;
use crate::PdfError;
use crate::lab_spot::find;
/// The embedded file holding the native document (its name among the PDF's attachments).
pub const EDITING_FILE: &str = "vectorcraft-editing.vectorcraft";
/// The editing file's name from before the project was renamed: still read.
pub const LEGACY_EDITING_FILE: &str = "drawcraft-editing.drawcraft";
/// The editing file's description: this text, then the hash of the pages.
const DESCRIPTION: &str = "VectorCraft editing data; page hash ";
/// Stands for the hash while the PDF is written: as long as a hash, so writing the hash over it
/// keeps every offset.
const PLACEHOLDER: &str = "0000000000000000";
/// Deepest name tree searched for the editing file.
const MAX_DEPTH: usize = 16;
/// The native document a PDF carries ([`crate::PdfSettings::preserve_editing`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Editing {
/// The native document's bytes (empty when they can't be decoded).
pub data: Vec<u8>,
/// The pages are still the ones written with it: the hash of their size and content matches.
/// False once another app changed them; true for editing data written without a hash.
pub intact: bool,
}
/// The embedded file for `native`, its description holding the hash placeholder [`seal`] fills.
pub(crate) fn embedded_file(native: &[u8], compress: bool, date: Option<DateTime>) -> EmbeddedFile {
EmbeddedFile {
path: EDITING_FILE.into(),
mime_type: MimeType::new("application/json"),
description: Some(format!("{DESCRIPTION}{PLACEHOLDER}")),
association_kind: AssociationKind::Source,
data: native.to_vec().into(),
modification_date: date,
compress: Some(compress),
location: None,
}
}
/// Write the hash of the pages of `pdf` (written with an [`embedded_file`]) over the placeholder.
pub(crate) fn seal(pdf: Vec<u8>) -> Result<Vec<u8>, PdfError> {
let data = Arc::new(pdf);
let hash = page_hash(&Pdf::new(data.clone()).map_err(|e| PdfError::Write(format!("can't read back the written PDF: {e:?}")))?);
let mut pdf = Arc::try_unwrap(data).unwrap_or_else(|d| (*d).clone());
let at = find(&pdf, format!("({DESCRIPTION}{PLACEHOLDER})").as_bytes(), 0).map(|i| i + 1 + DESCRIPTION.len());
let slot =
at.and_then(|i| pdf.get_mut(i..i + PLACEHOLDER.len())).ok_or_else(|| PdfError::Write("the editing data's hash has no place".into()))?;
slot.copy_from_slice(hash.as_bytes());
Ok(pdf)
}
/// The native document `bytes` (a PDF) carries, if any, and whether its pages changed since.
pub fn editing(bytes: &[u8]) -> Option<Editing> {
editing_in(&Pdf::new(bytes.to_vec()).ok()?)
}
/// [`editing`] of a parsed PDF.
pub(crate) fn editing_in(pdf: &Pdf) -> Option<Editing> {
let xref = pdf.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id())?;
let tree = catalog.get::<Dict<'_>>(b"Names")?.get::<Dict<'_>>(b"EmbeddedFiles")?;
let spec = editing_spec(&tree, 0)?;
let files = spec.get::<Dict<'_>>(b"EF")?;
let stream = files.get::<Stream<'_>>(b"F").or_else(|| files.get::<Stream<'_>>(b"UF"))?;
let data = stream.decoded().map(|d| d.into_owned()).unwrap_or_default();
// The hash ends the description; editing data described otherwise is trusted.
let desc = spec.get::<PdfString<'_>>(b"Desc").map(|d| String::from_utf8_lossy(d.as_bytes()).into_owned()).unwrap_or_default();
let intact = match desc.rsplit_once("hash ") {
Some((_, hash)) => hash.trim() == page_hash(pdf),
None => true,
};
Some(Editing { data, intact })
}
/// The file specification of the editing file in the name tree node `node`.
fn editing_spec<'a>(node: &Dict<'a>, depth: usize) -> Option<Dict<'a>> {
if let Some(names) = node.get::<Array<'a>>(b"Names") {
let mut items = names.iter::<Object<'a>>();
while let (Some(_), Some(spec)) = (items.next(), items.next()) {
if let Object::Dict(spec) = spec
&& is_editing_file(&spec)
{
return Some(spec);
}
}
}
if depth >= MAX_DEPTH {
return None;
}
node.get::<Array<'a>>(b"Kids")?.iter::<Dict<'a>>().find_map(|kid| editing_spec(&kid, depth + 1))
}
/// A file specification naming the editing file (current or legacy name).
fn is_editing_file(spec: &Dict<'_>) -> bool {
[&b"UF"[..], b"F"].into_iter().filter_map(|k| spec.get::<PdfString<'_>>(k)).any(|name| {
let name = name.as_bytes();
name == EDITING_FILE.as_bytes() || name == LEGACY_EDITING_FILE.as_bytes()
})
}
/// FNV-1a of the pages' media boxes and decoded content, as 16 hex digits.
fn page_hash(pdf: &Pdf) -> String {
let fnv = |h: u64, bytes: &[u8]| bytes.iter().fold(h, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x100000001b3));
let mut h = 0xcbf29ce484222325;
for page in pdf.pages().iter() {
let b = page.media_box();
h = fnv(h, format!("[{} {} {} {}]", b.x0, b.y0, b.x1, b.y1).as_bytes());
h = fnv(h, page.page_stream().unwrap_or_default());
}
format!("{h:016x}")
}

View File

@ -81,10 +81,19 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
if !info.keywords.is_empty() {
meta = meta.keywords(info.keywords.clone());
}
if let Some(t) = opts.created.or_else(vectorcraft_doc::metadata::now_unix) {
meta = meta.creation_date(date_time(t));
let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix).map(date_time);
if let Some(t) = created {
meta = meta.creation_date(t);
}
pdf.set_metadata(meta);
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
let mut warnings = set.warnings();
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
if let Some(native) = native {
pdf.embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created));
} else if set.preserve_editing {
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
}
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new(), brushes: None, knockout: doc.page_knockout, lab_spots: vec![] };
// CMYK documents blend in CMYK, as on screen: their groups' blending space is rewritten (see
@ -122,7 +131,7 @@ 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 = set.warnings();
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
warnings.extend(ex.warnings);
warnings.dedup();
Ok(ExportReport { bytes, warnings })

View File

@ -122,7 +122,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
}
}
}
Ok(ImportReport { document: doc, warnings })
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf) })
}
/// Is this art text and nothing else (in groups and clips), with some text?

View File

@ -7,7 +7,8 @@
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
//! 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.
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! - [`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,18 +16,24 @@
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
//! CMYK document. [`ImportOptions`] pick the pages, the box each
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
//! The native document a PDF carries comes back too ([`ImportReport::native`], [`editing()`]).
//! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]).
#![forbid(unsafe_code)]
mod editing;
mod export;
mod import;
mod import_color;
mod lab_spot;
mod pages;
mod presets;
mod settings;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing};
pub use export::{export, export_with_report};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use presets::*;
pub use settings::*;
use vectorcraft_doc::Document;
@ -41,6 +48,8 @@ pub struct PdfOptions {
pub title: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
}
impl PdfOptions {
@ -93,6 +102,8 @@ settings::choice! {
pub struct ImportReport {
pub document: Document,
pub warnings: Vec<String>,
/// The native document the PDF carries (written with Preserve Editing), if any.
pub native: Option<Editing>,
}
/// Result of an export with non-fatal warnings (features approximated or dropped).
@ -145,6 +156,8 @@ mod tests_cmykblend;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_editing;
#[cfg(test)]
mod tests_focal;
#[cfg(test)]
mod tests_fx;
@ -153,6 +166,8 @@ mod tests_import_color;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_presets;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stroke;

103
crates/pdf/src/presets.rs Normal file
View File

@ -0,0 +1,103 @@
//! PDF presets: named [`PdfSettings`]. The built-in ones are generated here (the app default,
//! which keeps the document editable, plus presets for print, press, small files and the PDF/X
//! standards); the user's own live in the app's preferences.
use serde::{Deserialize, Serialize};
use crate::{
BleedSettings, Compatibility, CompressionSettings, Downsample, ImageCodec, ImageCompression, JpegQuality, MonoCodec, MonoCompression,
PdfSettings, Standard,
};
/// The built-in preset every PDF export starts from.
pub const DEFAULT_PRESET: &str = "VectorCraft Default";
/// A named set of PDF settings: a built-in preset or one the user saved.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PdfPreset {
pub name: String,
/// What the preset is for.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default)]
pub settings: PdfSettings,
}
/// Colour and greyscale images resampled (bicubic) to `ppi` above `above` ppi, compressed with
/// `codec`.
fn images(ppi: f64, above: f64, codec: ImageCodec, quality: JpegQuality) -> ImageCompression {
ImageCompression { downsample: Downsample::Bicubic, ppi, above_ppi: above, compression: codec, quality }
}
/// Compression with the same settings for colour and greyscale images, and CCITT Group 4
/// monochrome images resampled to `mono_ppi` above 1.5 × that.
fn compression(img: ImageCompression, mono_ppi: f64) -> CompressionSettings {
let mono = MonoCompression { downsample: Downsample::Bicubic, ppi: mono_ppi, above_ppi: mono_ppi * 1.5, compression: MonoCodec::CcittG4 };
CompressionSettings { color: img.clone(), gray: img, mono, compress_text: true }
}
/// Settings for print: PDF 1.6, images kept at 300 ppi, the document's bleed when `bleed`.
fn print(codec: ImageCodec, bleed: bool) -> PdfSettings {
PdfSettings {
compatibility: Compatibility::Pdf16,
compression: compression(images(300.0, 450.0, codec, JpegQuality::Maximum), 1200.0),
bleed: BleedSettings { use_document: bleed, ..Default::default() },
..Default::default()
}
}
/// A PDF/X preset: print settings for `standard` at PDF version `compatibility`.
fn pdf_x(standard: Standard, compatibility: Compatibility) -> PdfSettings {
PdfSettings { standard, compatibility, ..print(ImageCodec::Auto, true) }
}
/// Every built-in preset, the app default first.
pub fn builtin_presets() -> Vec<PdfPreset> {
let preset = |name: &str, description: &str, settings| PdfPreset { name: name.into(), description: description.into(), settings };
vec![
preset(
DEFAULT_PRESET,
"Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again.",
PdfSettings { preserve_editing: true, ..Default::default() },
),
preset("High Quality Print", "For desktop printers and proofs: images stay at print resolution.", print(ImageCodec::Auto, false)),
preset(
"Press Quality",
"For commercial printing: high-resolution images compressed losslessly, the document's bleed.",
print(ImageCodec::Zip, true),
),
preset(
"Smallest File Size",
"For screens, email and the web: images resampled to 100 ppi and compressed.",
PdfSettings {
compatibility: Compatibility::Pdf16,
fast_web_view: true,
compression: compression(images(100.0, 150.0, ImageCodec::Jpeg, JpegQuality::Medium), 300.0),
..Default::default()
},
),
preset(
"PDF/X-1a:2001",
"Print exchange with CMYK and spot colours only (PDF/X output is not supported yet).",
pdf_x(Standard::PdfX1a, Compatibility::Pdf14),
),
preset(
"PDF/X-3:2002",
"Print exchange allowing colour-managed colours (PDF/X output is not supported yet).",
pdf_x(Standard::PdfX3, Compatibility::Pdf14),
),
preset(
"PDF/X-4:2010",
"Print exchange keeping live transparency and layers (PDF/X output is not supported yet).",
pdf_x(Standard::PdfX4, Compatibility::Pdf16),
),
]
}
/// The built-in preset `name` names (any case; `default` is the app default).
pub fn builtin_preset(name: &str) -> Option<PdfPreset> {
let name = name.trim();
let name = if name.eq_ignore_ascii_case("default") { DEFAULT_PRESET } else { name };
builtin_presets().into_iter().find(|p| p.name.eq_ignore_ascii_case(name))
}

View File

@ -387,18 +387,27 @@ fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfErr
}
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.
/// Refuse settings the writer can't honour: an unsupported standard, a password (the file
/// would not be protected), or what [`Self::check_values`] refuses.
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.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()));
}
self.check_values()
}
/// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
/// an out-of-range value.
pub fn check_values(&self) -> Result<(), PdfError> {
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()));
if self.preserve_editing && self.standard == Standard::PdfA2b {
return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label())));
}
let c = &self.compression;
for (name, img) in
@ -415,6 +424,12 @@ impl PdfSettings {
within("advanced.fontSubsetPercent", self.advanced.font_subset_percent, 0.0, 100.0, "%")
}
/// Forget the passwords (presets never store them).
pub fn clear_passwords(&mut self) {
self.security.open_password.clear();
self.security.permissions_password.clear();
}
/// Options that are accepted but not applied by the writer yet, one warning each.
pub fn warnings(&self) -> Vec<String> {
let d = Self::default();
@ -422,7 +437,6 @@ impl PdfSettings {
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"),

View File

@ -0,0 +1,90 @@
//! Preserve Editing: the native document as an embedded file, found again (also under its legacy
//! name) and checked against the pages it was written with.
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_geom::{Rect, shapes};
use crate::*;
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
}
/// The document's PDF with `native` embedded (content streams and the payload uncompressed when
/// `compress` is false).
fn pdf_with(native: Option<&[u8]>, compress: bool) -> ExportReport {
let mut settings: PdfSettings = serde_json::from_value(json!({"preserveEditing": true})).unwrap();
settings.compression.compress_text = compress;
export_with_report(&doc(), &PdfOptions { settings, native: native.map(<[u8]>::to_vec), created: Some(0), ..Default::default() }).unwrap()
}
/// `pdf` with `from` replaced by `to` (the same length, so the cross-reference stays valid).
fn patched(pdf: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
assert_eq!(from.len(), to.len());
let at = pdf.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("no {:?}", String::from_utf8_lossy(from)));
let mut out = pdf.to_vec();
out[at..at + to.len()].copy_from_slice(to);
out
}
#[test]
fn the_native_document_comes_back_intact() {
for compress in [true, false] {
let r = pdf_with(Some(b"{\"native\": true}"), compress);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
assert!(r.bytes.starts_with(b"%PDF-"));
let e = editing(&r.bytes).unwrap();
assert_eq!((e.data.as_slice(), e.intact), (&b"{\"native\": true}"[..], true));
// The import reports it too, and the pages still import as artwork.
let imported = import_with_report(&r.bytes, &ImportOptions::default()).unwrap();
assert_eq!(imported.native, Some(e));
assert_eq!(imported.document.artboards.len(), 1);
}
}
#[test]
fn changed_pages_are_noticed() {
let r = pdf_with(Some(b"native"), false);
let resized = patched(&r.bytes, b"/MediaBox[0 0 100 100]", b"/MediaBox[0 0 100 101]");
assert!(!editing(&resized).unwrap().intact, "another page size");
// Drawing changed elsewhere: the rectangle's first corner moves.
let moved = patched(&r.bytes, b"10 10 m", b"10 11 m");
assert!(!editing(&moved).unwrap().intact, "other page content");
// The description (not part of the hash) can change: re-saving keeps the data usable.
assert!(editing(&r.bytes).unwrap().intact);
}
#[test]
fn the_legacy_name_is_read_and_plain_pdfs_have_no_editing_data() {
let r = pdf_with(Some(b"legacy"), false);
let name = format!("({EDITING_FILE})");
let legacy = format!("({LEGACY_EDITING_FILE}){}", " ".repeat(EDITING_FILE.len() - LEGACY_EDITING_FILE.len()));
let mut old = r.bytes.clone();
// Every place the name is written (/F, /UF and the name tree key).
while let Some(at) = old.windows(name.len()).position(|w| w == name.as_bytes()) {
old[at..at + legacy.len()].copy_from_slice(legacy.as_bytes());
}
let e = editing(&old).unwrap();
assert_eq!((e.data.as_slice(), e.intact), (&b"legacy"[..], true));
// Off, or with no native document given: nothing embedded (the latter warns).
let plain = export(&doc(), &PdfOptions::default()).unwrap();
assert_eq!(editing(&plain), None);
assert_eq!(import_with_report(&plain, &ImportOptions::default()).unwrap().native, None);
let r = pdf_with(None, true);
assert!(r.warnings.len() == 1 && r.warnings[0].contains("Preserve editing"), "{:?}", r.warnings);
assert_eq!(editing(&r.bytes), None);
}
#[test]
fn pdf_a_carries_no_editing_data() {
let s: PdfSettings = serde_json::from_value(json!({"standard": "pdfA2b", "preserveEditing": true})).unwrap();
assert!(matches!(s.check(), Err(PdfError::BadSetting(m)) if m.contains("editing")));
assert!(matches!(s.check_values(), Err(PdfError::BadSetting(_))));
}

View File

@ -0,0 +1,22 @@
//! The built-in PDF presets: generated in code, valid, described, and round-tripping as JSON.
use crate::*;
#[test]
fn built_in_presets() {
let all = builtin_presets();
assert_eq!(all[0].name, DEFAULT_PRESET);
assert!(all[0].settings.preserve_editing, "the app default keeps the document editable");
assert_eq!(all.len(), 7);
for p in &all {
assert_eq!(p.settings.check_values(), Ok(()), "{}", p.name);
assert!(!p.description.is_empty());
let back: PdfPreset = serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap();
assert_eq!(&back, p, "round-trips");
}
// PDF/X presets name standards the writer doesn't produce yet.
let x4 = builtin_preset("pdf/x-4:2010").unwrap();
assert!(matches!(x4.settings.check(), Err(PdfError::Unsupported(_))));
assert_eq!(builtin_preset("default").unwrap().name, DEFAULT_PRESET);
assert!(builtin_preset("Nope").is_none());
}

View File

@ -71,7 +71,6 @@ fn checks_refuse_what_the_writer_cant_honour() {
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"),

View File

@ -32,6 +32,7 @@ pub mod new_color_group;
mod new_document;
pub mod new_swatch;
mod path_ops;
pub mod pdf_presets;
pub mod place;
mod png_options;
pub mod raster_effects;
@ -61,7 +62,7 @@ pub use effect::open as open_effect_dialog;
pub use export_as::open as open_export_as;
pub use new_document::{open as open_new_document, preset_card};
pub use png_options::open as open_raster_options;
pub use save_pdf::open as open_save_pdf;
pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset};
pub use tools::open_tool_dialog;
use crate::state::Dialog;
@ -198,6 +199,8 @@ registry! {
RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC,
MissingLinks: [missing_links::KIND] => missing_links::SPEC,
TextImport: [text_import::KIND] => text_import::SPEC,
PdfPresets: [pdf_presets::KIND] => pdf_presets::SPEC,
PdfPreset: [save_pdf::PRESET_KIND] => save_pdf::PRESET_SPEC,
}
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).

View File

@ -0,0 +1,316 @@
//! Edit → PDF Presets: every preset in a list (built-in ones in brackets, read only) with the
//! selected one's description and how its settings differ from the app default beside it; New…
//! (a preset starting from the selected one), Edit… (saved presets), Delete, Import… and Export…
//! (the selected preset) under the list. New… and Edit… open the preset editor (the Save PDF
//! dialog, kind `pdfPreset`), whose OK comes back here.
//!
//! Field: `selected` (a preset's name).
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::pdf;
use vectorcraft_engine::cmd::pdfcmds::PRESET_FORMAT;
use super::DialogSpec;
use super::save_pdf::{open_preset, option_label, section_of};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of PDF Presets.
pub const KIND: &str = "pdfPresets";
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| "PDF Presets".into(),
body,
confirm: |app, _| {
app.ui.dialog = None;
Ok(Value::Null)
},
ok: None,
min_width: 680.0,
..DialogSpec::FORM
};
/// Open the presets manager on `selected` (default: the app default).
pub fn open(app: &mut VectorcraftApp, selected: Option<&str>) {
app.ui.dialog = Some(Dialog::new(KIND, json!({ "selected": selected.unwrap_or(pdf::DEFAULT_PRESET) })));
}
/// What a button asked for, done once the list and the details are drawn.
enum Action {
Select(String),
New,
Edit,
Delete,
Import,
Export,
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let presets = app.session.pdf_presets();
let builtins = presets.len() - app.session.prefs.pdf_presets.len();
let i = presets.iter().position(|p| p.name.eq_ignore_ascii_case(d.str("selected").trim())).unwrap_or(0);
let Some(preset) = presets.get(i) else { return false };
let builtin = i < builtins;
let mut act = None;
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(300.0);
widgets::dim_label(ui, "Presets:");
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("pdf-presets").min_scrolled_height(260.0).max_height(260.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(260.0);
for (k, p) in presets.iter().enumerate() {
let label = if k < builtins { format!("[{}]", p.name) } else { p.name.clone() };
if ui.selectable_label(k == i, label).clicked() {
act = Some(Action::Select(p.name.clone()));
}
}
});
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
if widgets::flat_button(ui, "New…", 48.0).on_hover_text("A new preset starting from the selected one").clicked() {
act = Some(Action::New);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Edit…", 48.0)).inner.clicked() {
act = Some(Action::Edit);
}
if ui.add_enabled_ui(!builtin, |ui| widgets::flat_button(ui, "Delete", 52.0)).inner.clicked() {
act = Some(Action::Delete);
}
if widgets::flat_button(ui, "Import…", 64.0).clicked() {
act = Some(Action::Import);
}
if widgets::flat_button(ui, "Export…", 64.0).on_hover_text("Save the selected preset to a file").clicked() {
act = Some(Action::Export);
}
});
});
ui.add_space(18.0);
ui.vertical(|ui| {
ui.set_width(340.0);
ui.label(egui::RichText::new(&preset.name).color(t.text_strong).strong());
if !preset.description.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new(&preset.description).color(t.text));
}
ui.add_space(10.0);
details(ui, &t, preset, builtin);
});
});
if let Some(a) = act
&& let Err(e) = run(app, d, a, &preset.name)
{
app.status(e);
}
false
}
/// What sets the preset apart: its settings that differ from the app default, and the notes on
/// what Save PDF can't honour or apply yet.
fn details(ui: &mut egui::Ui, t: &Tokens, preset: &vectorcraft_pdf::PdfPreset, builtin: bool) {
let dim = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5));
widgets::dim_label(ui, &format!("Differences from {}:", pdf::DEFAULT_PRESET));
let base = vectorcraft_pdf::builtin_preset(pdf::DEFAULT_PRESET).map(|p| p.settings).unwrap_or_default();
let mut changed = pdf::changes(&preset.settings, &base);
changed.sort_by_key(|c| section_of(c["option"].as_str().unwrap_or_default()));
egui::ScrollArea::vertical().id_salt("pdf-preset-details").max_height(180.0).show(ui, |ui| {
if changed.is_empty() {
dim(ui, "None.");
}
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));
}
});
ui.add_space(6.0);
if let Err(e) = preset.settings.check() {
dim(ui, &format!("⚠ {e}"));
}
for w in preset.settings.warnings() {
dim(ui, &format!("⚠ {w}"));
}
if builtin {
ui.add_space(6.0);
dim(ui, "Built-in presets are read-only: New… starts an editable copy.");
}
}
/// Select the preset `name`.
fn select(d: &mut Dialog, name: &str) {
d.fields.insert("selected".into(), json!(name));
}
fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> Result<(), String> {
match act {
Action::Select(name) => select(d, &name),
// The editor replaces this dialog; its OK comes back here.
Action::New => {
open_preset(app, &json!({ "preset": current }))?;
*d = app.ui.dialog.take().ok_or("no preset editor")?;
}
Action::Edit => {
open_preset(app, &json!({ "name": current }))?;
*d = app.ui.dialog.take().ok_or("no preset editor")?;
}
Action::Delete => {
let i = app.session.prefs.pdf_presets.iter().position(|p| p.name == current);
app.run("pdf.preset.delete", json!({ "name": current }))?;
// The one above it takes its place (the last built-in one above the first saved one).
let builtins = vectorcraft_pdf::builtin_presets().len();
let next = app.session.pdf_presets().get((i.unwrap_or(0) + builtins).saturating_sub(1)).map(|p| p.name.clone());
select(d, &next.unwrap_or_default());
}
Action::Import => {
// On the web the picked file arrives later and imports through `io::open_bytes`.
if let Some(f) = app.services.open_async.as_mut() {
f();
return Ok(());
}
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
}
}
Action::Export => {
crate::io::save_command_output(app, "pdf.preset.export", PRESET_FORMAT, json!({ "names": [current] }))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn frame(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
crate::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 kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
/// Press a manager button on the preset `current`, as the body does.
fn press(app: &mut VectorcraftApp, act: Action, current: &str) {
let mut d = app.ui.dialog.take().unwrap();
run(app, &mut d, act, current).unwrap();
app.ui.dialog = Some(d);
frame(app);
}
#[test]
fn new_edit_and_delete_go_through_the_preset_editor() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("ui.pdfPresetsDialog", json!({"selected": "Press Quality"})).unwrap();
frame(&mut app);
assert_eq!(kind(&app), Some(KIND));
// New… opens the editor on a copy of the selected preset; its OK saves it and comes back.
press(&mut app, Action::New, "Press Quality");
assert_eq!(kind(&app), Some("pdfPreset"));
let d = app.ui.dialog.as_mut().unwrap();
assert_eq!((d.str("name"), d.fields["compatibility"].clone()), ("PDF Preset 1".to_string(), json!("1.6")));
d.fields.insert("name".into(), json!("Press 1.5"));
d.fields.insert("compatibility".into(), json!("1.5"));
d.fields.insert("description".into(), json!("Mine"));
for s in super::super::save_pdf::SECTIONS {
app.ui.dialog.as_mut().unwrap().fields.insert("__section".into(), json!(s));
frame(&mut app);
}
let r = super::super::confirm(&mut app).unwrap();
assert_eq!(r["name"], "Press 1.5");
assert_eq!((kind(&app), app.ui.dialog.as_ref().unwrap().str("selected")), (Some(KIND), "Press 1.5".to_string()));
let saved = &app.session.prefs.pdf_presets[0];
assert_eq!((saved.settings.compatibility.id(), saved.description.as_str()), ("1.5", "Mine"));
assert!(saved.settings.bleed.use_document, "the rest comes from Press Quality");
frame(&mut app);
// Edit… renames it; a new preset can't take a taken name.
press(&mut app, Action::Edit, "Press 1.5");
assert_eq!(app.ui.dialog.as_ref().unwrap().str("__editing"), "Press 1.5");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Press"));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.pdf_presets[0].name, "Press");
press(&mut app, Action::New, "Press");
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("press"));
assert!(super::super::confirm(&mut app).is_err());
assert_eq!(kind(&app), Some("pdfPreset"), "stays open on a taken name");
// Built-in presets can't be edited; Delete selects the preset above.
assert!(open_preset(&mut app, &json!({"name": "Press Quality"})).is_err());
open(&mut app, Some("Press"));
press(&mut app, Action::Delete, "Press");
assert!(app.session.prefs.pdf_presets.is_empty());
assert_eq!(app.ui.dialog.as_ref().unwrap().str("selected"), "PDF/X-4:2010");
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "Close");
}
#[test]
fn save_pdf_saves_its_settings_as_a_preset() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("ui.savePdfDialog", json!({"preset": "High Quality Print", "compatibility": "1.5"})).unwrap();
frame(&mut app);
// Save Preset… asks for a name; OK (or Enter) then saves the preset, not the PDF.
app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("Proofs"));
frame(&mut app);
let r = super::super::confirm(&mut app).unwrap();
assert_eq!(r["name"], "Proofs");
let d = app.ui.dialog.as_ref().expect("Save PDF stays open");
assert_eq!((d.kind.as_str(), d.str("preset")), ("savePdf", "Proofs".to_string()));
assert!(!d.fields.contains_key("__savePresetAs"));
assert!(d.fields["__presets"].as_array().unwrap().contains(&json!("Proofs")));
let saved = &app.session.prefs.pdf_presets[0].settings;
assert_eq!(saved.compatibility.id(), "1.5");
assert_eq!(
saved,
&vectorcraft_pdf::PdfSettings {
compatibility: saved.compatibility,
..vectorcraft_pdf::builtin_preset("High Quality Print").unwrap().settings
}
);
// A built-in name is refused and the row stays.
app.ui.dialog.as_mut().unwrap().fields.insert("__savePresetAs".into(), json!("Press Quality"));
assert!(super::super::confirm(&mut app).is_err());
frame(&mut app);
assert_eq!(app.session.prefs.pdf_presets.len(), 1);
}
#[test]
fn import_and_export_go_through_files() {
let dir = std::env::temp_dir().join(format!("vc-pdfpresets-ui-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("mine.vcpdfpresets").to_string_lossy().to_string();
let picked = file.clone();
let services = crate::Services {
pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
};
let mut app = VectorcraftApp::new(Session::new(), services);
app.run("pdf.preset.save", json!({"name": "Mine", "thumbnails": true})).unwrap();
app.run("ui.pdfPresetsDialog", json!({"selected": "Mine"})).unwrap();
frame(&mut app);
press(&mut app, Action::Export, "Mine");
// Opening the file imports it (also how the web's picked file arrives).
let bytes = std::fs::read(&file).unwrap();
crate::io::open_bytes(&mut app, &file, &bytes, Some(file.clone())).unwrap();
let names: Vec<&str> = app.session.prefs.pdf_presets.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Mine", "Mine 2"]);
assert!(app.session.prefs.pdf_presets[1].settings.thumbnails);
assert!(app.ui.status.contains("PDF presets"), "{}", app.ui.status);
let _ = std::fs::remove_dir_all(dir);
}
}

View File

@ -4,7 +4,10 @@
//! The fields are the `document.exportPdf` options (sections are objects such as `compression`),
//! plus `preset`, `range`, `path?` and UI-only `__` keys, so agents fill the dialog with
//! `ui.dialog.set`. OK runs `document.exportPdf` through [`io::export_pdf`]; the Summary is
//! `document.pdfSettings`.
//! `document.pdfSettings`. Save Preset… names the settings as a preset (`pdf.preset.save`).
//!
//! The same dialog edits a preset (kind `pdfPreset`, from Edit → PDF Presets): a `name` and
//! `description` instead of the preset and artboards, and OK saves the preset.
use std::sync::LazyLock;
@ -25,7 +28,23 @@ pub(super) const KIND: &str = "savePdf";
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Save PDF".into(), body, confirm, ok: Some("Save PDF"), ..DialogSpec::FORM };
const SECTIONS: [&str; 7] = ["General", "Compression", "Marks and Bleeds", "Output", "Advanced", "Security", "Summary"];
/// The dialog kind of the preset editor (New / Edit in Edit → PDF Presets).
pub(super) const PRESET_KIND: &str = "pdfPreset";
pub(super) const PRESET_SPEC: DialogSpec = DialogSpec {
heading: |d| if d.str(EDITING).is_empty() { "New PDF Preset" } else { "Edit PDF Preset" }.into(),
body,
confirm: confirm_preset,
ok: Some("Save Preset"),
..DialogSpec::FORM
};
/// The preset editor's field holding the name of the saved preset it edits (empty: a new one).
const EDITING: &str = "__editing";
/// Save PDF's field holding the name typed for Save Preset… (present while that row shows).
const SAVE_AS: &str = "__savePresetAs";
pub(super) 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<Value> = LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default());
@ -46,10 +65,10 @@ 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 mut fields = settings_fields(app, 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("__presets".into(), json!(pdf::presets(&app.session)));
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("")));
@ -60,9 +79,10 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
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())?;
/// The settings `params` ask for (their preset, built-in or saved, with their options applied) as
/// dialog fields. A standard the writer can't produce yet still shows (Save PDF then says so).
fn settings_fields(app: &VectorcraftApp, params: &Value) -> Result<Map<String, Value>, String> {
let settings = pdf::resolve("ui.savePdfDialog", params, &app.session.prefs.pdf_presets).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()),
@ -81,7 +101,14 @@ fn params(d: &Dialog) -> Value {
}
/// Save PDF: write the file; the dialog stays open when that fails (bad range, cancelled save…).
/// While Save Preset… asks for a name, OK (and Enter) saves the preset instead.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
if d.fields.contains_key(SAVE_AS) {
let mut d = d.clone();
let r = save_preset(app, &mut d);
app.ui.dialog = Some(d);
return r;
}
let r = io::export_pdf(app, params(d));
if r.is_ok() {
app.ui.dialog = None;
@ -89,6 +116,67 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
r
}
/// The settings as `pdf.preset.save` params (without the artboards and path).
fn preset_params(d: &Dialog) -> Value {
let mut p = form::params(d);
if let Some(o) = p.as_object_mut() {
for k in ["range", "path"] {
o.remove(k);
}
}
p
}
/// Save Preset…: save the settings under the typed name and pick that preset.
fn save_preset(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<Value, String> {
let mut p = preset_params(d);
p["name"] = json!(d.str(SAVE_AS));
let r = app.run("pdf.preset.save", p)?;
d.fields.remove(SAVE_AS);
d.fields.insert("preset".into(), r["name"].clone());
d.fields.insert("__presets".into(), json!(pdf::presets(&app.session)));
Ok(r)
}
/// Open the preset editor on the saved preset `name`, or on a new preset starting from `preset`
/// (default: the app default).
pub fn open_preset(app: &mut VectorcraftApp, params: &Value) -> Result<Value, String> {
let s = |k: &str| params.get(k).and_then(Value::as_str);
let (base, name, description, editing) = match s("name") {
Some(name) => {
let p = app.session.prefs.pdf_presets.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim()));
let p = p.ok_or_else(|| format!("no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them)"))?;
(p.name.clone(), p.name.clone(), p.description.clone(), p.name.clone())
}
None => (s("preset").unwrap_or(pdf::DEFAULT_PRESET).to_string(), app.session.new_pdf_preset_name(), String::new(), String::new()),
};
let mut fields = settings_fields(app, &json!({ "preset": base }))?;
fields.insert("name".into(), json!(name));
fields.insert("description".into(), json!(description));
fields.insert(EDITING.into(), json!(editing));
fields.insert("__section".into(), json!(SECTIONS[0]));
app.ui.dialog = Some(Dialog { kind: PRESET_KIND.into(), fields });
Ok(Value::Null)
}
/// Save Preset (the preset editor's OK): `pdf.preset.save`, then back to Edit → PDF Presets on it.
/// A new preset may not take the name of another one.
fn confirm_preset(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut p = preset_params(d);
let (name, editing) = (d.str("name"), d.str(EDITING));
if editing.is_empty() {
if app.session.pdf_presets().iter().any(|q| q.name.eq_ignore_ascii_case(name.trim())) {
return Err(format!("a preset named `{}` exists", name.trim()));
}
} else {
p["name"] = json!(editing);
p["newName"] = json!(name);
}
let r = app.run("pdf.preset.save", p)?;
super::pdf_presets::open(app, r["name"].as_str());
Ok(r)
}
// ---------- fields by path (`compression.color.ppi`) ----------
fn lookup<'a>(m: &'a Map<String, Value>, path: &str) -> Option<&'a Value> {
@ -203,15 +291,14 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) {
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).and_then(|i| presets.get(i)).map(|p| p.to_string());
if let Some(name) = chosen {
apply_preset(app, d, &name);
}
});
let editor = d.kind == PRESET_KIND;
if editor {
row_with(ui, "Preset name:", TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, "name", 288.0);
});
} else {
preset_rows(app, ui, d);
}
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();
@ -219,6 +306,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
// The standard's own version (PDF/A-2b is a PDF 1.7 standard).
set(d, "compatibility", json!(Compatibility::Pdf17.id()));
}
if picked && standard != Standard::None {
// Files of a standard don't carry the editing data.
set(d, "preserveEditing", json!(false));
}
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));
@ -252,16 +343,51 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
"Advanced" => advanced(ui, d),
"Security" => security(ui, d),
"Summary" => summary(app, ui, d),
_ => general(ui, d),
_ => general(ui, d, editor),
});
});
});
false
}
/// The Preset row (the presets, built-in and saved, and Save Preset…) and, while Save Preset…
/// asks for a name, the row taking it.
fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
row_with(ui, "Preset:", TOP_LABEL_WIDTH, |ui| {
let presets = pdf::presets(&app.session);
let names: Vec<&str> = presets.iter().map(String::as_str).collect();
let chosen = widgets::dropdown(ui, "pdf-preset", &d.str("preset"), &names, 300.0).and_then(|i| names.get(i)).map(|p| p.to_string());
if let Some(name) = chosen {
apply_preset(app, d, &name);
}
let asking = d.fields.contains_key(SAVE_AS);
if ui
.add_enabled_ui(!asking, |ui| widgets::flat_button(ui, "Save Preset…", 96.0))
.inner
.on_hover_text("Save these settings as a preset")
.clicked()
{
d.fields.insert(SAVE_AS.into(), json!(app.session.new_pdf_preset_name()));
}
});
if d.fields.contains_key(SAVE_AS) {
row_with(ui, "Save as preset:", TOP_LABEL_WIDTH, |ui| {
form::text_edit(ui, d, SAVE_AS, 200.0);
if widgets::flat_button(ui, "Save", 52.0).clicked()
&& let Err(e) = save_preset(app, d)
{
app.status(e);
}
if widgets::flat_button(ui, "Cancel", 60.0).clicked() {
d.fields.remove(SAVE_AS);
}
});
}
}
/// 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 })) {
match settings_fields(app, &json!({ "preset": name })) {
Ok(settings) => {
d.fields.extend(settings);
d.fields.insert("preset".into(), json!(name));
@ -270,13 +396,22 @@ fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) {
}
}
fn general(ui: &mut egui::Ui, d: &mut Dialog) {
/// General: the options, then the artboards (Save PDF) or the description (preset editor).
fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
heading(ui, "Options");
flag(ui, d, "preserveEditing", "Preserve editing capabilities", true);
let plain = choice::<Standard>(d, "standard").unwrap_or_default() == Standard::None;
flag(ui, d, "preserveEditing", "Preserve editing capabilities", plain);
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);
if editor {
heading(ui, "Description");
if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) {
d.fields.insert("description".into(), json!(text));
}
return;
}
heading(ui, "Artboards");
let mut all = d.bool("__allArtboards");
ui.horizontal(|ui| {
@ -449,7 +584,7 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) {
}
/// The section a changed option belongs to (for the Summary's order).
fn section_of(option: &str) -> usize {
pub(super) fn section_of(option: &str) -> usize {
match option.split('.').next().unwrap_or_default() {
"compression" => 1,
"marks" | "bleed" => 2,
@ -462,7 +597,7 @@ fn section_of(option: &str) -> usize {
/// `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 {
pub(super) 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));
@ -501,7 +636,8 @@ fn summary(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
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.");
let base = if d.kind == PRESET_KIND { pdf::DEFAULT_PRESET } else { "the preset" };
note(ui, &format!("Every option matches {base}."));
}
for c in changed {
let value = match &c["value"] {

View File

@ -8,13 +8,18 @@ use crate::VectorcraftApp;
use crate::dialogs::svg_options;
/// Open bytes of any readable format as a new document (templates open untitled); swatch and
/// graphic style library files open in the library panel and flattener presets files are imported.
/// graphic style library files open in the library panel and flattener and PDF presets files are
/// imported.
pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Option<String>) -> Result<(), String> {
let ext = fileio::extension(name);
if vectorcraft_engine::cmd::flatten::PRESET_EXTS.contains(&ext.as_str()) {
let r = app.run("flattener.presets.import", serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
let presets = [
(vectorcraft_engine::cmd::flatten::PRESET_EXTS, "flattener.presets.import", "flattener presets"),
(vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS, "pdf.preset.import", "PDF presets"),
];
if let Some((_, import, what)) = presets.iter().find(|(exts, ..)| exts.contains(&ext.as_str())) {
let r = app.run(import, serde_json::json!({"data": String::from_utf8_lossy(bytes)}))?;
let names: Vec<&str> = r["imported"].as_array().into_iter().flatten().filter_map(Value::as_str).collect();
app.status(format!("Imported flattener presets: {}", names.join(", ")));
app.status(format!("Imported {what}: {}", names.join(", ")));
return Ok(());
}
let swatches = vectorcraft_engine::cmd::swatchlib::LIBRARY_EXTS.contains(&ext.as_str());
@ -103,14 +108,17 @@ fn format_param(p: &Value) -> Option<&str> {
p.get("format").and_then(Value::as_str)
}
/// File → Save / Save As: native, or SVG for an .svg path (with the SVG options in `params`, else
/// the ones the document was last saved with).
/// File → Save / Save As: native, SVG for an .svg path (with the SVG options in `params`, else
/// the ones the document was last saved with), or a PDF-compatible .ai file (with the PDF options
/// in `params`) that reopens editable.
pub fn save(app: &mut VectorcraftApp, path: Option<String>, save_as: bool, params: &Value) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
let existing = if save_as { None } else { st.path.clone() };
let path = target_path(app, path.or(existing), vectorcraft_format::EXTENSION)?;
let f = fileio::save_format(format_param(params), Some(&path))?;
fileio::stamp_save_dates(app.session.active_mut().ok_or("no document")?);
let params = fileio::pdf::expand_preset(&app.session, "file.save", params).map_err(|e| e.to_string())?;
let params = &*params;
let st = app.session.active().ok_or("no document")?;
let (enc, opts) = fileio::save_encoding(st, f, params, Some(&path)).map_err(|e| e.to_string())?;
let doc = st.doc.clone();
@ -131,7 +139,7 @@ pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value,
let path = target_path(app, p.get("path").and_then(Value::as_str).map(str::to_string), vectorcraft_format::EXTENSION)?;
let f = fileio::save_format(format_param(p), Some(&path))?;
let given = p.get("svg").is_some_and(|v| !v.is_null()) || !fileio::svg_options(p)?.is_empty();
if f.id != "vectorcraft" && !given {
if matches!(f.id, "svg" | "svgz") && !given {
let mode = if copy { svg_options::Mode::SaveCopy } else { svg_options::Mode::Save };
svg_options::open(app, mode, Some(&path));
return Ok(json!({ "dialog": svg_options::KIND, "path": path }));
@ -139,9 +147,19 @@ pub fn save_as(app: &mut VectorcraftApp, copy: bool, p: &Value) -> Result<Value,
if !copy {
return save(app, Some(path), true, p).map(|p| json!({ "path": p }));
}
let doc = app.session.active().ok_or("no document")?.doc.clone();
let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path);
let enc = fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p).map_err(|e| e.to_string())?;
let st = app.session.active().ok_or("no document")?;
let doc = st.doc.clone();
// A .ai copy is written as Save writes it (PDF plus the native document).
let enc = match f.id {
"ai" => fileio::pdf::expand_preset(&app.session, "file.saveCopy", p)
.and_then(|p| fileio::save_encoding(st, f, &p, Some(&path)))
.map(|(enc, _)| enc),
_ => {
let copy = vectorcraft_engine::cmd::links::with_relative_paths(&doc, &path);
fileio::encode_all(copy.as_ref().unwrap_or(&doc), f.id, p)
}
}
.map_err(|e| e.to_string())?;
write_encoded(app, &doc, &path, &enc)?;
app.status(format!("Saved a copy as {path}"));
Ok(json!({ "path": path }))

View File

@ -33,6 +33,8 @@ pub mod unsaved;
pub mod widgets;
pub mod workspaces;
#[cfg(test)]
mod tests_aisave;
#[cfg(test)]
mod tests_clipboard;
#[cfg(test)]

View File

@ -48,14 +48,24 @@ use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.open", "Open…", "Cmd+O", "{path?}"),
("file.save", "Save", "Cmd+S", "{path?, svg?: {…SVG options}} (a document saved as .svg saves as SVG again, with the same options)"),
(
"file.save",
"Save",
"Cmd+S",
"{path?, svg?: {…SVG options}, …PDF options for .ai} (a document saved as .svg saves as SVG again, with the same options; one saved or opened as .ai saves as .ai again)",
),
(
"file.saveAs",
"Save As…",
"Cmd+Shift+S",
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg/.svgz (such a path without svg options opens SVG Options)",
"{path?, svg?: {…SVG options}, …document.exportPdf options for .ai} .vectorcraft, .ai (PDF-compatible: a PDF carrying the native document, which reopens editable) or .svg/.svgz (such a path without svg options opens SVG Options)",
),
(
"file.saveCopy",
"Save a Copy…",
"Cmd+Alt+S",
"{path?, svg?: {…SVG options}, …PDF options for .ai} like Save As, but the document keeps its path",
),
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
("file.revert", "Revert", "F12", "{}"),
(
@ -394,6 +404,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too",
),
(
"ui.pdfPresetsDialog",
"PDF Presets…",
"",
"{selected?: preset name} open Edit → PDF Presets (dialog `pdfPresets`, field `selected`): the built-in presets (read-only) and the saved ones, with the selected one's description and settings. New… and Edit… open the preset editor (ui.pdfPresetDialog); Delete, Import… and Export… run pdf.preset.delete / import / export",
),
(
"ui.pdfPresetDialog",
"PDF Preset",
"",
"{name?: a saved preset to edit | preset?: the preset a new one starts from (default VectorCraft Default)} open the preset editor (dialog `pdfPreset`: the Save PDF dialog's option fields plus `name` and `description`); OK runs pdf.preset.save and returns to PDF Presets",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -767,6 +789,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p),
"ui.fileInfoDialog" => crate::dialogs::file_info::open(app),
"ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app),
"ui.pdfPresetsDialog" => {
crate::dialogs::pdf_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
}
"ui.pdfPresetDialog" => crate::dialogs::open_pdf_preset(app, p),
_ => return None,
};
Some(r)
@ -1080,7 +1107,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
c("Transparency Flattener Presets…", "ui.flattenerPresetsDialog"),
todo("Print Presets…"),
todo("PDF Presets…"),
c("PDF Presets…", "ui.pdfPresetsDialog"),
cp("Perspective Grid Presets…", "perspective.grid.preset", json!({"kind": 2})),
Sep,
c("Color Settings…", "edit.colorSettings"),

View File

@ -0,0 +1,37 @@
//! Save As .ai in the app: a PDF-compatible file written without an options dialog, which reopens
//! as the document and saves as .ai again; Save a Copy as .ai leaves the document's path alone.
use serde_json::{Value, json};
use crate::tests_svg::app;
#[test]
fn save_as_ai_writes_a_pdf_that_reopens_editable() {
let (mut app, written) = app("/tmp/art.ai");
let r = app.run("file.saveAs", Value::Null).unwrap();
assert_eq!(r["path"], "/tmp/art.ai", "no options dialog: {r}");
assert!(app.ui.dialog.is_none());
let ai = written.borrow()[0].1.clone();
assert!(ai.starts_with(b"%PDF"));
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/art.ai"));
// Save writes .ai again; the document reopens from it.
app.run("shape.ellipse", json!({"x": 50, "y": 40, "width": 20, "height": 20})).unwrap();
app.run("file.save", Value::Null).unwrap();
let saved = written.borrow()[1].clone();
assert_eq!(saved.0, "/tmp/art.ai");
let layers = app.session.doc().unwrap().doc.layers.clone();
crate::io::open_bytes(&mut app, "/tmp/art.ai", &saved.1, Some("/tmp/art.ai".into())).unwrap();
assert_eq!(app.session.doc().unwrap().doc.layers, layers);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/art.ai"));
}
#[test]
fn save_a_copy_as_ai_keeps_the_path() {
let (mut app, written) = app("/tmp/copy.ai");
app.session.doc_mut().unwrap().path = Some("/tmp/doc.vectorcraft".into());
app.run("file.saveCopy", json!({"compression": {"compressText": false}})).unwrap();
let w = written.borrow();
assert_eq!(w[0].0, "/tmp/copy.ai");
assert!(vectorcraft_pdf::editing(&w[0].1).is_some_and(|e| e.intact));
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft"));
}

View File

@ -74,7 +74,12 @@ fn swatches_menu_offers_save_swatch_library() {
#[test]
fn edit_menu_lists_pdf_presets() {
let app = app();
assert!(menus::menu_entries(&app).iter().any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && !e.enabled));
// Implemented (M4.44): enabled, and it opens Edit → PDF Presets.
assert!(
menus::menu_entries(&app)
.iter()
.any(|e| e.path == ["Edit"] && e.label == "PDF Presets…" && e.enabled && e.command.as_deref() == Some("ui.pdfPresetsDialog"))
);
}
#[test]

View File

@ -165,6 +165,15 @@ built-in presets don't change. New, Delete, Import… and Export… are `flatten
Opening a `.vcflattener` file with `app.open` imports its presets. In the Flatten Transparency dialog, Save Preset…
keeps the dialog's options as a saved preset.
PDF presets: `ui.pdfPresetsDialog {selected?}` opens Edit → PDF Presets (dialog `pdfPresets`, field `selected`: a
preset name). Built-in presets are read-only. New… and Edit… open the preset editor, which `ui.pdfPresetDialog {name?
(a saved preset to edit) | preset? (the preset a new one starts from)}` opens too: dialog `pdfPreset`, the Save PDF
dialog's option fields plus `name` and `description`; `ui.dialog.confirm` runs `pdf.preset.save` and returns to PDF
Presets (a new preset can't take a name in use). Delete, Import… and Export… are `pdf.preset.delete`,
`pdf.preset.import` and `pdf.preset.export`; `ui.dialog.confirm` closes. Opening a `.vcpdfpresets` file with `app.open`
imports its presets. In the Save PDF dialog, Save Preset… shows a name field (`__savePresetAs`): while it shows,
`ui.dialog.confirm` saves the dialog's settings as that preset and selects it instead of writing the PDF.
Flattener Preview: `window.panel {panel: "flattenerPreview"}` shows the panel; `ui.flattenerPreview {highlight?,
overprints?, preset?, options?, showOptions?}` sets it (a preset loads its options, `options` adjust them), shows it
and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings

View File

@ -62,7 +62,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
| `open_panel` | `{panel}` | Remote only. |
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. `run_command document.formats` lists the formats. |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Saves in the native `.vectorcraft` format. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp` or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
@ -183,7 +183,7 @@ PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibili
`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf`
documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`:
options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused.
`document.pdfSettings` lists the options that differ from the defaults and the warnings without writing a file.
`document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file.
Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),
`cropTo` (`bounding` (the art's bounds), `art`, `crop` (default), `trim`, `bleed`, `media`: the box each artboard gets)
@ -201,6 +201,14 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
say so, as does an `.ai` whose PDF part is only a placeholder page.
Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as
an embedded file (`vectorcraft-editing.vectorcraft`; files named `drawcraft-editing.drawcraft` are read too) with a
hash of the pages it was written with. `open_file` / `document.open` of such a PDF, `.ai` or `.ait` (no `pages` picked) restores the
document exactly (`restored: true`); when another app changed the pages, or the data can't be read, the artwork is
imported instead and the first warning says why. Choosing a standard turns it off (PDF/A refuses it). Save to a `.ai`
path (`document.save {path: "art.ai"}` or `{format: "ai"}`) writes a PDF-compatible file that always carries the
document, takes the PDF options and keeps its path when reopened, so Save writes `.ai` again; a `.ait` opens untitled.
Drive a tool like a mouse:
```json
@ -951,3 +959,20 @@ bounds, or the artboard less a 36 pt margin.
```json
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}}
```
## PDF presets
`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print,
Press Quality, Smallest File Size and the PDF/X presets, `builtIn: true`; `supported: false` where the writer can't
produce the standard yet) and the saved ones, each with its `description` and `settings`. `pdf.preset.save {name?,
newName?, description?, preset?, …document.exportPdf options}` creates or changes a saved preset (a change starts from
the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored);
`pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file
(without `path` it returns `data`), and `pdf.preset.import {path? | data? | dataBase64?, replace?}` adds a file's
presets (names in use get a number unless `replace`). Saved presets live with the preferences and work as `preset`
in `document.exportPdf`, `document.pdfSettings`, `document.export`/`serialize` with format `pdf` and `.ai` saves.
```json
{"name":"run_command","arguments":{"command":"pdf.preset.save","params":{"name":"Web","preset":"Smallest File Size","compatibility":"1.5"}}}
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"preset":"Web","path":"/tmp/web.pdf"}}}
```