Save: include a PDF in new native files by default

Squashed from #5320 onto main.
This commit is contained in:
dotCooCoo
2026-11-09 00:25:34 +07:00
committed by bflatastic
parent f5be571d47
commit d61a7abc96
18 changed files with 579 additions and 36 deletions
+6 -1
View File
@@ -128,12 +128,17 @@ USAGE:
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
(warm), multithreaded and single-threaded.
vectorcraft-cli perf [--paths N] [--deep]
vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]]
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
budget is exceeded, unless the report says the machine is busy. --deep also times work with
no budget yet and prints memory use (the working set on Windows), with the peak so far on
Windows and Linux.
--native-io instead measures the application's native save/open commands (one warmup,
five samples, no disk I/O), including preview, PDF, compression and API base64 costs.
It reports the default and explicit PDF opt-out, with and without preview/compression;
--artboards repeats the same artwork
on 1 to 4 overlapping artboards. This mode allows at most 50000 paths.
";
/// The usage text plus the formats `document.open` reads and `document.export` writes.
+104 -2
View File
@@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// Exercise the application's native save/open commands, including previews, PDF output,
/// compression and API base64 conversion. Session setup and validation are outside timing.
/// Five measured runs follow one warmup; no filesystem or GPU window is involved.
fn native_io(doc: &Document) -> Result<(), String> {
for (label, compatible, preview) in [
("default options", None, false),
("PDF disabled", Some(false), false),
("preview+gzip", None, true),
("preview+gzip, PDF disabled", Some(false), true),
] {
let mut session = vectorcraft_engine::Session::new();
session.add_document(doc.clone(), None);
let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview});
if let Some(value) = compatible {
params["pdfCompatible"] = json!(value);
}
let (mut saves, mut opens) = (Vec::new(), Vec::new());
let (mut size, mut has_pdf, mut compressed) = (0, false, false);
for run in 0..6 {
let start = Instant::now();
let saved = session.execute("file.saveAs", &params).map_err(|e| e.to_string())?;
let save_ms = start.elapsed().as_secs_f64() * 1000.0;
let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?;
let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?;
size = bytes.len();
has_pdf = vectorcraft_format::pdf_content(&bytes).is_some();
if compatible == Some(false) && has_pdf {
return Err("native save ignored the explicit PDF opt-out".into());
}
compressed = vectorcraft_format::is_compressed(&bytes);
if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview {
return Err("native save did not apply the requested preview/compression options".into());
}
let args = json!({"name":"perf.vectorcraft","dataBase64":data});
let mut opened = vectorcraft_engine::Session::new();
let start = Instant::now();
opened.execute("document.open", &args).map_err(|e| e.to_string())?;
let open_ms = start.elapsed().as_secs_f64() * 1000.0;
if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() {
return Err("native round trip changed the artboard count".into());
}
if run > 0 {
saves.push(save_ms);
opens.push(open_ms);
}
}
saves.sort_by(f64::total_cmp);
opens.sort_by(f64::total_cmp);
let save = saves.get(2).copied().ok_or("no save measurements")?;
let open = opens.get(2).copied().ok_or("no open measurements")?;
outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}");
}
Ok(())
}
/// 1-minute load average (macOS / Linux), if available.
#[cfg(not(windows))]
fn load_average() -> Option<f64> {
@@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> {
}
pub fn run(args: &[String]) -> Result<(), String> {
if crate::help_requested(args, &["--paths"]) {
if crate::help_requested(args, &["--paths", "--artboards"]) {
return crate::help();
}
let mut n = 50_000usize;
let mut want_deep = false;
let mut want_native = false;
let mut artboards = None::<usize>;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--deep" => want_deep = true,
"--native-io" => want_native = true,
"--artboards" => {
artboards =
Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?)
}
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
other => return Err(format!("unknown perf option `{other}`")),
}
}
if want_native && n > 50_000 {
return Err("--native-io allows at most 50000 paths".into());
}
if want_native && want_deep {
return Err("--native-io and --deep are separate measurement modes".into());
}
if artboards.is_some() && !want_native {
return Err("--artboards requires --native-io".into());
}
let artboards = artboards.unwrap_or(1);
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
#[cfg(not(windows))]
@@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> {
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n)?;
let mut doc = synthetic(n)?;
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
if want_native {
let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?;
// Overlapping artboards intentionally repeat the same paths on every PDF page.
for index in 1..artboards {
let mut page = first.clone();
page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?;
page.name = format!("Perf {}", index + 1);
doc.artboards.push(page);
}
outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O");
return native_io(&doc);
}
let mut rows: Vec<(&str, f64, f64)> = vec![];
let opts = RenderOptions::default();
@@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> {
mod tests {
use super::*;
#[test]
fn native_io_exercises_the_application_save_and_open_commands() {
native_io(&synthetic(3).unwrap()).unwrap();
}
#[test]
fn native_io_rejects_conflicting_modes_and_excessive_workloads() {
for (args, expected) in [
(vec!["--native-io", "--deep"], "separate measurement modes"),
(vec!["--native-io", "--paths", "50001"], "at most 50000"),
(vec!["--native-io", "--artboards", "5"], "1 to 4"),
(vec!["--artboards", "1"], "requires --native-io"),
(vec!["--artboards", "2"], "requires --native-io"),
] {
assert!(run(&args.into_iter().map(str::to_owned).collect::<Vec<_>>()).unwrap_err().contains(expected));
}
}
#[test]
fn busy_share_leaves_out_idle_time() {
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
+9 -2
View File
@@ -29,6 +29,8 @@ pub struct Loaded {
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
/// that it carries (its layers), not from its PDF part alone.
pub editing_data: bool,
/// Whether the native source declared a PDF; other formats carry no native save preference.
pub native_pdf_compatible: Option<bool>,
}
impl Loaded {
@@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
let mut converted = false;
let mut preview_only = false;
let mut editing_data = false;
let mut native_pdf_compatible = None;
let (mut doc, mut warnings, restored) = match format.id {
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
@@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
}
"vectorcraft" | "template" => {
let (d, info, warnings) = native_file(bytes)?;
native_pdf_compatible = Some(info.pdf_compatible);
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
(d, warnings, false)
}
@@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
doc.title = title;
}
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible })
}
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
@@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
/// Make a loaded file the new active document; `links` is what became of its linked files, and
/// `opts` are the options it was read with.
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs);
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
// fallbacks must ask before overwriting the richer source they could not fully read.
let losses: Vec<String> = warnings
@@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
let converted = saves_back && converted && s.prefs.append_converted;
let index = s.add_document(doc, path);
let st = s.doc_mut()?;
if let Some(pdf) = native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
if saves_back {
st.format = format.id;
// An SVG that reopened as the full document saves as one again: Save keeps Preserve
+10 -6
View File
@@ -48,7 +48,7 @@ pub const OPTIONS: &[FormatOption] = &[
FormatOption {
name: "pdfCompatible",
ty: "boolean",
default: "false",
default: "true",
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
},
];
@@ -146,7 +146,7 @@ struct NativeOptions {
precision: Option<Value>,
include_linked: bool,
embed_profiles: Option<bool>,
pdf_compatible: bool,
pdf_compatible: Option<bool>,
}
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
@@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
so.profiles = embedded_profiles(doc);
}
so.check().map_err(|e| bad(cmd, e.to_string()))?;
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
let pdf = o.pdf_compatible.unwrap_or(true);
so.pdf_compatible = Some(pdf);
Ok((so, Extras { preview: o.preview, pdf }))
}
/// The `precision` option `v`: a whole number of decimal places, 0 to
@@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
if extras.preview {
so.preview = preview_png(&drawn)?;
}
if extras.pdf {
// Every artboard as PDF pages (no editing data: the file is the native document).
so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0);
if extras.pdf && !drawn.artboards.is_empty() {
// Every artboard as PDF pages (no editing data: the file is the native document), dated by
// the document's File Info, so the same document and dates give the same bytes.
let at = drawn.metadata.modified.or(drawn.metadata.created);
so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0);
}
drop(drawn);
let native = match full {
+25 -3
View File
@@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
/// and a document without one gets neither.
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time())
}
/// [`options`] modified at `now` (Unix seconds) instead of the time of writing.
fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option<i64>) -> Result<PdfOptions> {
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
let now = vectorcraft_doc::metadata::output_time();
Ok(PdfOptions {
settings: settings(cmd, p)?,
artboards,
@@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file
/// 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>)> {
encode_at(cmd, doc, p, None)
}
/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing.
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<String>)> {
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
warnings.extend(drawn_warnings);
warnings.extend(overflow_warning(cmd, &full, p)?);
Ok((bytes, warnings))
@@ -294,7 +303,20 @@ pub(super) fn encode_carrying(
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options(cmd, doc, p)?;
encode_carrying_at(cmd, doc, p, None, native, private)
}
/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of
/// writing.
fn encode_carrying_at(
cmd: &str,
doc: &Document,
p: &Value,
modified: Option<i64>,
native: impl FnOnce() -> Result<Vec<u8>>,
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
) -> Result<(Vec<u8>, Vec<String>)> {
let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?;
// The pages' background colors, behind the art (the editing data keeps them as colors).
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?;
+32 -3
View File
@@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
}
}
// None given: the ones the document was last saved with in this format.
let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
// Remember the effective choice, including the encoder's default, so another document's
// later save cannot change what this one writes. Invalid non-null values still reach validation.
if matches!(format.id, "vectorcraft" | "template") {
let pdf = match options.get("pdfCompatible") {
None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)),
_ => None,
};
if let Some(pdf) = pdf {
options.insert("pdfCompatible".into(), json!(pdf));
}
}
// Save writes the document's own file; the other modes only where they are told to.
let path =
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
@@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool {
}
}
/// Native documents and templates share only their PDF choice. Other source formats use the
/// application's last native choice, keeping an AI file's separate PDF option out of it.
fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option<bool> {
st.filter(|d| matches!(d.format, "vectorcraft" | "template"))
.and_then(|d| d.save_options.get("pdfCompatible"))
.and_then(Value::as_bool)
.or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool))
}
/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`)
/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is
/// left out, and one that moved is named by its new number. When none of the artboards the save
@@ -1028,12 +1048,15 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
let Loaded { mut doc, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
doc.template = false;
// Back to the saved file: its recovery copy has nothing left to recover.
let uid = s.doc()?.uid;
crate::cmd::recovery::forget(s, uid);
s.replace_document(index, doc);
if let Some(pdf) = native_pdf_compatible {
s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf));
}
Ok(json!({ "path": path }))
}
@@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone()));
// A native save compresses as Use Compression says, unless told otherwise.
let prefs = with_compression_pref(&s.prefs, &json!({}));
let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten();
let mut v = f.to_json();
if let Some(options) = v["options"].as_object_mut() {
for (name, o) in options.iter_mut() {
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone();
if name == "pdfCompatible"
&& let Some(pdf) = native_pdf
{
o["value"] = json!(pdf);
}
}
}
v["saveFormats"] =
@@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
"Save Document",
[],
None,
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
has_doc,
|s, p| save(s, SaveMode::Save, p)
),
+14 -1
View File
@@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
let path = d.join(name).to_string_lossy().to_string();
let p = json!({"path": path, "compress": compress});
// A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison
// uses files without one.
let p = json!({"path": path, "compress": compress, "pdfCompatible": false});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
@@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
}
// A save with the default options streams a file that carries its PDF.
let path = d.join("d.vectorcraft").to_string_lossy().to_string();
let p = json!({"path": path});
let r = s.execute("file.saveCopy", &p).unwrap();
let written = std::fs::read(&path).unwrap();
assert_eq!(r["bytes"], written.len(), "{r}");
assert!(vectorcraft_format::pdf_content(&written).is_some());
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
let job = save_job(&mut s, plan).unwrap();
assert!(job.streams());
assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some());
// A writer that fails: the save fails with its error.
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
let job = save_job(&mut s, plan).unwrap();
+5 -1
View File
@@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() {
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
s.execute("document.save", &json!({"path": native})).unwrap();
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options");
assert_eq!(
Value::Object(s.doc().unwrap().save_options.clone()),
json!({"pdfCompatible":true}),
"a native save retains its PDF choice without SVG options"
);
let _ = std::fs::remove_dir_all(dir);
}
+13 -1
View File
@@ -565,6 +565,10 @@ struct Meta {
path: Option<String>,
/// The format Save writes for it ([`SAVE_FORMATS`]).
format: String,
/// A document's own native PDF choice before the recovery copy omits its PDF. None keeps
/// inheritance from the current application preference. Older copies did not record a choice.
#[serde(skip_serializing_if = "Option::is_none")]
native_pdf_compatible: Option<bool>,
/// When the copy was written (Unix seconds; none without a clock).
saved: Option<i64>,
/// The file it was read from when that import left things out of the document, and what
@@ -821,7 +825,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
mode: SaveMode::Copy,
path: st.path.clone(),
format: native,
options: Default::default(),
options: serde_json::Map::from_iter([("pdfCompatible".into(), json!(false))]),
name: format!("{file}{DOC_EXT}"),
folder: None,
modified: None,
@@ -832,6 +836,11 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
title: title.clone(),
path: st.path.clone(),
format: st.format.to_string(),
native_pdf_compatible: if matches!(st.format, "vectorcraft" | "template") {
st.save_options.get("pdfCompatible").and_then(Value::as_bool)
} else {
None
},
saved,
imported_from: st.imported_from.clone(),
import_losses: st.import_losses.clone(),
@@ -1060,6 +1069,9 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
st.imported_from = meta.imported_from.clone();
st.import_losses = meta.import_losses.clone();
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
if let Some(pdf) = meta.native_pdf_compatible {
st.save_options.insert("pdfCompatible".into(), json!(pdf));
}
st.mark_unsaved();
// The copy stays until the document is saved or closed.
st.recovery = Some(RecoveryCopy { file: mine.clone(), doc: st.doc.clone() });
+5 -1
View File
@@ -222,7 +222,8 @@ fn a_version_1_documents_twist_turns_as_it_was_saved() {
/// A save records the date the document was made in, so the same commands twice gave different
/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and
/// `SOURCE_DATE_EPOCH` for an export that takes no date param.
/// `SOURCE_DATE_EPOCH` for an export that takes no date param. The PDF a native file carries by
/// default is dated by the document's dates, not by the time of writing.
#[test]
fn a_pinned_date_makes_saves_byte_identical() {
let once = |t: i64| {
@@ -232,6 +233,9 @@ fn a_pinned_date_makes_saves_byte_identical() {
saved(&mut s, json!({}))
};
let at = 1_791_208_980;
let pdf = vectorcraft_format::pdf_content(&once(at)).expect("a native save carries a PDF by default");
let info = vectorcraft_pdf::file_info(&pdf, None).unwrap();
assert_eq!((info.created, info.modified), (Some(at), Some(at)), "the PDF has the document's dates");
assert_eq!(once(at), once(at), "the same document and date give the same bytes");
// The date really is in the bytes, so the comparison above is not vacuous.
assert_ne!(once(at), once(at + 1), "another date shows in the bytes");
+51
View File
@@ -59,6 +59,57 @@ fn restored(r: serde_json::Value) -> usize {
r["restored"].as_array().unwrap().len()
}
#[test]
fn native_recovery_copies_leave_out_the_pdf() {
let (mut s, store) = session();
let r = s.execute("file.recovery.save", &json!({})).unwrap();
let file = format!("{}.vectorcraft", r["saved"][0]["file"].as_str().unwrap());
let bytes = store.read(&file).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), s.doc().unwrap().doc.node_count());
}
#[test]
fn restored_recovery_copies_keep_the_original_pdf_preference() {
let dir = crate::tests_links::Folder::new("recovered-pdf-preference");
for pdf in [false, true] {
let (mut s, store) = session();
s.execute("file.saveAs", &json!({"path": dir.file(&format!("original-{pdf}.vectorcraft")), "pdfCompatible": pdf})).unwrap();
s.execute("shape.ellipse", &json!({"x": 80, "y": 20, "width": 20, "height": 20})).unwrap();
let copied = s.execute("file.recovery.save", &json!({})).unwrap();
let file = format!("{}.vectorcraft", copied["saved"][0]["file"].as_str().unwrap());
assert!(vectorcraft_format::pdf_content(&store.read(&file).unwrap()).is_none());
drop(s);
let mut s = relaunch(&store);
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(pdf)));
let saved = s.execute("document.save", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&bytes).is_some(), pdf);
}
}
#[test]
fn recovered_unsaved_documents_keep_inheriting_the_current_pdf_preference() {
let dir = crate::tests_links::Folder::new("recovered-inherited-pdf-choice");
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none());
s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path":dir.file("other.vectorcraft"),"pdfCompatible":false})).unwrap();
assert!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().is_empty());
let latest_preferences = s.prefs.clone();
drop(s);
let mut s = relaunch(&store);
// The host loads its latest preferences before offering recovery.
s.prefs = latest_preferences;
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert!(s.doc().unwrap().save_options.get("pdfCompatible").is_none());
let saved = s.execute("document.save", &json!({})).unwrap();
let bytes = vectorcraft_format::base64_decode(saved["dataBase64"].as_str().unwrap()).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
}
#[test]
fn recovery_save_writes_modified_documents_once() {
let (mut s, store) = session();
+141 -2
View File
@@ -157,7 +157,146 @@ fn pdf_compatible_files_carry_a_pdf() {
assert!(pdf.starts_with(b"%PDF"));
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
assert_eq!(info["pages"], 3);
assert!(vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({})).unwrap()["dataBase64"])).is_none());
assert!(
vectorcraft_format::pdf_content(&bytes_of(&s.execute("file.saveCopy", &json!({"pdfCompatible": false})).unwrap()["dataBase64"])).is_none()
);
}
/// A native save is portable to the other apps by default. The option is still an explicit
/// opt-out, and a remembered opt-out survives later saves of the document.
#[test]
fn native_saves_carry_all_artboards_as_pdf_by_default() {
let mut s = three_boards();
for compress in [false, true] {
let result = s.execute("file.saveCopy", &json!({"compress": compress})).unwrap();
let file = bytes_of(&result["dataBase64"]);
let pdf = vectorcraft_format::pdf_content(&file).expect("the default native save carries a PDF");
let info = s.execute("document.pdfInfo", &json!({"dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
assert_eq!(info["pages"], 3);
assert_eq!(vectorcraft_format::load(&file).unwrap().artboards.len(), 3);
}
let dir = Folder::new("pdf-opt-out");
let path = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 10, "height": 10})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
let options = s.execute("file.formatOptions", &json!({"format":"vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false);
s.execute("document.save", &json!({"compress": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], false);
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false);
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none());
s.execute("document.save", &json!({"pdfCompatible": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some());
}
#[test]
fn native_saves_without_artboards_keep_the_editable_document() {
let mut s = three_boards();
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).artboards.clear();
let result = s.execute("file.saveCopy", &json!({})).unwrap();
let file = bytes_of(&result["dataBase64"]);
let restored = vectorcraft_format::load(&file).unwrap();
assert!(restored.artboards.is_empty());
assert_eq!(restored.node_count(), s.doc().unwrap().doc.node_count());
assert!(vectorcraft_format::pdf_content(&file).is_none());
s.execute("document.open", &json!({"name": "no-artboards.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(&file)})).unwrap();
assert_eq!(s.doc().unwrap().save_options["pdfCompatible"], true);
s.execute("artboard.new", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap();
let result = s.execute("file.saveCopy", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&result["dataBase64"])).is_some());
}
#[test]
fn native_template_saves_keep_the_document_pdf_preference() {
let mut s = three_boards();
let dir = Folder::new("template-pdf-preference");
s.execute("file.saveAs", &json!({"path": dir.file("compact.vectorcraft"), "pdfCompatible": false, "version": 3})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "template"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], false, "the save dialog must offer the same retained choice");
let r = s.execute("file.saveAsTemplate", &json!({"format": "template"})).unwrap();
let bytes = bytes_of(&r["dataBase64"]);
assert!(vectorcraft_format::pdf_content(&bytes).is_none());
assert_eq!(
vectorcraft_format::load_file(&bytes).unwrap().info.version,
vectorcraft_format::VERSION,
"unrelated format options are not carried over"
);
let r = s.execute("file.saveAsTemplate", &json!({"format": "template", "pdfCompatible": true})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&r["dataBase64"])).is_some());
}
#[test]
fn a_default_native_save_keeps_its_pdf_after_another_document_opts_out() {
let dir = Folder::new("default-native-pdf-choice");
for (index, options) in [json!({}), json!({"options":{"pdfCompatible":null}})].into_iter().enumerate() {
let mut s = three_boards();
let path = dir.file(&format!("first-{index}.vectorcraft"));
let mut params = options.as_object().unwrap().clone();
params.insert("path".into(), json!(path));
s.execute("file.saveAs", &Value::Object(params)).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some());
s.execute("file.new", &json!({"width": 10, "height": 10})).unwrap();
s.execute("file.saveAs", &json!({"path":dir.file(&format!("other-{index}.vectorcraft")),"pdfCompatible":false})).unwrap();
s.execute("document.activate", &json!({"index":0})).unwrap();
s.execute("document.save", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), "the first document keeps the PDF it was saved with");
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(true)));
}
}
#[test]
fn a_null_nested_pdf_option_keeps_the_saved_opt_out() {
let dir = Folder::new("null-nested-pdf-option");
let mut s = three_boards();
let path = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": false})).unwrap();
s.execute("document.save", &json!({"options": {"pdfCompatible": null}})).unwrap();
assert!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_none(), "null leaves the saved choice");
assert_eq!(s.doc().unwrap().save_options.get("pdfCompatible"), Some(&json!(false)));
}
#[test]
fn reopening_native_files_keeps_each_files_pdf_compatibility() {
let dir = Folder::new("pdf-reopened-options");
let mut s = three_boards();
let pdf = dir.file("portable.vectorcraft");
let compact = dir.file("compact.vectorcraft");
s.execute("file.saveAs", &json!({"path": pdf, "pdfCompatible": true})).unwrap();
s.execute("file.saveAs", &json!({"path": compact, "pdfCompatible": false})).unwrap();
s.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
let copy = s.execute("file.saveCopy", &json!({})).unwrap();
assert!(vectorcraft_format::pdf_content(&bytes_of(&copy["dataBase64"])).is_none(), "new files use the last native preference");
for (path, expected) in [(&pdf, true), (&compact, false)] {
s.execute("document.open", &json!({"path": path})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], expected);
s.execute("document.save", &json!({})).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(path).unwrap()).is_some(), expected);
}
}
#[test]
fn reverting_native_files_updates_their_pdf_compatibility() {
let dir = Folder::new("pdf-revert-options");
for saved in [false, true] {
let mut s = three_boards();
let path = dir.file(if saved { "with-pdf.vectorcraft" } else { "compact.vectorcraft" });
s.execute("file.saveAs", &json!({"path": path, "pdfCompatible": saved})).unwrap();
let replacement = s.execute("file.saveCopy", &json!({"pdfCompatible": !saved})).unwrap();
std::fs::write(&path, bytes_of(&replacement["dataBase64"])).unwrap();
s.execute("shape.rectangle", &json!({"x": 70, "y": 20, "width": 8, "height": 8})).unwrap();
s.execute("file.revert", &json!({})).unwrap();
let options = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!(options["options"]["pdfCompatible"]["value"], !saved);
s.execute("document.save", &json!({})).unwrap();
assert_eq!(vectorcraft_format::pdf_content(&std::fs::read(&path).unwrap()).is_some(), !saved);
}
}
#[test]
@@ -429,7 +568,7 @@ fn format_options_list_the_save_options() {
assert_eq!(v["options"][k]["value"], value, "{k}");
}
let v = s.execute("file.formatOptions", &json!({"format": "vectorcraft"})).unwrap();
assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(false), true));
assert_eq!((v["options"]["pdfCompatible"]["value"].as_bool(), v["options"]["range"]["value"].is_null()), (Some(true), true));
// Saved with them, a document remembers them for Save.
let dir = Folder::new("remember");
let path = dir.file("r.vectorcraft");
+10 -5
View File
@@ -69,9 +69,8 @@ fn an_exporter_adds_a_write_only_format_while_it_is_installed() {
assert!(e.to_string().contains("unknown format"), "{e}");
}
/// The exporter is given the document as a native file's JSON: what `document.save` writes before
/// the data of large images, which stays behind (compact, where a save of a small document is
/// indented).
/// The exporter receives the native document JSON without optional save renditions or preference
/// metadata. Large image bytes stay in the saved file's tail; plug-in input stays compact.
#[test]
fn the_exporter_is_given_the_native_file_s_json() {
let mut s = session();
@@ -94,8 +93,14 @@ fn the_exporter_is_given_the_native_file_s_json() {
let saved = exported(&s.execute("document.save", &json!({})).unwrap());
assert!(saved.len() > given.len() + png.len(), "the image data follows the JSON in the saved file");
let doc: Value = serde_json::from_slice(&given).unwrap();
let saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap();
assert_eq!(doc, saved_json, "the saved file's JSON");
let mut saved_json: Value = serde_json::from_slice(vectorcraft_format::json_part(&saved)).unwrap();
assert!(saved_json.get("pdf").is_some(), "a default application save carries a PDF");
assert_eq!(saved_json["pdfCompatible"], true);
assert!(doc.get("pdf").is_none() && doc.get("pdfCompatible").is_none(), "the plug-in input omits optional save members");
let saved_members = saved_json.as_object_mut().unwrap();
saved_members.remove("pdf");
saved_members.remove("pdfCompatible");
assert_eq!(doc, saved_json, "the native document JSON without optional save members");
assert!(!given.contains(&b'\n'), "without indentation");
assert_eq!((&doc["format"], &doc["version"]), (&json!("vectorcraft"), &json!(vectorcraft_format::VERSION)));
let images = doc["images"].as_object().unwrap();
+145 -4
View File
@@ -45,11 +45,13 @@ mod stream;
mod unpack;
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use std::io::Write;
use std::sync::Arc;
use craft_vectorcraft_format::{Contents, Head, Image, ReadError, Tail};
use serde::Serialize;
use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
use serde::{Deserializer, Serialize};
use vectorcraft_doc::{Document, ImageBlob};
#[cfg(not(target_arch = "wasm32"))]
@@ -146,6 +148,9 @@ pub struct SaveOptions {
pub profiles: BTreeMap<String, Vec<u8>>,
/// Create PDF-Compatible File: a PDF of the document to carry.
pub pdf: Option<Vec<u8>>,
/// The application's PDF save preference, retained even when there are no artboards to draw.
/// `None` omits this metadata, preserving the lower-level writer's existing output.
pub pdf_compatible: Option<bool>,
/// Decimal places to round path anchors and handles to ([`Document::round_geometry`]), at
/// most [`MAX_PRECISION`] (a larger value rounds to that many); `None` writes them exactly.
pub precision: Option<u8>,
@@ -164,6 +169,7 @@ impl Default for SaveOptions {
include_linked: false,
profiles: BTreeMap::new(),
pdf: None,
pdf_compatible: None,
precision: None,
}
}
@@ -327,6 +333,9 @@ pub fn save_to(doc: &Document, o: &SaveOptions, w: impl Write) -> Result<(), For
env.field(&mut out, "format", &if o.version < 3 { LEGACY_EXTENSION } else { EXTENSION })?;
env.field(&mut out, "version", &o.version)?;
env.field(&mut out, "generator", &format!("VectorCraft {}", env!("CARGO_PKG_VERSION")))?;
if let Some(compatible) = o.pdf_compatible {
env.field(&mut out, "pdfCompatible", &compatible)?;
}
if let Some(png) = &o.preview {
env.field(&mut out, "preview", &Image::inline("image/png", png))?;
}
@@ -470,6 +479,9 @@ pub struct FileInfo {
pub version: u32,
/// Written under the project's former name (`"format": "drawcraft"`).
pub legacy: bool,
/// The saved `pdfCompatible` preference, or the presence of a non-null `pdf` member in
/// files without a boolean preference. The PDF payload is not read or validated.
pub pdf_compatible: bool,
}
impl FileInfo {
@@ -511,7 +523,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
ReadError::Envelope(e) => e.into(),
e => FormatError::NotVectorcraft(e.to_string()),
})?;
let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION };
let pdf_compatible = pdf_member(file.json()).map_err(|e| FormatError::NotVectorcraft(e.to_string()))?;
let info = FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION, pdf_compatible };
// A version 1 document saved before VectorCraft 0.21.0, or for an app before it (an older file
// version): its Twist angles turned the other way. The app that wrote it is read from the JSON
// before the data after it is taken; the walk's depth is bounded by the JSON reader's nesting
@@ -602,6 +615,92 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result
Ok(NativeFile { doc, depth_notes, info, profiles })
}
/// The saved PDF preference, falling back to the presence of its payload without retaining or
/// decoding it. The array form has neither member. Repeated members use their last value.
fn pdf_member(json: &[u8]) -> Result<bool, serde_json::Error> {
struct PdfMember;
impl<'de> Visitor<'de> for PdfMember {
type Value = bool;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a native file envelope")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<bool, A::Error> {
let mut present = false;
let mut preference = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"pdfCompatible" => preference = map.next_value_seed(Preference)?,
"pdf" => present = map.next_value::<Option<IgnoredAny>>()?.is_some(),
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
Ok(preference.unwrap_or(present))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<bool, A::Error> {
while seq.next_element::<IgnoredAny>()?.is_some() {}
Ok(false)
}
}
serde_json::Deserializer::from_slice(json).deserialize_any(PdfMember)
}
/// A `pdfCompatible` value: `Some` for a boolean, `None` for null or any other value, which is
/// skipped without being kept, as an unknown member is.
struct Preference;
impl<'de> DeserializeSeed<'de> for Preference {
type Value = Option<bool>;
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<Option<bool>, D::Error> {
d.deserialize_any(self)
}
}
impl<'de> Visitor<'de> for Preference {
type Value = Option<bool>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("any value")
}
fn visit_bool<E>(self, v: bool) -> Result<Option<bool>, E> {
Ok(Some(v))
}
fn visit_i64<E>(self, _: i64) -> Result<Option<bool>, E> {
Ok(None)
}
fn visit_u64<E>(self, _: u64) -> Result<Option<bool>, E> {
Ok(None)
}
fn visit_f64<E>(self, _: f64) -> Result<Option<bool>, E> {
Ok(None)
}
fn visit_str<E>(self, _: &str) -> Result<Option<bool>, E> {
Ok(None)
}
fn visit_unit<E>(self) -> Result<Option<bool>, E> {
Ok(None)
}
fn visit_seq<A: SeqAccess<'de>>(self, seq: A) -> Result<Option<bool>, A::Error> {
IgnoredAny.visit_seq(seq).map(|_| None)
}
fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Option<bool>, A::Error> {
IgnoredAny.visit_map(map).map(|_| None)
}
}
/// The file's first `generator` member, the app that wrote it (`None`: it has none, it is not a
/// string, or the file is not an object of members). The other members, and any further
/// `generator`, are skipped without being kept, as reading the file skips them.
@@ -671,17 +770,59 @@ mod tests {
assert!(load(br#"{"format":"other","version":1,"document":{}}"#).is_err());
}
#[test]
fn file_info_keeps_pdf_presence_without_reading_its_payload() {
let d = Document::new(10.0, 10.0);
for version in [3, 4] {
for compress in [false, true] {
for present in [false, true] {
let options = SaveOptions { version, compress, pdf: present.then(|| vec![7; 128 * 1024]), ..SaveOptions::default() };
let bytes = save_with(&d, &options).unwrap();
assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, present);
}
}
}
let mut value: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap();
let array = serde_json::json!([EXTENSION, VERSION, value["document"], value["images"], {}]);
assert!(!load_info(&serde_json::to_vec(&array).unwrap()).unwrap().1.pdf_compatible);
value["pdf"] = serde_json::json!({"mime":"application/pdf", "data":"not base64"});
assert!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible);
value["pdf"] = serde_json::Value::Null;
value["unrecognized"] = serde_json::json!({"pdf":{"data":"nested"}});
assert!(!load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible);
// A preference that is not a boolean is skipped like an unknown member: the file opens,
// and the presence of the PDF decides.
for odd in [serde_json::json!(1), serde_json::json!("yes"), serde_json::json!([true]), serde_json::json!({"on": true})] {
for (pdf, present) in [(serde_json::Value::Null, false), (serde_json::json!({"mime":"application/pdf"}), true)] {
value["pdf"] = pdf;
value["pdfCompatible"] = odd.clone();
assert_eq!(load_info(&serde_json::to_vec(&value).unwrap()).unwrap().1.pdf_compatible, present, "{odd}");
}
}
}
#[test]
fn file_info_keeps_the_pdf_preference_without_artboards() {
let mut d = Document::new(10.0, 10.0);
d.artboards.clear();
for compatible in [false, true] {
let bytes = save_with(&d, &SaveOptions { pdf_compatible: Some(compatible), ..SaveOptions::default() }).unwrap();
assert_eq!(load_info(&bytes).unwrap().1.pdf_compatible, compatible);
assert!(pdf_content(&bytes).is_none());
}
}
#[test]
fn file_info_tells_old_files() {
let d = Document::new(10.0, 10.0);
let current = save(&d, false);
assert_eq!(load_info(&current).unwrap().1, FileInfo { version: VERSION, legacy: false });
assert_eq!(load_info(&current).unwrap().1, FileInfo { version: VERSION, legacy: false, pdf_compatible: false });
assert!(!load_info(&current).unwrap().1.is_old());
let text = String::from_utf8(current).unwrap();
let legacy = text.replacen("\"format\":\"vectorcraft\"", "\"format\":\"drawcraft\"", 1);
assert!(load_info(legacy.as_bytes()).unwrap().1.is_old());
let v1 = text.replacen(&format!("\"version\":{VERSION}"), "\"version\":1", 1);
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false });
assert_eq!(load_info(v1.as_bytes()).unwrap().1, FileInfo { version: 1, legacy: false, pdf_compatible: false });
assert!(load_info(v1.as_bytes()).unwrap().1.is_old());
}
@@ -221,6 +221,7 @@ fn save_a_copy_and_template_suggest_their_names() {
assert_eq!(picks.borrow()[0].name, "Untitled-1 copy.vectorcraft");
assert!(app.ui.dialog.is_none(), "the native format has no options");
assert!(vectorcraft_format::sniff(&written.borrow()[0].1));
assert!(vectorcraft_format::pdf_content(&written.borrow()[0].1).is_some());
assert!(app.session.active().unwrap().path.is_none() && app.session.active().unwrap().is_dirty());
app.run("file.saveAsTemplate", json!({})).unwrap();
let pick = &picks.borrow()[1];
+1 -1
View File
@@ -32,7 +32,7 @@ fn save_as_native_asks_for_its_options_and_writes_every_artboard() {
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.str("format").as_str(), d.bool("separateArtboards"), d.bool("embedProfiles"), d.bool("pdfCompatible")),
("vectorcraft", false, true, false)
("vectorcraft", false, true, true)
);
assert!(written.borrow().is_empty(), "nothing is written before OK");
frame(&mut app);
+5 -1
View File
@@ -4976,8 +4976,12 @@ or in `options` (`file.formatOptions {format}` lists them with their values):
- `includeLinked: true` keeps linked images' own pixels in the file (they stay linked), not just the previews.
- `embedProfiles` (default true) carries the ICC profiles the document is tagged with that were loaded from files;
opening the file installs them where they are missing (a warning names one that can't be used).
- `pdfCompatible`: native files (default false) also carry a PDF of every artboard (`pdf` in the file); `.ai` files
- `pdfCompatible`: native files (default true) also carry a PDF of every artboard (`pdf` in the file). This makes saves
larger and adds PDF rendering work. Set it to false to leave the PDF out; Save remembers that choice. `.ai` files
(default true) with false write blank pages around the native document (smaller; other apps show empty pages).
Native files without artboards and Data Recovery copies omit the PDF.
Reopening a native file keeps its saved PDF preference, falling back to whether it declared a PDF in older files.
Existing files saved without a PDF remain compact.
- `.ai` only: `compress` (default true) is the PDF option `compression.compressText`.
- The editing copy of a `.ai` or `.ait` file (`editingData`) names its active artboard (`CropAreaActive`): the active
artboard of the app's view when the app saves or exports the file, otherwise the artboard the document opened on (the
+2 -2
View File
@@ -282,8 +282,8 @@ object: its geometry (`type`, `path`, `fillRule`, `bounds`) and its `fills` and
from before these give geometry only). The paints are read only: an effect may draw as wide as a stroke or use a
fill's colour to decide its shape, but it changes only the geometry.
An **exporter** gets something else: the document as a native `.vectorcraft` file's JSON, as `document.save` writes it
but without indentation (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object,
An **exporter** gets something else: the native `.vectorcraft` document JSON without indentation, optional preview/PDF
renditions or save-preference metadata (the format is documented in [`crates/format/src/lib.rs`](../crates/format/src/lib.rs)). That is every layer, object,
appearance and effect record, artboard, swatch and symbol, so an exporter reads whatever the document holds; it checks
`version`, since a later host may write a newer one. Images of 8 KiB or more are listed in `images` with their MIME
type and `blob` but without their data, which follows the JSON in a saved file; smaller ones keep their base64 `data`.