mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
PDF/.ai import: PDF and .ai files open with the File Info they carry
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.
This commit is contained in:
Generated
+1
@@ -4787,6 +4787,7 @@ dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"roxmltree 0.21.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! File → File Info (`file.info`): the document title and its descriptive metadata (author,
|
||||
//! description, keywords, rating, copyright), with the read-only created and modified dates. The
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it.
|
||||
//! SVG (with `metadata`), PDF and PNG writers carry it, and a PDF or `.ai` opens with the File Info
|
||||
//! it gives ([`read_pdf`]).
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::metadata::{MAX_RATING, iso8601};
|
||||
@@ -62,6 +63,30 @@ pub(crate) fn file_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(info_json(&s.doc()?.doc))
|
||||
}
|
||||
|
||||
/// The File Info a PDF or PDF-compatible `.ai` gives ([`vectorcraft_pdf::file_info`]) into `m`: each
|
||||
/// field that `file.info` would take (a value it refuses is left out), and the created and
|
||||
/// modified dates.
|
||||
pub(crate) fn read_pdf(m: &mut DocMetadata, f: vectorcraft_pdf::FileInfo) {
|
||||
let status = f.copyrighted.map(|c| if c { CopyrightStatus::Copyrighted } else { CopyrightStatus::PublicDomain });
|
||||
let fields = [
|
||||
("author", f.author.map(Value::from)),
|
||||
("description", f.description.map(Value::from)),
|
||||
("keywords", f.keywords.map(Value::from)),
|
||||
("copyrightStatus", status.map(|s| Value::from(s.id()))),
|
||||
("copyrightNotice", f.copyright_notice.map(Value::from)),
|
||||
("copyrightUrl", f.copyright_url.map(Value::from)),
|
||||
];
|
||||
// `apply` changes the field only when it takes the value.
|
||||
let mut title = String::new();
|
||||
for (k, v) in fields {
|
||||
if let Some(v) = v {
|
||||
let _ = apply(&mut title, m, k, &v);
|
||||
}
|
||||
}
|
||||
m.created = f.created.or(m.created);
|
||||
m.modified = f.modified.or(m.modified);
|
||||
}
|
||||
|
||||
fn apply(title: &mut String, m: &mut DocMetadata, k: &str, v: &Value) -> Result<()> {
|
||||
match k {
|
||||
"title" => {
|
||||
|
||||
@@ -254,6 +254,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// A PDF, `.ai` or `.ait` read from its pages or its editing copy, not restored from the document
|
||||
// it carries, opens with the File Info it gives (a PostScript `.ai` gives none).
|
||||
if matches!(format.id, "pdf" | "ai" | "ait")
|
||||
&& !restored
|
||||
&& let Some(info) = vectorcraft_pdf::file_info(bytes, opts.password.as_deref())
|
||||
{
|
||||
super::super::fileinfo::read_pdf(&mut doc.metadata, info);
|
||||
}
|
||||
// Threaded type read from a file flows through its frames.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
|
||||
@@ -126,6 +126,43 @@ fn pdf_info_carries_author_subject_and_keywords() {
|
||||
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();
|
||||
|
||||
@@ -710,6 +710,75 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// An XMP packet with every property File Info reads: arrays, language alternatives, attributes,
|
||||
/// a qualified value, a URI value and dates.
|
||||
const RICH_XMP: &str = "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><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:xmp=\"http://ns.adobe.com/xap/1.0/\" \
|
||||
xmlns:xmpRights=\"http://ns.adobe.com/xap/1.0/rights/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" \
|
||||
xmlns:q=\"urn:example:qualifiers\" xmpRights:Marked=\"True\" xmp:ModifyDate=\"2026-10-06T09:00:00.5+05:30\">\
|
||||
<dc:creator><rdf:Seq><rdf:li rdf:parseType=\"Resource\"><rdf:value>Ada</rdf:value><q:role>Author</q:role></rdf:li><rdf:li>Grace</rdf:li></rdf:Seq></dc:creator>\
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">A poster</rdf:li><rdf:li xml:lang=\"de\">Ein Plakat</rdf:li></rdf:Alt></dc:description>\
|
||||
<dc:subject><rdf:Bag><rdf:li>fair</rdf:li><rdf:li>print</rdf:li></rdf:Bag></dc:subject><pdf:Keywords>a, b; c</pdf:Keywords>\
|
||||
<dc:rights><rdf:Alt><rdf:li xml:lang=\"x-default\">(c) 2026 Ada</rdf:li></rdf:Alt></dc:rights>\
|
||||
<xmpRights:WebStatement rdf:resource=\"https://example.com/rights\"/><xmp:CreateDate>2026-10-05T14:03:00Z</xmp:CreateDate>\
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end=\"w\"?>";
|
||||
|
||||
/// A PDF whose trailer names an information dictionary with the entries `info` and whose catalog's
|
||||
/// metadata stream holds `xmp`.
|
||||
fn file_info_pdf(info: &str, xmp: &str) -> Vec<u8> {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
let x = first_extra(1);
|
||||
let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len());
|
||||
let dict = format!("<< {info} >>");
|
||||
let pdf = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &[&stream, &dict], &format!("/Metadata {x} 0 R "), None);
|
||||
// The trailer follows the cross-reference table, so a longer trailer moves no object.
|
||||
String::from_utf8(pdf).unwrap().replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {} 0 R", x + 1), 1).into_bytes()
|
||||
}
|
||||
|
||||
/// The sample `pdf_file_info_never_panics` mutates opens with the File Info of its packet.
|
||||
#[test]
|
||||
fn the_file_info_sample_reaches_the_reader() {
|
||||
let pdf = file_info_pdf("/Author (Info author) /Subject (Info subject)", RICH_XMP);
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("document.open", &json!({"name": "x.ai", "dataBase64": vectorcraft_format::base64_encode(&pdf)})).unwrap();
|
||||
let info = s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!((info["author"].as_str(), info["description"].as_str()), (Some("Ada, Grace"), Some("A poster")));
|
||||
assert_eq!((info["copyrightStatus"].as_str(), info["keywords"].clone()), (Some("copyrighted"), json!(["fair", "print"])));
|
||||
assert_eq!(info["copyrightUrl"].as_str(), Some("https://example.com/rights"));
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// File Info read on open: mutated XMP packets and junk information dictionaries, opened as a
|
||||
/// PDF and as a `.ai`.
|
||||
#[test]
|
||||
fn pdf_file_info_never_panics(
|
||||
cut in 0usize..2_000,
|
||||
edits in prop::collection::vec((0usize..2_000, prop::sample::select(vec!['<', '>', '"', '/', ':', '-', '+', 'T', 'Z', '9', '0', '.', ' ', '&', ';', '=', '#'])), 0..10),
|
||||
info in prop::sample::select(vec![
|
||||
"",
|
||||
"/Author (Ada) /Subject (A poster) /Keywords (a;b,,c ;) /CreationDate (D:2026) /ModDate (D:20261005140300-08'30')",
|
||||
"/CreationDate (D:99999999999999+99'99') /ModDate (D:20261301) /Author 5 /Keywords [(a)]",
|
||||
"/Subject <FEFF00> /Author <FEFFD800> /Keywords <EFBBBF> /Title (\\377\\376)",
|
||||
"/CreationDate (D:00000101000000Z) /ModDate (D:99991231235959+23'59')",
|
||||
]),
|
||||
) {
|
||||
let pdf = file_info_pdf(info, &mutate_text(RICH_XMP, cut, &edits));
|
||||
let b64 = vectorcraft_format::base64_encode(&pdf);
|
||||
let r = catch_quiet(|| {
|
||||
let _ = vectorcraft_pdf::file_info(&pdf, None);
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
for name in ["x.pdf", "x.ai"] {
|
||||
let _ = no_panic(s.execute("document.open", &json!({"name": name, "dataBase64": b64})));
|
||||
let _ = no_panic(s.execute("file.info", &json!({})));
|
||||
}
|
||||
});
|
||||
r.map_err(|msg| TestCaseError::fail(format!("{info}: panicked: {msg}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile content over layers (optional content, one group off), soft masks, tiling patterns,
|
||||
/// a patch mesh, an unextended shading and fonts: the import's own readings of the file.
|
||||
fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
|
||||
|
||||
@@ -27,6 +27,8 @@ log = { workspace = true }
|
||||
smallvec = "1"
|
||||
weezl = "0.1"
|
||||
serde = { workspace = true }
|
||||
# The XMP packet that File Info is read from (file_info.rs); the version usvg already uses.
|
||||
roxmltree = "0.21"
|
||||
# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm.
|
||||
aes = "0.8"
|
||||
md-5 = { version = "0.10", default-features = false }
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! File › File Info read from a PDF (and a PDF-compatible `.ai`): the document information
|
||||
//! dictionary (ISO 32000-1 §14.3.3) and the XMP packet of the catalog's metadata stream (§14.3.2,
|
||||
//! ISO 16684-1). Where both give a field, the XMP packet's value is taken; a packet that can't be
|
||||
//! read leaves the information dictionary's values.
|
||||
|
||||
use craft_pdf::syntax::Pdf;
|
||||
use craft_pdf::syntax::object::{DateTime, Dict, Stream};
|
||||
use roxmltree::Node;
|
||||
|
||||
use crate::import_scan::text_string;
|
||||
|
||||
/// The longest XMP packet read, in bytes once decoded; a longer one is left out.
|
||||
const MAX_XMP: usize = 8 << 20;
|
||||
|
||||
const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
||||
const XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const DC: &str = "http://purl.org/dc/elements/1.1/";
|
||||
const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use
|
||||
const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use
|
||||
const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use
|
||||
|
||||
/// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FileInfo {
|
||||
/// XMP `dc:creator` (its names joined with ", "), else Info `Author`.
|
||||
pub author: Option<String>,
|
||||
/// XMP `dc:description` (its `x-default` item, else its first), else Info `Subject`.
|
||||
pub description: Option<String>,
|
||||
/// XMP `dc:subject`, else XMP `pdf:Keywords` or Info `Keywords` split at commas and semicolons.
|
||||
pub keywords: Option<Vec<String>>,
|
||||
/// XMP `dc:rights` (its `x-default` item, else its first).
|
||||
pub copyright_notice: Option<String>,
|
||||
/// XMP `xmpRights:WebStatement`.
|
||||
pub copyright_url: Option<String>,
|
||||
/// XMP `xmpRights:Marked`: true for a copyrighted work, false for a public-domain one.
|
||||
pub copyrighted: Option<bool>,
|
||||
/// XMP `xmp:CreateDate`, else Info `CreationDate`, as Unix seconds (UTC).
|
||||
pub created: Option<i64>,
|
||||
/// XMP `xmp:ModifyDate`, else Info `ModDate`, as Unix seconds (UTC).
|
||||
pub modified: Option<i64>,
|
||||
}
|
||||
|
||||
/// The File Info of `bytes` (a PDF, or a PDF-compatible `.ai`), opened with `password` when it is
|
||||
/// encrypted; `None` when it can't be opened.
|
||||
pub fn file_info(bytes: &[u8], password: Option<&str>) -> Option<FileInfo> {
|
||||
Some(file_info_in(&crate::pages::open(bytes, password).ok()?))
|
||||
}
|
||||
|
||||
/// [`file_info`] of a parsed PDF.
|
||||
pub(crate) fn file_info_in(pdf: &Pdf) -> FileInfo {
|
||||
let mut f = packet(pdf).and_then(|p| xmp(&p)).unwrap_or_default();
|
||||
let info = pdf.metadata();
|
||||
let text = |b: &Option<Vec<u8>>| b.as_deref().map(info_string).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
f.author = f.author.or_else(|| text(&info.author));
|
||||
f.description = f.description.or_else(|| text(&info.subject));
|
||||
f.keywords = f.keywords.or_else(|| text(&info.keywords).map(|k| split(&k)).filter(|k| !k.is_empty()));
|
||||
f.created = f.created.or_else(|| info.creation_date.and_then(pdf_date));
|
||||
f.modified = f.modified.or_else(|| info.modification_date.and_then(pdf_date));
|
||||
f
|
||||
}
|
||||
|
||||
/// An information dictionary string: UTF-16 or UTF-8 with its byte order mark, as [`text_string`]
|
||||
/// reads it, else PDFDocEncoding (ISO 32000-1 Annex D, Table D.2).
|
||||
fn info_string(b: &[u8]) -> String {
|
||||
if b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
return text_string(b);
|
||||
}
|
||||
b.iter().map(|&c| pdf_doc_char(c)).collect()
|
||||
}
|
||||
|
||||
/// Byte `c` of a PDFDocEncoding string. It matches Latin-1 except at 0x18 to 0x1F (accents) and
|
||||
/// 0x80 to 0xA0 (punctuation, ligatures, letters and the euro sign); 0x9F has no character and
|
||||
/// stays as it is.
|
||||
fn pdf_doc_char(c: u8) -> char {
|
||||
const ACCENTS: [char; 8] = ['\u{2D8}', '\u{2C7}', '\u{2C6}', '\u{2D9}', '\u{2DD}', '\u{2DB}', '\u{2DA}', '\u{2DC}'];
|
||||
const HIGH: [char; 33] = [
|
||||
'\u{2022}', '\u{2020}', '\u{2021}', '\u{2026}', '\u{2014}', '\u{2013}', '\u{192}', '\u{2044}', '\u{2039}', '\u{203A}', '\u{2212}',
|
||||
'\u{2030}', '\u{201E}', '\u{201C}', '\u{201D}', '\u{2018}', '\u{2019}', '\u{201A}', '\u{2122}', '\u{FB01}', '\u{FB02}', '\u{141}', '\u{152}',
|
||||
'\u{160}', '\u{178}', '\u{17D}', '\u{131}', '\u{142}', '\u{153}', '\u{161}', '\u{17E}', '\u{9F}', '\u{20AC}',
|
||||
];
|
||||
let mapped = match c {
|
||||
0x18..=0x1F => ACCENTS.get(usize::from(c - 0x18)),
|
||||
0x80..=0xA0 => HIGH.get(usize::from(c - 0x80)),
|
||||
_ => None,
|
||||
};
|
||||
mapped.copied().unwrap_or(char::from(c))
|
||||
}
|
||||
|
||||
/// The catalog's metadata stream, decoded; `None` past [`MAX_XMP`] bytes.
|
||||
fn packet(pdf: &Pdf) -> Option<Vec<u8>> {
|
||||
let xref = pdf.xref();
|
||||
let catalog = xref.get::<Dict<'_>>(xref.root_id())?;
|
||||
let data = catalog.get::<Stream<'_>>(b"Metadata")?.decoded_within(MAX_XMP + 1).ok()?;
|
||||
(data.len() <= MAX_XMP).then(|| data.into_owned())
|
||||
}
|
||||
|
||||
/// The File Info fields of an XMP packet; `None` when it isn't XML in UTF-8 or UTF-16.
|
||||
fn xmp(packet: &[u8]) -> Option<FileInfo> {
|
||||
let text = xml_text(packet)?;
|
||||
let xml = roxmltree::Document::parse(&text).ok()?;
|
||||
// The resource's own descriptions: those right under `rdf:RDF`, not the ones inside a value.
|
||||
let descriptions: Vec<Node<'_, '_>> = xml
|
||||
.descendants()
|
||||
.filter(|n| n.has_tag_name((RDF, "RDF")))
|
||||
.flat_map(|rdf| rdf.children())
|
||||
.filter(|n| n.has_tag_name((RDF, "Description")))
|
||||
.collect();
|
||||
let get = |ns: &str, name: &str| property(&descriptions, ns, name);
|
||||
let first = |ns: &str, name: &str| get(ns, name).into_iter().next();
|
||||
let creators = get(DC, "creator");
|
||||
let subjects = get(DC, "subject");
|
||||
Some(FileInfo {
|
||||
author: (!creators.is_empty()).then(|| creators.join(", ")),
|
||||
description: first(DC, "description"),
|
||||
keywords: if subjects.is_empty() { first(PDF, "Keywords").map(|k| split(&k)).filter(|k| !k.is_empty()) } else { Some(subjects) },
|
||||
copyright_notice: first(DC, "rights"),
|
||||
copyright_url: first(XMP_RIGHTS, "WebStatement"),
|
||||
copyrighted: first(XMP_RIGHTS, "Marked").and_then(|m| match m.to_ascii_lowercase().as_str() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}),
|
||||
created: first(XMP, "CreateDate").and_then(|d| xmp_date(&d)),
|
||||
modified: first(XMP, "ModifyDate").and_then(|d| xmp_date(&d)),
|
||||
})
|
||||
}
|
||||
|
||||
/// A packet's text: UTF-8 (with or without a byte order mark), or UTF-16 told by its byte order
|
||||
/// mark or by a zero byte beside the `<` it starts with.
|
||||
fn xml_text(b: &[u8]) -> Option<String> {
|
||||
let utf16 = |be: bool| {
|
||||
let units: Vec<u16> = b.as_chunks::<2>().0.iter().map(|c| if be { u16::from_be_bytes(*c) } else { u16::from_le_bytes(*c) }).collect();
|
||||
String::from_utf16(&units).ok().map(|s| s.trim_start_matches('\u{FEFF}').to_string())
|
||||
};
|
||||
match b {
|
||||
[0xFE, 0xFF, ..] | [0, b'<', ..] => utf16(true),
|
||||
[0xFF, 0xFE, ..] | [b'<', 0, ..] => utf16(false),
|
||||
_ => std::str::from_utf8(b.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(b)).ok().map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of property `ns` `name` on the first of `descriptions` that gives it: an attribute
|
||||
/// of the `rdf:Description`, or a property element's items ([`items`]), blank ones left out.
|
||||
fn property(descriptions: &[Node<'_, '_>], ns: &str, name: &str) -> Vec<String> {
|
||||
for d in descriptions {
|
||||
if let Some(v) = d.attribute((ns, name)).map(str::trim).filter(|v| !v.is_empty()) {
|
||||
return vec![v.to_string()];
|
||||
}
|
||||
let found = d.children().filter(|c| c.has_tag_name((ns, name))).map(items).find(|v| !v.is_empty());
|
||||
if let Some(v) = found {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// The items of property element `p`: the `rdf:li` of its `rdf:Seq`, `rdf:Bag` or `rdf:Alt` (the
|
||||
/// `x-default` language item first), else its own value ([`value_of`]); trimmed, blank ones left
|
||||
/// out.
|
||||
fn items(p: Node<'_, '_>) -> Vec<String> {
|
||||
let array = p.children().find(|c| ["Seq", "Bag", "Alt"].into_iter().any(|t| c.has_tag_name((RDF, t))));
|
||||
let mut values: Vec<(bool, String)> = match array {
|
||||
Some(a) => a
|
||||
.children()
|
||||
.filter(|c| c.has_tag_name((RDF, "li")))
|
||||
.map(|li| (li.attribute((XML, "lang")).is_some_and(|l| l.eq_ignore_ascii_case("x-default")), value_of(li)))
|
||||
.collect(),
|
||||
None => vec![(false, value_of(p))],
|
||||
};
|
||||
// A stable sort: the `x-default` item first, the others in their order.
|
||||
values.sort_by_key(|(default, _)| !default);
|
||||
values.into_iter().map(|(_, v)| v.trim().to_string()).filter(|v| !v.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// The value of element `n`: the URI of its `rdf:resource` attribute (how RDF writes a URI value);
|
||||
/// for a qualified value, its `rdf:value` (an attribute or an element, on `n` or on an
|
||||
/// `rdf:Description` inside it), without the qualifiers; else the text inside it.
|
||||
fn value_of(mut n: Node<'_, '_>) -> String {
|
||||
// Each step goes to an element inside `n`, so the loop ends.
|
||||
loop {
|
||||
if let Some(uri) = n.attribute((RDF, "resource")) {
|
||||
return uri.to_string();
|
||||
}
|
||||
let holder = n.children().find(|c| c.has_tag_name((RDF, "Description"))).unwrap_or(n);
|
||||
if let Some(v) = holder.attribute((RDF, "value")) {
|
||||
return v.to_string();
|
||||
}
|
||||
match holder.children().find(|c| c.has_tag_name((RDF, "value"))) {
|
||||
Some(v) => n = v,
|
||||
None => return n.descendants().filter(|d| d.is_text()).filter_map(|d| d.text()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keywords written as one text (Info `Keywords`, XMP `pdf:Keywords`): split at commas and
|
||||
/// semicolons, trimmed, blank ones left out.
|
||||
fn split(s: &str) -> Vec<String> {
|
||||
s.split([',', ';']).map(str::trim).filter(|k| !k.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// A PDF date (Info `CreationDate`, `ModDate`) as Unix seconds; `None` for a day its month doesn't
|
||||
/// have.
|
||||
fn pdf_date(d: DateTime) -> Option<i64> {
|
||||
let sign = if d.utc_offset_hour < 0 { -1 } else { 1 };
|
||||
let offset = i64::from(d.utc_offset_hour) * 60 + sign * i64::from(d.utc_offset_minute);
|
||||
let [year, month, day] = [d.year, d.month.into(), d.day.into()].map(i64::from);
|
||||
let time = [d.hour, d.minute, d.second].map(i64::from);
|
||||
in_month(year, month, day).then(|| unix([year, month, day, time[0], time[1], time[2]], offset))
|
||||
}
|
||||
|
||||
/// Whether `day` is a day of `month` (1 to 12) in `year` (proleptic Gregorian).
|
||||
fn in_month(year: i64, month: i64, day: i64) -> bool {
|
||||
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
|
||||
let last = match month {
|
||||
2 if leap => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
};
|
||||
(1..=last).contains(&day)
|
||||
}
|
||||
|
||||
/// An XMP date (ISO 16684-1: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, then optionally `Thh:mm`, `:ss` with
|
||||
/// a fraction, and a zone `Z` or `±hh:mm`) as Unix seconds; `None` when it isn't one.
|
||||
fn xmp_date(s: &str) -> Option<i64> {
|
||||
let num = |p: Option<&str>, len: usize, max: i64| -> Option<i64> {
|
||||
let p = p?;
|
||||
let n: i64 = p.parse().ok().filter(|_| p.len() == len && p.bytes().all(|b| b.is_ascii_digit()))?;
|
||||
(n <= max).then_some(n)
|
||||
};
|
||||
let (date, time) = s.trim().split_once('T').map_or((s.trim(), None), |(d, t)| (d, Some(t)));
|
||||
let mut ymd = date.split('-');
|
||||
let year = num(ymd.next(), 4, 9999)?;
|
||||
let month = ymd.next().map_or(Some(1), |m| num(Some(m), 2, 12).filter(|m| *m >= 1))?;
|
||||
let day = ymd.next().map_or(Some(1), |d| num(Some(d), 2, 31).filter(|d| *d >= 1))?;
|
||||
if ymd.next().is_some() || !in_month(year, month, day) {
|
||||
return None;
|
||||
}
|
||||
let Some(time) = time else { return Some(unix([year, month, day, 0, 0, 0], 0)) };
|
||||
let (clock, zone) = time.find(['Z', '+', '-']).map_or((time, ""), |at| time.split_at(at));
|
||||
let mut hms = clock.split(':');
|
||||
let hour = num(hms.next(), 2, 23)?;
|
||||
let minute = num(hms.next(), 2, 59)?;
|
||||
// A leap second (60) counts as 59; the fraction is dropped.
|
||||
let second = hms.next().map_or(Some(0), |s| num(s.split('.').next(), 2, 60).map(|s| s.min(59)))?;
|
||||
if hms.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
let offset = match zone {
|
||||
"" | "Z" => 0,
|
||||
_ => {
|
||||
let (sign, hm) = zone.split_at_checked(1)?;
|
||||
let (h, m) = hm.split_once(':')?;
|
||||
(if sign == "-" { -1 } else { 1 }) * (num(Some(h), 2, 23)? * 60 + num(Some(m), 2, 59)?)
|
||||
}
|
||||
};
|
||||
Some(unix([year, month, day, hour, minute, second], offset))
|
||||
}
|
||||
|
||||
/// `[year, month, day, hour, minute, second]` at a UTC offset of `offset` minutes → Unix seconds
|
||||
/// (days-from-civil, proleptic Gregorian: the inverse of [`vectorcraft_doc::metadata::civil`]).
|
||||
fn unix([year, month, day, hour, minute, second]: [i64; 6], offset: i64) -> i64 {
|
||||
let y = if month <= 2 { year - 1 } else { year };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
let days = era * 146_097 + doe - 719_468;
|
||||
days * 86_400 + hour * 3600 + minute * 60 + second - offset * 60
|
||||
}
|
||||
@@ -31,7 +31,8 @@
|
||||
//! 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()`]).
|
||||
//! The native document a PDF carries comes back too ([`ImportReport::native`], [`editing()`]),
|
||||
//! and [`file_info()`] reads its File Info (the information dictionary and the XMP packet).
|
||||
//! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]).
|
||||
//! - [`print()`] lays a document out on paper as File → Print does ([`PrintSettings`]) and writes
|
||||
//! the job as a print-ready PDF: composite or one page per ink.
|
||||
@@ -43,6 +44,7 @@
|
||||
mod editing;
|
||||
mod encrypt;
|
||||
mod export;
|
||||
mod file_info;
|
||||
mod forms;
|
||||
mod images;
|
||||
mod import;
|
||||
@@ -70,6 +72,7 @@ mod syntax;
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report, page_areas};
|
||||
pub use file_info::{FileInfo, file_info};
|
||||
pub use import::{OFF_ARTBOARD_NOTE, PAST_PAGE_NOTE, import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, has_illustrator_data, illustrator_data, info, is_postscript};
|
||||
pub use post::{THUMBNAIL_SIZE, Thumbnail};
|
||||
@@ -249,6 +252,8 @@ mod tests_editing;
|
||||
#[cfg(test)]
|
||||
mod tests_encrypt;
|
||||
#[cfg(test)]
|
||||
mod tests_file_info;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! File Info read from a PDF: the information dictionary, the XMP packet, the packet's values
|
||||
//! where both give a field, and the dictionary's alone where the packet can't be read.
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A one-page PDF whose trailer names an information dictionary with the entries `info`, and whose
|
||||
/// catalog has a metadata stream holding `xmp`.
|
||||
fn pdf(info: Option<&str>, xmp: Option<&str>) -> Vec<u8> {
|
||||
let x = first_extra(1);
|
||||
let mut extra = vec![];
|
||||
let mut catalog = String::new();
|
||||
if let Some(p) = xmp {
|
||||
catalog = format!("/Metadata {x} 0 R ");
|
||||
extra.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{p}\nendstream", p.len()));
|
||||
}
|
||||
let info_at = x + extra.len();
|
||||
if let Some(i) = info {
|
||||
extra.push(format!("<< {i} >>"));
|
||||
}
|
||||
let objects: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
let bytes = pdf_with_catalog(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 10 10 50 50 re f")], &objects, &catalog, None);
|
||||
if info.is_none() {
|
||||
return bytes;
|
||||
}
|
||||
// The trailer follows the cross-reference table, so a longer trailer moves no object.
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
text.replacen("/Root 1 0 R", &format!("/Root 1 0 R /Info {info_at} 0 R"), 1).into_bytes()
|
||||
}
|
||||
|
||||
/// An XMP packet whose `rdf:RDF` holds `body`.
|
||||
fn packet(body: &str) -> String {
|
||||
format!(
|
||||
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">{body}</rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>"
|
||||
)
|
||||
}
|
||||
|
||||
/// An `rdf:Description` declaring the namespaces File Info is read from, with `attrs` and `body`.
|
||||
fn description(attrs: &str, body: &str) -> String {
|
||||
format!(
|
||||
"<rdf:Description rdf:about=\"\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:xmpRights=\"http://ns.adobe.com/xap/1.0/rights/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" {attrs}>{body}</rdf:Description>"
|
||||
)
|
||||
}
|
||||
|
||||
const INFO: &str = "/Author (Grace Hopper) /Subject <FEFF00430061007400610020006C006F0067> /Keywords (navy; compilers, , cobol) \
|
||||
/CreationDate (D:20261005140300+02'00') /ModDate (D:20261006090000Z) /Title (Ignored title)";
|
||||
|
||||
/// Unix seconds of 2026-10-05 12:03:00 UTC (14:03 at +02:00).
|
||||
const CREATED: i64 = 1_791_201_780;
|
||||
/// Unix seconds of 2026-10-06 09:00:00 UTC.
|
||||
const MODIFIED: i64 = 1_791_277_200;
|
||||
|
||||
#[test]
|
||||
fn the_information_dictionary_gives_file_info() {
|
||||
let f = file_info(&pdf(Some(INFO), None), None).unwrap();
|
||||
assert_eq!(
|
||||
f,
|
||||
FileInfo {
|
||||
author: Some("Grace Hopper".into()),
|
||||
description: Some("Cata log".into()),
|
||||
keywords: Some(vec!["navy".into(), "compilers".into(), "cobol".into()]),
|
||||
created: Some(CREATED),
|
||||
modified: Some(MODIFIED),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(file_info(&pdf(None, None), None), Some(FileInfo::default()));
|
||||
// Strings without a byte order mark are PDFDocEncoding, which differs from Latin-1 in places.
|
||||
let info = r"/Author (Miles O\220Brien) /Subject (Caf\351 \204 \215Menu\216\222 \030 \240) /Keywords (\223le)";
|
||||
let f = file_info(&pdf(Some(info), None), None).unwrap();
|
||||
assert_eq!(f.author.as_deref(), Some("Miles O\u{2019}Brien"));
|
||||
assert_eq!(f.description.as_deref(), Some("Caf\u{E9} \u{2014} \u{201C}Menu\u{201D}\u{2122} \u{2D8} \u{20AC}"));
|
||||
assert_eq!(f.keywords, Some(vec!["\u{FB01}le".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xmp_packet_gives_file_info() {
|
||||
let body = description(
|
||||
"xmpRights:Marked=\"False\" xmp:ModifyDate=\"2026-10-06T09:00:00.25Z\"",
|
||||
"<dc:creator><rdf:Seq><rdf:li>Ada Lovelace</rdf:li><rdf:li>Charles Babbage</rdf:li></rdf:Seq></dc:creator>\
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang=\"de\">Ein Plakat</rdf:li><rdf:li xml:lang=\"x-default\">A poster\nfor the fair</rdf:li></rdf:Alt></dc:description>\
|
||||
<dc:subject><rdf:Bag><rdf:li>fair</rdf:li><rdf:li> print </rdf:li><rdf:li></rdf:li></rdf:Bag></dc:subject>\
|
||||
<dc:rights><rdf:Alt><rdf:li xml:lang=\"en\">(c) 2026 Ada</rdf:li></rdf:Alt></dc:rights>\
|
||||
<xmpRights:WebStatement>https://example.com/rights</xmpRights:WebStatement>\
|
||||
<xmp:CreateDate>2026-10-05T14:03+02:00</xmp:CreateDate>",
|
||||
);
|
||||
let f = file_info(&pdf(None, Some(&packet(&body))), None).unwrap();
|
||||
assert_eq!(
|
||||
f,
|
||||
FileInfo {
|
||||
author: Some("Ada Lovelace, Charles Babbage".into()),
|
||||
description: Some("A poster\nfor the fair".into()),
|
||||
keywords: Some(vec!["fair".into(), "print".into()]),
|
||||
copyright_notice: Some("(c) 2026 Ada".into()),
|
||||
copyright_url: Some("https://example.com/rights".into()),
|
||||
copyrighted: Some(false),
|
||||
created: Some(CREATED),
|
||||
modified: Some(MODIFIED),
|
||||
}
|
||||
);
|
||||
// Marked True is a copyrighted work; a URL can be written as an RDF resource; a date alone is
|
||||
// midnight UTC.
|
||||
let body = description(
|
||||
"xmpRights:Marked=\"True\"",
|
||||
"<xmpRights:WebStatement rdf:resource=\"https://example.com/terms\"/><xmp:CreateDate>2026-10-05</xmp:CreateDate>",
|
||||
);
|
||||
let f = file_info(&pdf(None, Some(&packet(&body))), None).unwrap();
|
||||
assert_eq!((f.copyrighted, f.created), (Some(true), Some(1_791_158_400)));
|
||||
assert_eq!(f.copyright_url.as_deref(), Some("https://example.com/terms"));
|
||||
// Qualified values give their `rdf:value` alone, without the qualifiers' text.
|
||||
let body = description(
|
||||
"xmlns:q=\"urn:example:qualifiers\"",
|
||||
"<dc:creator><rdf:Seq><rdf:li rdf:parseType=\"Resource\"><rdf:value>Ada Lovelace</rdf:value><q:role>Author</q:role></rdf:li>\
|
||||
<rdf:li><rdf:Description rdf:value=\"Grace Hopper\" q:role=\"Editor\"/></rdf:li></rdf:Seq></dc:creator>\
|
||||
<xmp:CreateDate><rdf:Description><rdf:value>2026-10-05</rdf:value><q:source>scan</q:source></rdf:Description></xmp:CreateDate>",
|
||||
);
|
||||
let f = file_info(&pdf(None, Some(&packet(&body))), None).unwrap();
|
||||
assert_eq!((f.author.as_deref(), f.created), (Some("Ada Lovelace, Grace Hopper"), Some(1_791_158_400)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_xmp_packet_wins_where_both_give_a_field() {
|
||||
// The packet gives the author, keywords (as `pdf:Keywords`) and the created date; the
|
||||
// dictionary still gives the description (the packet's is blank, and a description inside a
|
||||
// value is another resource's) and the modified date.
|
||||
let body = description(
|
||||
"",
|
||||
"<dc:creator><rdf:Seq><rdf:li>Ada Lovelace</rdf:li></rdf:Seq></dc:creator><pdf:Keywords>engines, notes</pdf:Keywords>\
|
||||
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\"> </rdf:li></rdf:Alt></dc:description>\
|
||||
<h:History xmlns:h=\"urn:example:history\"><rdf:Seq><rdf:li><rdf:Description><dc:description>Nested</dc:description></rdf:Description></rdf:li></rdf:Seq></h:History>\
|
||||
<xmp:CreateDate>2026-10-07T00:00:00-05:30</xmp:CreateDate>",
|
||||
);
|
||||
let f = file_info(&pdf(Some(INFO), Some(&packet(&body))), None).unwrap();
|
||||
assert_eq!(f.author.as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(f.keywords, Some(vec!["engines".to_string(), "notes".into()]));
|
||||
assert_eq!(f.created, Some(1_791_331_200 + 5 * 3600 + 30 * 60));
|
||||
assert_eq!(f.description.as_deref(), Some("Cata log"));
|
||||
assert_eq!(f.modified, Some(MODIFIED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_xmp_packet_leaves_the_information_dictionary() {
|
||||
let creator = "<dc:creator><rdf:Seq><rdf:li>Ada Lovelace</rdf:li></rdf:Seq></dc:creator>";
|
||||
let bad = [
|
||||
// Not well-formed.
|
||||
packet(&description("", creator)).replace("</rdf:RDF>", ""),
|
||||
// A document type declaration (roxmltree refuses a DTD by default).
|
||||
format!("<!DOCTYPE x [<!ENTITY a \"Ada\">]>{}", packet(&description("", creator))),
|
||||
// Not XML.
|
||||
"Ada Lovelace".to_string(),
|
||||
// Dates that aren't dates.
|
||||
packet(&description("xmp:CreateDate=\"2026-13-01\" xmp:ModifyDate=\"2026-10-05T24:00Z\"", "")),
|
||||
// Days their month doesn't have.
|
||||
packet(&description("xmp:CreateDate=\"2026-02-29\" xmp:ModifyDate=\"2026-04-31T10:00Z\"", "")),
|
||||
];
|
||||
for xmp in bad {
|
||||
let f = file_info(&pdf(Some(INFO), Some(&xmp)), None).unwrap();
|
||||
assert_eq!(f.author.as_deref(), Some("Grace Hopper"), "{xmp}");
|
||||
assert_eq!((f.created, f.modified), (Some(CREATED), Some(MODIFIED)), "{xmp}");
|
||||
}
|
||||
// The dictionary's dates are checked the same way: 2026 has no February 29, 2024 has one.
|
||||
let f = file_info(&pdf(Some("/CreationDate (D:20260229) /ModDate (D:20240229)"), None), None).unwrap();
|
||||
assert_eq!((f.created, f.modified), (None, Some(1_709_164_800)));
|
||||
// A packet in UTF-16 reads.
|
||||
let utf16: Vec<u8> = packet(&description("", creator)).encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let mut bytes = pdf(None, Some(&"#".repeat(utf16.len())));
|
||||
let at = bytes.windows(utf16.len()).position(|w| w.iter().all(|b| *b == b'#')).unwrap();
|
||||
bytes.splice(at..at + utf16.len(), utf16);
|
||||
assert_eq!(file_info(&bytes, None).unwrap().author.as_deref(), Some("Ada Lovelace"));
|
||||
// A file that can't be read gives none.
|
||||
assert_eq!(file_info(b"%PDF-1.7\nnot a PDF", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_pdf_gives_its_file_info_back() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.metadata.author = "Ada Lovelace".into();
|
||||
d.metadata.description = "A poster\nfor the fair".into();
|
||||
d.metadata.keywords = vec!["fair".into(), "print".into()];
|
||||
let bytes = export(&d, &PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }).unwrap();
|
||||
let f = file_info(&bytes, None).unwrap();
|
||||
assert_eq!(f.author.as_deref(), Some("Ada Lovelace"));
|
||||
assert_eq!(f.description.as_deref(), Some("A poster\nfor the fair"));
|
||||
assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()]));
|
||||
assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED)));
|
||||
}
|
||||
@@ -78,11 +78,11 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests |
|
||||
|---|---|---|---|---|
|
||||
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible only | see above; no real-file corpus |
|
||||
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text placement issues in some files (#722); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505; Type 42 (TrueType) fonts set type at its size (#1553); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| PostScript `.ps` | open | no | via Print to PostScript | |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598) |
|
||||
|
||||
+9
-1
@@ -2224,7 +2224,15 @@ ISO 8601 UTC. Pass any of the editable keys to change them in one undo step (`nu
|
||||
list or a comma-separated string and keeps each word once); bad values are refused. PDF export writes the title,
|
||||
author, description and keywords to the document info and XMP, PNG export writes them (with the copyright and
|
||||
creation date) as text chunks, and SVG export writes the description as `<desc>` and, with the `metadata` option,
|
||||
all of it as Dublin Core. `file.new {created}` and a save to a file (`document.save` or `file.saveAs` with
|
||||
all of it as Dublin Core. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`,
|
||||
`file.newFromTemplate`, `file.revert`; not one restored from the document it carries) opens with the `author`,
|
||||
`description`, `keywords`, `copyrightStatus`, `copyrightNotice`, `copyrightUrl`, `created` and `modified` that its XMP
|
||||
packet (`dc:creator`, `dc:description`, `dc:subject` or `pdf:Keywords`, `xmpRights:Marked`, `dc:rights`,
|
||||
`xmpRights:WebStatement`, `xmp:CreateDate`, `xmp:ModifyDate`) and its document information dictionary (`Author`,
|
||||
`Subject`, `Keywords`, `CreationDate`, `ModDate`) give, the XMP packet's value where both give one. A value that
|
||||
`file.info` would refuse is left out, and the title is set as for any other open (the file name, or a new untitled
|
||||
name for a template).
|
||||
`file.new {created}` and a save to a file (`document.save` or `file.saveAs` with
|
||||
`{path, modified}`) use the given date (Unix seconds, or `null` for none) instead of now. Their journal entries always
|
||||
record the date they used, so replaying the journal makes the same document whenever it runs (an action recorded in
|
||||
the Actions panel leaves the date out: playing it later dates the document then). As a step of `command.batch`, a
|
||||
|
||||
@@ -582,6 +582,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Affinity documents in (AF.1–AF.4): `.af` (Affinity 3) and `.afdesign`/`.afphoto`/`.afpub` (Affinity 1 and 2, container versions 8–12) open and place as editable art: layers and sublayers, groups (pass-through or isolated), artboards (with their names and backgrounds) and Publisher pages side by side, curves with live corners, parametric shapes (rectangles with corner radii, ellipses, polygons, stars, pies, triangles, trapezoids), compound shapes, solid/linear/elliptical gradient fills and strokes (weight, dashes, alignment, scale-with-object), clipping by nesting, vector and pixel masks, artistic and frame text with fonts, sizes, tracking, leading and alignment, placed images (their original JPEG/PNG) and pixel layers (8/16-bit RGB, CMYK), opacity, visibility, locking and blend modes, at the document's resolution in points. Effects, adjustments, brushes and pressure profiles, transparency gradients, master pages, symbol instances (imported as their cached picture), special shapes (cloud, heart, cog…, imported as ellipses) and Affinity-only blend modes are named in the import warning. A file whose native data can't be read opens as its embedded preview with the reason, and that preview can't be placed. Save never writes back over the Affinity file and there is no Affinity export. AF.4 adds Affinity 3 artboard recognition and actual-outline clipping, source-backed JPEG/PNG raster tiles, Unicode text-run recovery, stored fallback/exact installed fonts, OpenType feature flags and compound-geometry limits. 51 public CC0/MIT/Apache-2.0 files (33 current `.af`), pinned and sha256-verified (`cargo xtask corpus --affinity`), test native retention and every-board native/SVG/PDF/PSD output for current files; selected files compare their saved thumbnails. No Affinity reopen or independent paired PSD oracle was available; [source audit and complex-font gaps](affinity-validation.md); [scope, provenance and limits](../crates/affinity/README.md).
|
||||
- Placed documents: File › Place of a `.vectorcraft` file links to it by default (`file.place`'s `link`): one locked object showing an artboard of the document (or its art's bounds), read the way placing it without Link reads it, its symbols, patterns and images kept apart from the document's; without Link (or from bytes, not a file) it is an editable copy as before. It stays vectors in every output (SVG, PDF, EPS, EMF, DXF), and so do placed documents inside it, read from their own files up to 8 levels deep (documents that place each other stop there). On screen they draw from bitmaps cached per file and zoom step, shared by every copy and made in the background (a placeholder until ready), so a sheet of them redraws in about a millisecond; exports and prints draw them exactly. Like linked images, a save keeps only a preview of each (JPEG when opaque) unless Include Linked Files; output, and zooming past the preview, read the file again, falling back to the preview with a warning when it is gone or changed. Saves for older versions write them as plain art. Links check, update, relink, Update Links on open, Link Info (the artboard's size), Placement Options, Break Link (also Embed, Object › Expand: the editable copy placing without Link gives, its resources joining the document), Edit Original (the document in a new tab) and the Links panel cover them; saving a document updates the open documents that place it. The native format (v4) keeps data of 8 KB or more after the JSON instead of base64 in it.
|
||||
- Desktop print transport uses `craft-print` for CUPS and modern Windows. Sheet composition, copies, separations and colour management remain in VectorCraft; Windows 7 retains WMI and the installed PDF viewer. The print-preview and physical-device fidelity gaps remain, so print scores and hours are unchanged.
|
||||
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`).
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
|
||||
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
|
||||
|
||||
Reference in New Issue
Block a user