mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 11:47:21 -06:00
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>
171 lines
8.9 KiB
Rust
171 lines
8.9 KiB
Rust
//! 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}");
|
||
}
|
||
}
|