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:
commit
a560f74e17
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(_))));
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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", ¤t, &presets, 300.0).and_then(|i| presets.get(i)).map(|p| p.to_string());
|
||||
if let Some(name) = chosen {
|
||||
apply_preset(app, d, &name);
|
||||
}
|
||||
});
|
||||
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"] {
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
29
docs/mcp.md
29
docs/mcp.md
|
|
@ -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"}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue