diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..68e286450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -431,6 +431,7 @@ Newest first. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. - **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-19** · A PDF export can repeat its bytes: it recorded the second it was made in, so exporting one document twice gave different files though the pages matched. `SOURCE_DATE_EPOCH` stands in for the clock when the export is given no date, as the reproducible-builds specification defines it; a date passed in still wins, and a malformed value is ignored rather than refusing the export. The timestamp was the only thing that differed, so the files are now identical byte for byte. No readiness percentage change. - **2026-10-17** · PDF/X-4 export warns about fonts drawing visible text without an embedded font program, including placed PDF forms; `validate-pdf` reports the font and page (#909). Invisible OCR text is exempt. Detection only: fonts are not embedded or outlined automatically. diff --git a/crates/pdf/src/export/metadata.rs b/crates/pdf/src/export/metadata.rs index 619a0f5a5..b5993453f 100644 --- a/crates/pdf/src/export/metadata.rs +++ b/crates/pdf/src/export/metadata.rs @@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option { None } +/// The date an exported file records, when the caller gives none: `SOURCE_DATE_EPOCH` if it holds +/// a non-negative whole number of seconds, else the clock. Pinning it makes an export repeat its +/// bytes, which is otherwise impossible -- the only thing that differs between two exports of one +/// document is the timestamp, and the instance id krilla derives from the bytes follows it. +/// +/// +pub(super) fn export_time() -> Option { + pinned_or(|name| std::env::var(name).ok(), now_unix) +} + +/// [`export_time`] with the environment and the clock given, so a test can show which wins. +fn pinned_or(get: impl Fn(&str) -> Option, clock: impl Fn() -> Option) -> Option { + let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::().ok()).filter(|secs| PDF_DATES.contains(secs)); + pinned.or_else(clock) +} + +/// The dates a PDF can hold, as Unix seconds: `date_time` clamps the year to 0..=9999, so a value +/// outside this would be recorded as some other date rather than the one asked for. A value that +/// cannot be honoured is left to the clock, as a malformed one is. +const PDF_DATES: std::ops::RangeInclusive = 0..=253_402_300_799; + /// The document information from File Info (title, author, description, keywords). `title` and /// `author` from the export options replace File Info's. pub(super) fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) -> Metadata { @@ -49,3 +70,42 @@ pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) { let (y, t) = designcraft_doc::time::utc_parts(t, 0); (y, t.month() as u8, t.day() as u8, t.hour() as u8, t.minute() as u8, t.second() as u8) } + +#[cfg(test)] +mod export_time_tests { + use super::pinned_or; + + /// What an export records when the caller gives no date: `SOURCE_DATE_EPOCH` when it holds a + /// non-negative whole number of seconds, the clock whenever it does not. The environment and + /// the clock are given here rather than set, so no test changes this process's environment -- + /// that is `unsafe` under edition 2024 and would reach every test beside it. + #[test] + fn a_pinned_epoch_is_preferred_and_the_clock_answers_otherwise() { + let env = |v: Option<&'static str>| move |_: &str| v.map(str::to_owned); + let clock = || Some(1_000_000); + assert_eq!(pinned_or(env(Some("1791208980")), clock), Some(1_791_208_980), "the variable wins"); + assert_eq!(pinned_or(env(Some("0")), clock), Some(0), "the epoch itself counts"); + assert_eq!(pinned_or(env(Some(" 1791208980 ")), clock), Some(1_791_208_980), "spaces around it are allowed"); + // A date a PDF cannot hold is left to the clock too: `date_time` would clamp the year and + // record some other date. 253402300799 is 9999-12-31T23:59:59Z, the last one it can. + assert_eq!(pinned_or(env(Some("253402300799")), clock), Some(253_402_300_799), "the last date a PDF can hold"); + for ignored in [ + None, + Some(""), + Some(" "), + Some("-1"), + Some("later"), + Some("1791208980.5"), + Some("99999999999999999999"), + Some("253402300800"), + Some("9223372036854775807"), + ] { + assert_eq!(pinned_or(env(ignored), clock), Some(1_000_000), "{ignored:?} leaves it to the clock"); + } + // A platform with no clock and no variable records no date at all. + assert_eq!(pinned_or(env(None), || None), None); + assert_eq!(pinned_or(env(Some("1791208980")), || None), Some(1_791_208_980), "the variable needs no clock"); + // Only that variable is read. + assert_eq!(pinned_or(|name: &str| (name == "TZ").then(|| "1791208980".to_owned()), clock), Some(1_000_000)); + } +} diff --git a/crates/pdf/src/export/pipeline.rs b/crates/pdf/src/export/pipeline.rs index ca9d1a988..ffef45c87 100644 --- a/crates/pdf/src/export/pipeline.rs +++ b/crates/pdf/src/export/pipeline.rs @@ -1,6 +1,6 @@ use super::common::{tf, to_path}; use super::images::ImageContentCache; -use super::metadata::{date_time, file_info_metadata, now_unix}; +use super::metadata::{date_time, export_time, file_info_metadata}; use super::*; /// Export `doc` as PDF bytes. @@ -162,7 +162,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions }; let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..base }; let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); - let created = opts.created.or_else(now_unix); + let created = opts.created.or_else(export_time); let metadata = Metadata { created: created.map(date_time), ..file_info_metadata(doc, opts.title.as_deref(), opts.author.as_deref()) }; let mut writer = Writer::new(settings, metadata); let pdf = writer.document_mut(); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7d6682c6d..987e7ea97 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -136,7 +136,8 @@ pub struct PdfOptions { /// Title for the metadata; `None` = the document title. pub title: Option, pub author: Option, - /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). + /// Creation date as Unix seconds (UTC). `None` takes `SOURCE_DATE_EPOCH` when it names a date + /// a PDF can hold, else the clock (native) / omitted (wasm). pub created: Option, /// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt /// text, parent-page items and printer's marks artifacts.