Files
vectorcraft/crates/engine/src/cmd/fileio/tests_pdfedit.rs
T
bflatasticandClaude Opus 5.5 0ffaa427d1 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>
2026-10-05 10:34:15 +07:00

171 lines
8.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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}");
}
}