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:
bflatastic 2026-10-05 09:49:41 +07:00
parent 4b956422c9
commit 0ffaa427d1
26 changed files with 811 additions and 118 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
//! PDF export settings for every PDF path (`document.export {format: pdf}`, Export for Screens,
//! `document.exportPdf` and the Save PDF dialog): presets, the options parsed over them, the
//! warnings that come back, and `document.pdfSettings` (the dialog's Summary).
//! `document.exportPdf` and the Save PDF dialog): presets (built-in and saved), the options parsed
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
use std::borrow::Cow;
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfSettings};
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
@ -14,7 +16,7 @@ use crate::EngineError;
const C: &str = "document.exportPdf";
/// The built-in preset every PDF export starts from.
pub const DEFAULT_PRESET: &str = "VectorCraft Default";
pub use vectorcraft_pdf::DEFAULT_PRESET;
/// The PDF options `document.formats` lists; `document.exportPdf` documents every field.
pub(super) const OPTIONS: &[FormatOption] = &[
@ -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 }))
}

View File

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

View File

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

View File

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

View File

@ -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![],
}
}
}

View File

@ -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))?;
}
}

View File

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

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

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

View File

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

View File

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

View File

@ -7,7 +7,8 @@
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
//! paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF
//! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output,
//! Advanced, Security); options the writer doesn't apply yet come back as warnings.
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
@ -15,18 +16,24 @@
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
//! CMYK document. [`ImportOptions`] pick the pages, the box each
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
//! The native document a PDF carries comes back too ([`ImportReport::native`], [`editing()`]).
//! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]).
#![forbid(unsafe_code)]
mod editing;
mod export;
mod import;
mod import_color;
mod lab_spot;
mod pages;
mod presets;
mod settings;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing};
pub use export::{export, export_with_report};
pub use import::{import, import_with_report};
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
pub use presets::*;
pub use settings::*;
use vectorcraft_doc::Document;
@ -41,6 +48,8 @@ pub struct PdfOptions {
pub title: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
}
impl PdfOptions {
@ -93,6 +102,8 @@ settings::choice! {
pub struct ImportReport {
pub document: Document,
pub warnings: Vec<String>,
/// The native document the PDF carries (written with Preserve Editing), if any.
pub native: Option<Editing>,
}
/// Result of an export with non-fatal warnings (features approximated or dropped).
@ -145,6 +156,8 @@ mod tests_cmykblend;
#[cfg(test)]
mod tests_dashalign;
#[cfg(test)]
mod tests_editing;
#[cfg(test)]
mod tests_focal;
#[cfg(test)]
mod tests_fx;

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

@ -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))
}

View File

@ -387,18 +387,27 @@ fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result<(), PdfErr
}
impl PdfSettings {
/// Refuse settings the writer can't honour: an unsupported standard, a standard with a PDF
/// version it doesn't allow, a password (the file would not be protected) or an out-of-range
/// value.
/// Refuse settings the writer can't honour: an unsupported standard, a password (the file
/// would not be protected), or what [`Self::check_values`] refuses.
pub fn check(&self) -> Result<(), PdfError> {
if !self.standard.supported() {
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
}
if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() {
return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into()));
}
self.check_values()
}
/// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
/// an out-of-range value.
pub fn check_values(&self) -> Result<(), PdfError> {
if !self.standard.allows(self.compatibility) {
return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label())));
}
if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() {
return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into()));
if self.preserve_editing && self.standard == Standard::PdfA2b {
return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label())));
}
let c = &self.compression;
for (name, img) in
@ -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"),

View File

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

View File

@ -71,7 +71,6 @@ fn checks_refuse_what_the_writer_cant_honour() {
fn options_not_applied_yet_come_back_as_warnings() {
assert!(PdfSettings::default().warnings().is_empty());
for (v, word) in [
(json!({"preserveEditing": true}), "Preserve editing"),
(json!({"thumbnails": true}), "thumbnails"),
(json!({"fastWebView": true}), "fast web view"),
(json!({"createLayers": true}), "layers"),

View File

@ -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);

View File

@ -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 }))

View File

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

View File

@ -48,14 +48,24 @@ use Item::Sep;
/// UI-level commands: (id, label, shortcut, params doc).
pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("file.open", "Open…", "Cmd+O", "{path?}"),
("file.save", "Save", "Cmd+S", "{path?, svg?: {…SVG options}} (a document saved as .svg saves as SVG again, with the same options)"),
(
"file.save",
"Save",
"Cmd+S",
"{path?, svg?: {…SVG options}, …PDF options for .ai} (a document saved as .svg saves as SVG again, with the same options; one saved or opened as .ai saves as .ai again)",
),
(
"file.saveAs",
"Save As…",
"Cmd+Shift+S",
"{path?, svg?: {…SVG options}} .vectorcraft, or .svg/.svgz (such a path without svg options opens SVG Options)",
"{path?, svg?: {…SVG options}, …document.exportPdf options for .ai} .vectorcraft, .ai (PDF-compatible: a PDF carrying the native document, which reopens editable) or .svg/.svgz (such a path without svg options opens SVG Options)",
),
(
"file.saveCopy",
"Save a Copy…",
"Cmd+Alt+S",
"{path?, svg?: {…SVG options}, …PDF options for .ai} like Save As, but the document keeps its path",
),
("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?, svg?: {…SVG options}} like Save As, but the document keeps its path"),
("file.newFromTemplate", "New from Template…", "Cmd+Shift+N", "{path?} open a template as a new untitled document"),
("file.revert", "Revert", "F12", "{}"),
(

View File

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

View File

@ -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