PDF export: PDF and .ai files keep the document's creation date

The engine built the PDF options without a creation date, so the
writer dated every PDF and PDF-compatible .ai file with the time it
was written. A document created on 2020-01-01 (File › File Info
shows the date) exported as a PDF with today's date in /CreationDate
and xmp:CreateDate. SVG export with the metadata option already
writes the document's own date.

The options now carry the document's File Info created date and the
time of writing as the modification date. A document without a
creation date is dated with the time of writing, as before, so PDF/X
files still get their required date. The PDF writer writes one date
for both, so the export then sets /ModDate, xmp:ModifyDate and the
PDF/A history dates to the time of writing; /CreationDate and
xmp:CreateDate keep the document's date. The page information mark
and the embedded editing data's modification date keep the time of
writing.

Docs: docs/mcp.md (File Info), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-18 12:36:27 +07:00
committed by bflatastic
parent 24d4777d1a
commit aa9cf83fd2
10 changed files with 206 additions and 8 deletions
+6 -2
View File
@@ -235,11 +235,15 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
super::eps::with_flattener(s, cmd, expanded)
}
/// The full export options for `doc`: settings plus the artboards `p` picks (default all).
/// The full export options for `doc`: settings plus the artboards `p` picks (default all), created
/// at `doc`'s File Info creation date (the time of writing when it has none) and modified at the
/// 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> {
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))?;
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() })
let now = vectorcraft_doc::metadata::now_unix();
Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() })
}
/// Why a PDF of some artboards carries no editing data.
+72
View File
@@ -141,6 +141,78 @@ fn an_exported_pdf_opens_with_its_file_info() {
assert_eq!(back["title"], "poster.pdf", "the title stays the file name");
}
/// Unix seconds of 2020-01-01 00:00:00 UTC.
const CREATED: i64 = 1_577_836_800;
/// A session whose document was created at `created` (Unix seconds, or `null` for none).
fn created_at(created: Value) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 80, "created": created})).unwrap();
s
}
/// The PDF date the writer gives Unix seconds `t` (UTC).
fn pdf_date(t: i64) -> String {
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t);
format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)")
}
/// Check that a PDF or PDF-compatible `.ai` was created at a time in `created` and modified at a
/// time in `modified`: in the XMP packet (`vectorcraft_pdf::file_info`) and in the document
/// information dictionary.
fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive<i64>, modified: std::ops::RangeInclusive<i64>) {
let xmp = vectorcraft_pdf::file_info(bytes, None).expect("a PDF");
assert!(xmp.created.is_some_and(|t| created.contains(&t)), "xmp:CreateDate {:?} in {created:?}", xmp.created);
assert!(xmp.modified.is_some_and(|t| modified.contains(&t)), "xmp:ModifyDate {:?} in {modified:?}", xmp.modified);
let text = String::from_utf8_lossy(bytes);
let trailer = &text[text.rfind("trailer").expect("a trailer")..];
let n = trailer.split("/Info ").nth(1).and_then(|r| r.split(' ').next()).expect("an /Info entry");
let info = &text[text.find(&format!("\n{n} 0 obj")).expect("the document information")..];
let info = &info[..info.find("endobj").expect("its end")];
assert!(created.clone().any(|t| info.contains(&format!("/CreationDate{}", pdf_date(t)))), "/CreationDate in {created:?}: {info}");
assert!(modified.clone().any(|t| info.contains(&format!("/ModDate{}", pdf_date(t)))), "/ModDate in {modified:?}: {info}");
}
/// Run `f` → (its result, the time of writing: the Unix seconds before and after it).
fn written<T>(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive<i64>) {
let before = vectorcraft_doc::metadata::now_unix().unwrap();
let out = f();
(out, before..=vectorcraft_doc::metadata::now_unix().unwrap())
}
#[test]
fn an_exported_pdf_is_created_with_the_document_and_modified_when_it_is_written() {
let mut s = created_at(json!(CREATED));
for p in [json!({}), json!({"preset": "PDF/X-4:2010"})] {
let (pdf, now) = written(|| b64(&s.execute("document.exportPdf", &p).unwrap()));
assert_dated(&pdf, CREATED..=CREATED, now);
}
let (pdf, now) = written(|| b64(&s.execute("document.export", &json!({"format": "pdf", "preserveEditing": false})).unwrap()));
assert_dated(&pdf, CREATED..=CREATED, now);
}
#[test]
fn a_pdf_compatible_ai_is_created_with_the_document_and_modified_when_it_is_written() {
let mut s = created_at(json!(CREATED));
let path = std::env::temp_dir().join(format!("vc-fileinfo-created-{}.ai", std::process::id()));
let (saved, now) = written(|| s.execute("document.save", &json!({"path": path.to_string_lossy()})));
saved.unwrap();
let ai = std::fs::read(&path).unwrap();
let _ = std::fs::remove_file(&path);
assert_dated(&ai, CREATED..=CREATED, now);
assert_eq!(s.doc().unwrap().doc.metadata.created, Some(CREATED), "the save keeps the creation date");
}
#[test]
fn a_pdf_of_a_document_without_a_creation_date_is_dated_when_it_is_written() {
let mut s = created_at(Value::Null);
assert_eq!(s.doc().unwrap().doc.metadata.created, None);
let (pdf, now) = written(|| b64(&s.execute("document.exportPdf", &json!({})).unwrap()));
assert_dated(&pdf, now.clone(), now);
let xmp = vectorcraft_pdf::file_info(&pdf, None).unwrap();
assert_eq!(xmp.created, xmp.modified, "created and modified at the same time of writing");
}
#[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};
+10 -3
View File
@@ -69,14 +69,16 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
let pages = pages(doc, opts)?;
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
// The time of writing (none on the web) dates the editing data and the page information.
let now = vectorcraft_doc::metadata::now_unix();
let created_at = opts.created.or(now);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
let mut warnings = set.warnings();
let native = opts.native.as_deref().filter(|_| set.preserve_editing);
if let Some(native) = native {
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, created_at.map(date_time)));
w.pdf.document_mut().embed_file(crate::editing::embedded_file(native, set.compression.compress_text, now.map(date_time)));
} else if set.preserve_editing {
warnings.push("Preserve editing needs the native document, which wasn't given: the PDF reopens as plain artwork".into());
}
@@ -103,7 +105,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
vectorcraft_doc::cancel::progress(written as u64 + 1, total, &format!("Writing page {} of {total}", written + 1));
let (m, r) = (boxes.media, boxes.bleed);
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, created_at) } else { String::new() };
let info = if marks.page_info { crate::marks::page_info(doc, &title, i, now) } else { String::new() };
let art = crate::marks::art(doc, &marks, &boxes, bleed, &info);
// The page is the media box, y down from its top-left corner (like ours), in units of
// `unit` points.
@@ -132,6 +134,11 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
w.absorb(ex);
let (bytes, more) = w.finish(left_out)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
// The writer gives the file its creation date as its modification date too.
let bytes = match opts.modified.filter(|m| Some(*m) != created_at) {
Some(modified) => crate::post::modification_date(bytes, modified)?,
None => bytes,
};
warnings.extend(more);
let bytes = crate::post::user_units(bytes, &units)?;
let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?;
+2
View File
@@ -95,6 +95,8 @@ pub struct PdfOptions {
pub title: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
/// Modification date as Unix seconds (UTC); `None` = the creation date.
pub modified: Option<i64>,
/// The native document (`.vectorcraft` bytes) Preserve Editing embeds.
pub native: Option<Vec<u8>>,
/// The pages drawn small, in page order: what Embed Page Thumbnails embeds (the writer
+46 -2
View File
@@ -2,8 +2,9 @@
//! (General › Embed page thumbnails), the seal of the editing data, fast web view (a linearised
//! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes
//! through between its two steps so that it stays linearised. Also here: what makes a file say it
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, and the pages'
//! user units ([`user_units`]), which the export writes before the thumbnails.
//! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's
//! modification date ([`modification_date`]) and the pages' user units ([`user_units`]), which the
//! export writes in that order before the thumbnails.
use std::borrow::Cow;
@@ -77,6 +78,49 @@ pub(crate) fn user_units(pdf: Vec<u8>, units: &[f64]) -> Result<Vec<u8>, PdfErro
patch.apply(&pdf, &xref)
}
/// `pdf` dated as modified at `modified` (Unix seconds, UTC), its creation dates kept: the
/// document information's `/ModDate` (a PDF date, ISO 32000-1 §7.9.4) and every `xmp:ModifyDate`,
/// `xmp:MetadataDate` and `stEvt:when` of the catalog's XMP metadata (ISO 8601 dates), in UTC as
/// the PDF writer writes the creation date. The writer gives `/ModDate`, `xmp:ModifyDate` and
/// `stEvt:when` the creation date.
pub(crate) fn modification_date(pdf: Vec<u8>, modified: i64) -> Result<Vec<u8>, PdfError> {
let failed = |why: &str| PdfError::Write(format!("can't write the file's modification date: {why}"));
let Parsed { xref, objects, trailer, root, .. } = linearize::parse(&pdf).map_err(failed)?;
let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(modified);
let mut patch = Patch::new(&xref);
if let Some(&Obj::Ref(n, _)) = trailer.get(b"Info")
&& let Some(Obj::Str { start, end, .. }) = objects.get(&n).and_then(|o| o.value.get(b"ModDate"))
{
patch.replace(*start, *end, format!("(D:{y:04}{mo:02}{d:02}{h:02}{mi:02}{s:02}Z)").into_bytes());
}
let metadata = match objects.get(&root).and_then(|c| c.value.get(b"Metadata")) {
Some(&Obj::Ref(n, _)) => objects.get(&n),
_ => None,
};
if let Some(o) = metadata
&& let Some(&Obj::Int { value, start, end }) = o.value.get(b"Length")
{
let date = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}+00:00");
let xmp = pdf.get(..o.endobj).unwrap_or_default();
let mut grown = 0;
for tag in ["xmp:ModifyDate", "xmp:MetadataDate", "stEvt:when"] {
let (open, close) = (format!("<{tag}>"), format!("</{tag}>"));
let mut from = o.value_span.1;
while let Some(at) = crate::lab_spot::find(xmp, open.as_bytes(), from).map(|i| i + open.len())
&& let Some(to) = crate::lab_spot::find(xmp, close.as_bytes(), at)
{
grown += date.len() as i64 - (to - at) as i64;
patch.replace(at, to, date.clone().into_bytes());
from = to + close.len();
}
}
if grown != 0 {
patch.replace(start, end, (value + grown).to_string().into_bytes());
}
}
patch.apply(&pdf, &xref)
}
/// Linearise `pdf`, encrypting it on the way as `set` says.
fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> {
let (prepared, plan) = linearize::prepare(pdf)?;
+24
View File
@@ -825,6 +825,30 @@ fn creation_date_written() {
assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456"));
}
#[test]
fn a_later_modification_date_is_written_beside_the_creation_date() {
let d = doc(100.0, 100.0);
// 2020-01-01 00:00:00 and 2026-10-17 14:03:00 UTC.
let (created, modified) = (1_577_836_800, 1_792_245_780);
for standard in [Standard::None, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4, Standard::PdfA2b] {
let mut o = PdfOptions { created: Some(created), modified: Some(modified), ..PdfOptions::uncompressed() };
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
let bytes = export(&d, &o).unwrap();
let t = String::from_utf8_lossy(&bytes);
assert!(t.contains("/CreationDate(D:20200101000000Z)") && t.contains("/ModDate(D:20261017140300Z)"), "{standard:?}: document information");
let info = file_info(&bytes, None).unwrap();
assert_eq!((info.created, info.modified), (Some(created), Some(modified)), "{standard:?}: XMP");
// PDF/A files record when they were saved and converted: at the time of writing too.
assert_eq!(t.contains("<stEvt:when>2026-10-17T14:03:00"), standard == Standard::PdfA2b, "{standard:?}");
assert!(!t.contains("<stEvt:when>2020"), "{standard:?}");
assert_eq!(import(&bytes).unwrap().artboards.len(), 1, "{standard:?}: the file reads back");
}
// Without a modification date, the file is modified when it was created.
let bytes = export(&d, &PdfOptions { created: Some(created), ..PdfOptions::uncompressed() }).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("/ModDate(D:20200101000000Z)"));
assert_eq!(file_info(&bytes, None).unwrap().modified, Some(created));
}
#[test]
fn opacity_mask_exports_as_luminosity_soft_mask() {
let mut d = doc(100.0, 100.0);
+11
View File
@@ -49,6 +49,17 @@ fn the_native_document_comes_back_intact() {
}
}
#[test]
fn the_native_documents_modification_date_is_the_time_of_writing() {
// Created in 1970: the information dictionary's dates say so, the embedded file's doesn't.
let year = |t: Option<i64>| vectorcraft_doc::metadata::civil(t.unwrap())[0];
let before = year(vectorcraft_doc::metadata::now_unix());
let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned();
let after = year(vectorcraft_doc::metadata::now_unix());
assert!(text.contains("/CreationDate(D:1970"), "the file's creation date");
assert!([before, after].iter().any(|y| text.contains(&format!("/ModDate(D:{y:04}"))), "the embedded file is dated when it is written");
}
#[test]
fn changed_pages_are_noticed() {
let r = pdf_with(Some(b"native"), false);
+31
View File
@@ -211,3 +211,34 @@ fn page_information_names_the_file_artboard_and_date() {
let band_end = top + 100.0 + 6.0 + PrinterMarks::LENGTH;
assert!(glyphs.iter().all(|g| g.y0 > band_end && g.x0 >= top - 0.5), "{glyphs:?}");
}
#[test]
fn page_information_prints_the_time_of_the_export_not_the_creation_date() {
let d = doc();
// The outlines of the page information of a PDF of `d` created at `created`.
let info = |created: i64| {
let opts = PdfOptions { settings: settings(json!({"marks": {"pageInfo": true}})), created: Some(created), ..PdfOptions::uncompressed() };
let back = import(&export_with_report(&d, &opts).unwrap().bytes).unwrap();
let mut out = vec![];
back.walk(|n| {
if n.appearance.fill().is_some_and(|f| f.paint.is_registration())
&& let Some(p) = n.path_data()
{
out.push(p.clone());
}
});
out
};
let now = || vectorcraft_doc::metadata::now_unix().unwrap();
// Both print the minute they are written in: try again when the minute turns between them.
for _ in 0..3 {
let minute = now() / 60;
let (old, new) = (info(1_577_836_800), info(now()));
if now() / 60 == minute {
assert!(!old.is_empty());
assert!(old == new, "a file created in 2020 prints the same date as one created now");
return;
}
}
panic!("the minute turned during each of three tries");
}
+3 -1
View File
@@ -2599,7 +2599,9 @@ 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. A PDF, `.ai` or `.ait` opened from its pages or its editing copy (`document.open`,
all of it as Dublin Core. PDF export and PDF-compatible `.ai` and `.ait` saves write the `created` date (the time of
writing when the document has none) as the file's creation date and the time of writing as its modification date.
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`,
+1
View File
@@ -722,6 +722,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place with Bounding Box (`file.place {crop: "bounding"}`) measures the art that shows, with its strokes and effects, as an export does: hidden objects, guides and template layers do not count, a group whose only part on the page or artboard is hidden is not placed, and symbol instances are measured by their art, for `at`, `rect`, the place cursor and `file.place.info` (`crates/engine/src/tests_place.rs`).
- 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`).
- PDF export and PDF-compatible `.ai` and `.ait` saves write the document's File Info created date as the file's creation date, or the time of writing when the document has none, and the time of writing as its modification date; the page information mark still prints the time of writing (`crates/engine/src/tests_fileinfo.rs`, `crates/pdf/src/tests.rs`, `crates/pdf/src/tests_marks.rs`).
- Opening a PDF or PDF-compatible `.ai` whose XMP packet nests elements more than 256 deep no longer overflows the stack: File Info leaves the packet out and takes the document information dictionary's values (`crates/pdf/src/tests_file_info.rs`).
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
- 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).