M4.43: PDFs and .ai files reopen editable; Save As writes PDF-compatible .ai
Preserve Editing (on in the VectorCraft Default preset) embeds the native document in the PDF as an embedded file with a hash of the pages it was written with. document.open of a PDF, .ai or .ait restores it exactly (restored: true) and falls back to importing the artwork, with a first warning saying why, when the pages were changed elsewhere or the data can't be read; SVG and PDF share that fallback and its warnings. The legacy drawcraft payload name is read too. vectorcraft_pdf gains editing(), ImportReport.native and PdfOptions.native. document.save / file.saveAs / Save a Copy write .ai (format "ai" or an .ai path): every artboard as a PDF that always carries the document and takes the PDF options; a restored .ai keeps its path so Save writes .ai again, and .ait templates open their document untitled. The desktop save dialog lists the formats Save writes. Choosing a standard turns Preserve Editing off (PDF/A refuses it), Export for Screens and PDFs of some artboards leave it out with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4b956422c9
commit
0ffaa427d1
|
|
@ -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
|
||||
),
|
||||
|
|
@ -513,6 +513,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] = &[
|
||||
|
|
@ -25,6 +27,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
|||
FormatOption { name: "preset", ty: "string", default: "\"VectorCraft Default\"", description: "the PDF preset the other options apply over" },
|
||||
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
|
||||
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
|
||||
FormatOption {
|
||||
name: "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 +48,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\", 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
|
||||
),
|
||||
|
|
@ -56,16 +64,27 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
]
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(s.prefs.pdf_presets.iter().map(|p| p.name.clone())).collect()
|
||||
}
|
||||
|
||||
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 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(|| {
|
||||
let names: Vec<String> = vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect();
|
||||
bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names.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 +108,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 +164,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 +191,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 +211,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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -533,3 +533,33 @@ 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))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
//! PDF presets: named [`PdfSettings`]. The built-in ones are generated here (the app default
|
||||
//! keeps the document editable).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::PdfSettings;
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Every built-in preset, the app default first.
|
||||
pub fn builtin_presets() -> Vec<PdfPreset> {
|
||||
vec![PdfPreset {
|
||||
name: DEFAULT_PRESET.into(),
|
||||
description: "Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again.".into(),
|
||||
settings: PdfSettings { preserve_editing: true, ..Default::default() },
|
||||
}]
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -422,7 +431,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(_))));
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -46,10 +46,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 +60,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()),
|
||||
|
|
@ -219,6 +220,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));
|
||||
|
|
@ -261,7 +266,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
|
||||
/// 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));
|
||||
|
|
@ -272,7 +277,8 @@ fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) {
|
|||
|
||||
fn general(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -103,14 +103,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 +134,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 +142,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", "{}"),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
10
docs/mcp.md
10
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`). |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue