mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
A PDF, PDF-compatible .ai or .ait opened from its pages had an empty File Info. Nothing read the file's document information dictionary or its XMP packet, so file.info reported no author, description or keywords, even for a PDF that VectorCraft exported. vectorcraft_pdf::file_info reads both. The author, description, keywords, copyright status, notice and URL and the created and modified dates come from the XMP packet (dc:creator, dc:description, dc:subject or pdf:Keywords, xmpRights:Marked, dc:rights, xmpRights:WebStatement, xmp:CreateDate, xmp:ModifyDate), else from the dictionary's Author, Subject, Keywords, CreationDate and ModDate. document.open, file.newFromTemplate and file.revert put each value through file.info's checks and leave out a value that file.info refuses. A packet that is not well-formed XML leaves the dictionary's values. File Info does not set the title, and a file restored from the document it carries keeps that document's File Info. vectorcraft-pdf depends on roxmltree 0.21, which usvg already brings into Cargo.lock. Docs: docs/mcp.md (File Info), docs/file-format-parity.md, docs/target-app-parity.md.
245 lines
12 KiB
Rust
245 lines
12 KiB
Rust
//! File → File Info (`file.info`): editing as one undo step, the native round trip, and the PDF,
|
|
//! PNG and SVG metadata it writes.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_doc::{CopyrightStatus, DocMetadata};
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 80, "title": "Poster"})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn info() -> Value {
|
|
json!({
|
|
"author": "Ada Lovelace",
|
|
"authorTitle": "Designer",
|
|
"description": "A poster\nfor the fair",
|
|
"keywords": "fair, Poster; print, poster",
|
|
"rating": 4,
|
|
"copyrightStatus": "copyrighted",
|
|
"copyrightNotice": "(c) 2026 Ada",
|
|
"copyrightUrl": "https://example.com/rights",
|
|
})
|
|
}
|
|
|
|
fn b64(v: &Value) -> Vec<u8> {
|
|
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn file_info_is_one_undo_step_and_reports_everything() {
|
|
let mut s = session();
|
|
let r = s.execute("file.info", &info()).unwrap();
|
|
assert_eq!(r["keywords"], json!(["fair", "Poster", "print"]), "keywords split, trimmed, each once ignoring case");
|
|
assert_eq!((r["rating"].as_u64(), r["copyrightStatus"].as_str()), (Some(4), Some("copyrighted")));
|
|
assert_eq!(r["description"], "A poster\nfor the fair");
|
|
assert!(r["created"].as_str().is_some_and(|c| c.ends_with('Z')), "a new document has its creation date: {}", r["created"]);
|
|
assert!(r["modified"].is_null(), "never saved");
|
|
assert_eq!(r["title"], "Poster");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.history.undo.len(), 1);
|
|
assert_eq!(st.doc.metadata.author, "Ada Lovelace");
|
|
// The report fed back changes nothing (read-only values are ignored).
|
|
let again = s.execute("file.info", &r).unwrap();
|
|
assert_eq!(again, r);
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), 1);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let m = &s.doc().unwrap().doc.metadata;
|
|
assert_eq!((m.author.as_str(), m.keywords.len()), ("", 0));
|
|
// Clearing a field with null.
|
|
s.execute("edit.redo", &json!({})).unwrap();
|
|
s.execute("file.info", &json!({"author": null, "keywords": []})).unwrap();
|
|
let m = &s.doc().unwrap().doc.metadata;
|
|
assert_eq!((m.author.as_str(), m.keywords.len(), m.rating), ("", 0, 4));
|
|
}
|
|
|
|
#[test]
|
|
fn junk_is_rejected() {
|
|
let mut s = session();
|
|
for bad in [
|
|
json!({"rating": 9}),
|
|
json!({"rating": -1}),
|
|
json!({"rating": "five"}),
|
|
json!({"keywords": 5}),
|
|
json!({"keywords": ["ok", 3]}),
|
|
json!({"copyrightStatus": "maybe"}),
|
|
json!({"copyrightUrl": "not a url"}),
|
|
json!({"author": "two\nlines"}),
|
|
json!({"description": "bell\u{7}"}),
|
|
json!({"title": " "}),
|
|
json!({"author": 3}),
|
|
json!({"favouriteColour": "blue"}),
|
|
json!([1, 2]),
|
|
json!({"keywords": "x".repeat(300)}),
|
|
] {
|
|
assert!(s.execute("file.info", &bad).is_err(), "{bad}");
|
|
}
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), 0);
|
|
assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() });
|
|
}
|
|
|
|
#[test]
|
|
fn file_info_round_trips_and_old_files_load() {
|
|
let mut s = session();
|
|
s.execute("file.info", &info()).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
let back = vectorcraft_format::load(&vectorcraft_format::save_file(d)).unwrap();
|
|
assert_eq!(back.metadata, d.metadata);
|
|
assert_eq!(back.metadata.copyright_status, CopyrightStatus::Copyrighted);
|
|
// A document saved before File Info existed has none.
|
|
let mut old = vectorcraft_doc::Document::new(10.0, 10.0);
|
|
old.metadata = DocMetadata::default();
|
|
let bytes = vectorcraft_format::save_file(&old);
|
|
assert!(!String::from_utf8_lossy(&bytes).contains("\"metadata\""), "default File Info isn't written");
|
|
assert_eq!(vectorcraft_format::load(&bytes).unwrap().metadata, DocMetadata::default());
|
|
}
|
|
|
|
#[test]
|
|
fn saving_to_a_file_stamps_the_modified_date() {
|
|
let mut s = session();
|
|
let created = s.doc().unwrap().doc.metadata.created;
|
|
let path = std::env::temp_dir().join(format!("vc-fileinfo-{}.vectorcraft", std::process::id()));
|
|
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
let st = s.doc().unwrap();
|
|
assert!(st.doc.metadata.modified.is_some() && st.doc.metadata.created == created);
|
|
assert!(!st.is_dirty(), "the stamped document is the saved one");
|
|
let back = vectorcraft_format::load(&std::fs::read(&path).unwrap()).unwrap();
|
|
assert_eq!(back.metadata.modified, st.doc.metadata.modified);
|
|
let _ = std::fs::remove_file(&path);
|
|
assert!(s.execute("file.info", &json!({})).unwrap()["modified"].is_string());
|
|
}
|
|
|
|
#[test]
|
|
fn pdf_info_carries_author_subject_and_keywords() {
|
|
let mut s = session();
|
|
s.execute("file.info", &info()).unwrap();
|
|
let pdf = crate::export_pdf(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
|
let text = String::from_utf8_lossy(&pdf);
|
|
// (The description has a line break, so it is written as UTF-16.)
|
|
for want in ["/Author(Ada Lovelace)", "/Keywords(fair, Poster, print)", "/Title(Poster)", "/Subject<FEFF0041"] {
|
|
assert!(text.contains(want), "PDF Info has {want}");
|
|
}
|
|
// XMP too.
|
|
assert!(text.contains("<dc:creator>") && text.contains("Ada Lovelace"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_exported_pdf_opens_with_its_file_info() {
|
|
let mut s = session();
|
|
s.execute("file.info", &info()).unwrap();
|
|
let exported = s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap();
|
|
let r = s.execute("document.open", &json!({"name": "poster.pdf", "dataBase64": exported["dataBase64"]})).unwrap();
|
|
assert_eq!(r["restored"], false, "opened from its pages");
|
|
let back = s.execute("file.info", &json!({})).unwrap();
|
|
assert_eq!(back["author"], "Ada Lovelace");
|
|
assert_eq!(back["description"], "A poster\nfor the fair");
|
|
assert_eq!(back["keywords"], json!(["fair", "Poster", "print"]));
|
|
assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]);
|
|
assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
|
|
}
|
|
|
|
#[test]
|
|
fn a_pdf_compatible_ai_opens_with_the_file_info_of_its_xmp_packet() {
|
|
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
|
let xmp = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
|
|
<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmpRights=\"http://ns.adobe.com/xap/1.0/rights/\" xmpRights:Marked=\"False\">\
|
|
<dc:creator><rdf:Seq><rdf:li>Grace Hopper</rdf:li></rdf:Seq></dc:creator>\
|
|
<dc:subject><rdf:Bag><rdf:li>navy</rdf:li><rdf:li>cobol</rdf:li></rdf:Bag></dc:subject>\
|
|
<dc:rights><rdf:Alt><rdf:li xml:lang=\"x-default\">No rights reserved</rdf:li></rdf:Alt></dc:rights>\
|
|
<xmpRights:WebStatement>https://example.com/a page</xmpRights:WebStatement>\
|
|
</rdf:Description></rdf:RDF></x:xmpmeta>";
|
|
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
|
let catalog = format!("/Metadata {} 0 R ", first_extra(1));
|
|
let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &[&stream], &catalog, None);
|
|
let mut s = Session::new();
|
|
s.execute("document.open", &json!({"name": "notes.ai", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
|
let m = &s.doc().unwrap().doc.metadata;
|
|
assert_eq!((m.author.as_str(), m.keywords.clone()), ("Grace Hopper", vec!["navy".to_string(), "cobol".into()]));
|
|
assert_eq!((m.copyright_status, m.copyright_notice.as_str()), (CopyrightStatus::PublicDomain, "No rights reserved"));
|
|
// A URL with a space is one `file.info` refuses: it is left out.
|
|
assert_eq!(m.copyright_url, "");
|
|
}
|
|
|
|
#[test]
|
|
fn png_exports_carry_text_chunks() {
|
|
let mut s = session();
|
|
s.execute("file.info", &info()).unwrap();
|
|
let png = b64(&s.execute("document.export", &json!({"format": "png"})).unwrap());
|
|
let text = crate::cmd::fileio::pngtext::text(&png);
|
|
let get = |k: &str| text.iter().find(|(key, _)| key == k).map(|(_, v)| v.as_str());
|
|
assert_eq!(get("Title"), Some("Poster"));
|
|
assert_eq!(get("Author"), Some("Ada Lovelace"));
|
|
assert_eq!(get("Keywords"), Some("fair, Poster, print"));
|
|
assert_eq!(get("Copyright"), Some("(c) 2026 Ada"));
|
|
assert!(get("Creation Time").is_some_and(|t| t.ends_with('Z')));
|
|
assert!(image::load_from_memory(&png).is_ok(), "still a valid PNG");
|
|
}
|
|
|
|
#[test]
|
|
fn svg_metadata_carries_file_info_as_dublin_core() {
|
|
let mut s = session();
|
|
s.execute("file.info", &info()).unwrap();
|
|
let svg = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg", "metadata": true})).unwrap())).unwrap();
|
|
for want in [
|
|
"<dc:title>Poster</dc:title>",
|
|
"<dc:creator>Ada Lovelace</dc:creator>",
|
|
"<dc:subject>fair</dc:subject>",
|
|
"<dc:subject>print</dc:subject>",
|
|
"<dc:rights>(c) 2026 Ada</dc:rights>",
|
|
"<dc:rights>https://example.com/rights</dc:rights>",
|
|
"<desc>A poster\nfor the fair</desc>",
|
|
] {
|
|
assert!(svg.contains(want), "SVG has {want}");
|
|
}
|
|
// Without the option: no <metadata>, but the title and description stay.
|
|
let plain = String::from_utf8(b64(&s.execute("document.export", &json!({"format": "svg"})).unwrap())).unwrap();
|
|
assert!(!plain.contains("<metadata>") && plain.contains("<desc>"));
|
|
}
|
|
|
|
/// The PDF `document.exportPdf` and `document.export` write of the active document (text
|
|
/// uncompressed, so its metadata reads).
|
|
fn pdf_texts(s: &mut Session) -> [String; 2] {
|
|
let p = json!({"compression": {"compressText": false}, "preserveEditing": false});
|
|
let a = b64(&s.execute("document.exportPdf", &p).unwrap());
|
|
let b = b64(&s.execute("document.export", &json!({"format": "pdf", "compression": {"compressText": false}, "preserveEditing": false})).unwrap());
|
|
[a, b].map(|pdf| String::from_utf8_lossy(&pdf).into_owned())
|
|
}
|
|
|
|
/// The XMP `dc:title` of a PDF's text.
|
|
fn xmp_title(pdf: &str) -> &str {
|
|
let from = pdf.find("<dc:title>").expect("XMP title");
|
|
&pdf[from..from + pdf[from..].find("</dc:title>").unwrap()]
|
|
}
|
|
|
|
#[test]
|
|
fn an_untitled_document_saved_to_a_file_exports_titled_after_it() {
|
|
let dir = std::env::temp_dir().join(format!("vc-pdftitle-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("Document C.vectorcraft");
|
|
// Saved as a file, then exported: Untitled-1 was only the name New gave it.
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
|
s.execute("document.save", &json!({"path": path.to_str().unwrap()})).unwrap();
|
|
for pdf in pdf_texts(&mut s) {
|
|
assert!(pdf.contains("/Title(Document C)") && xmp_title(&pdf).contains("Document C"), "{}", xmp_title(&pdf));
|
|
}
|
|
// Opened from that file, in another session.
|
|
let mut s = Session::new();
|
|
s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap();
|
|
for pdf in pdf_texts(&mut s) {
|
|
assert!(pdf.contains("/Title(Document C)") && !pdf.contains("Untitled"), "{}", xmp_title(&pdf));
|
|
}
|
|
// The document's own title is left as it was.
|
|
assert_eq!(s.doc().unwrap().doc.title, "Untitled-1");
|
|
// A title of its own (File Info, New Document's Name) stays.
|
|
let mut s = session();
|
|
s.execute("document.save", &json!({"path": dir.join("Document D.vectorcraft").to_str().unwrap()})).unwrap();
|
|
for pdf in pdf_texts(&mut s) {
|
|
assert!(pdf.contains("/Title(Poster)") && xmp_title(&pdf).contains("Poster"));
|
|
}
|
|
let _ = std::fs::remove_dir_all(dir);
|
|
}
|