diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index 5231132421..c4ad98efa0 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -296,7 +296,7 @@ pub(crate) fn info(d: &Document) -> Value { // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the // ones the spec names, set and cleared with doc_set_info. "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), - "creator": i.creator, "producer": i.producer, + "creator": i.creator, "producer": i.producer, "created": i.created, "modified": i.modified, "tagged": i.tagged, "has_javascript": i.has_javascript, // Where the document opens (/OpenAction, named destinations resolved): 1-based page and diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 728b9e3615..f7d7265684 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -763,8 +763,9 @@ pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render:: } /// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none, dates shown as +/// [`pdfcraft_render::pretty_date`] shows them. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 8] { let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); [ (&mut info.title, read("Title")), @@ -773,6 +774,8 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen (&mut info.keywords, read("Keywords")), (&mut info.creator, read("Creator")), (&mut info.producer, read("Producer")), + (&mut info.created, read("CreationDate").map(|d| pdfcraft_render::pretty_date(&d))), + (&mut info.modified, read("ModDate").map(|d| pdfcraft_render::pretty_date(&d))), ] } diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..1c329da080 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -53,6 +53,10 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// `/Info /CreationDate` and `/ModDate` for display ("2026-10-01 12:30", see [`pretty_date`]); + /// a value that isn't a PDF date is shown as written. + pub created: Option, + pub modified: Option, /// Custom document properties: `/Info` entries with a key the spec does not name and a text /// value, as (name, text), in the dictionary's order (at most 256). pub custom: Vec<(String, String)>, diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c5df792f23..46e3406391 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -188,6 +188,14 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + // Dates are text strings only (ISO 32000-2 §7.9.4), as the engine reads them after an + // edit (`pdfcraft_organize::info`): a name value is no date. + let date = |key: &[u8]| match self.resolve(d.get(key).ok()?) { + Object::String(..) => self.text(d, key).map(|s| pretty_date(&s)), + _ => None, + }; + info.created = date(b"CreationDate"); + info.modified = date(b"ModDate"); const STANDARD: [&[u8]; 9] = [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index c103276cb9..f67d78366d 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -729,6 +729,31 @@ const CUSTOM_INFO: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R /Info 4 0 R >> %%EOF"; +/// One page whose `/Info` is `info` (the dictionary's contents). +fn with_info(info: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << {info} >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF" + ) + .into_bytes() +} + +#[test] +fn creation_and_modification_dates_are_read_for_document_properties() { + let info = inspect(Arc::new(with_info("/CreationDate (D:20240105093000+01'00') /ModDate (D:20261031174500Z)")), None).expect("opens"); + assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("2024-01-05 09:30"), Some("2026-10-31 17:45"))); + // Not a PDF date: shown as written. Empty, a number, a name and absent: no date. + let info = inspect(Arc::new(with_info("/CreationDate (last Tuesday) /ModDate ()")), None).expect("opens"); + assert_eq!((info.created.as_deref(), info.modified.as_deref()), (Some("last Tuesday"), None)); + let info = inspect(Arc::new(with_info("/CreationDate 20240105 /ModDate /D:20240105093000Z")), None).expect("opens"); + assert_eq!((info.created, info.modified), (None, None)); +} + #[test] fn custom_document_properties_are_listed_apart_from_the_standard_ones() { let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b88a567140..93dcffe35c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -550,6 +550,8 @@ fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending } row(ui, "Application", i.creator.clone().unwrap_or_default()); row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); + row(ui, "Created", i.created.clone().unwrap_or_default()); + row(ui, "Modified", i.modified.clone().unwrap_or_default()); } PropsTab::InitialView => { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 265765a7a6..9285f4bac9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3538,8 +3538,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] -notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card", "crates/render/src/inspect/tests.rs::creation_and_modification_dates_are_read_for_document_properties"] +notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Description and doc_info show the /Info CreationDate and ModDate as written (YYYY-MM-DD HH:MM, not converted to local time; #5534). Missing: the dates from XMP (xmp:CreateDate, xmp:ModifyDate) when /Info has none, editing them, a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata"