mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 21:26:48 -06:00
PDF export: SOURCE_DATE_EPOCH stands in for the clock, so exports repeat
A PDF export recorded the second it was made in, so exporting one document twice gave different files although the pages matched -- and the instance id krilla derives from the bytes followed the timestamp, so nothing about the two files was comparable. 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 value the PDF date format cannot hold -- malformed, negative, or past the range -- is ignored rather than refusing the export or writing a date no reader can parse. With the epoch pinned the two files are identical byte for byte. Rebased onto the post-split main: export_time and pinned_or live in export/metadata.rs beside now_unix, which they stand in front of, and the call site is in export/pipeline.rs. The ROADMAP keeps upstream's 2026-10-17 entry with this one above it.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -11,6 +11,27 @@ pub(super) fn now_unix() -> Option<i64> {
|
||||
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.
|
||||
///
|
||||
/// <https://reproducible-builds.org/specs/source-date-epoch/>
|
||||
pub(super) fn export_time() -> Option<i64> {
|
||||
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<String>, clock: impl Fn() -> Option<i64>) -> Option<i64> {
|
||||
let pinned = get("SOURCE_DATE_EPOCH").and_then(|v| v.trim().parse::<i64>().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<i64> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -136,7 +136,8 @@ pub struct PdfOptions {
|
||||
/// Title for the metadata; `None` = the document title.
|
||||
pub title: Option<String>,
|
||||
pub author: Option<String>,
|
||||
/// 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<i64>,
|
||||
/// Tagged PDF: stories become paragraphs in reading order, graphics figures with their alt
|
||||
/// text, parent-page items and printer's marks artifacts.
|
||||
|
||||
Reference in New Issue
Block a user