mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
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:
@@ -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)?;
|
||||
|
||||
@@ -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
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user