From 1c80b23745cd4291773db976e230b24713d49fd2 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 18 Oct 2026 21:50:36 +0500 Subject: [PATCH] Exports: SOURCE_DATE_EPOCH stands in for the clock, so output repeats PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files record the wall-clock time they were made in, so the same commands run twice gave different bytes although the pixels matched (#2880): PDF /CreationDate and /ModDate D:20261018155816 vs D:20261018155817 PNG the `Creation Time` tEXt entry .vectorcraft one byte of the stored date SVG carries no time, and already repeated `metadata::output_time` is the time a file records: `SOURCE_DATE_EPOCH` when it holds a non-negative whole number of seconds, else the clock, as the reproducible-builds specification defines it. A date a command is given (`file.new {created}`, a save's `modified`) still wins over the variable, and a blank, negative or non-numeric value is ignored so that an export is never refused over the environment. `metadata::now_unix` keeps the real clock and now has exactly two callers, both of which must not be pinnable from the environment: the recovery heartbeat, and the seed PDF encryption draws on. --- ROADMAP.md | 1 + crates/doc/src/metadata.rs | 36 +++++++++++++++++++++++ crates/engine/src/cmd/fileio/eps.rs | 2 +- crates/engine/src/cmd/fileio/legacy_ai.rs | 2 +- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/engine/src/cmd/fileio/screens.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 +- crates/engine/src/cmd/package.rs | 2 +- crates/engine/src/tests_fileinfo.rs | 4 +-- crates/engine/src/tests_nativefile.rs | 17 +++++++++++ crates/pdf/src/export.rs | 2 +- crates/pdf/src/print/mod.rs | 2 +- crates/pdf/src/tests_editing.rs | 4 +-- crates/pdf/src/tests_marks.rs | 2 +- crates/ui-egui/src/tests_sysclip.rs | 4 +-- docs/mcp.md | 19 ++++++++++++ 16 files changed, 88 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4b32b3b8d..579a79148 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock (#2880). Headline numbers unchanged. | | 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). | | 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. | | 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from the out port (#2713, part of #2572), a panel per swatch library with Persistent (#2714, #2603), installed plug-ins kept across restarts (#2695, #2541). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). | diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 2d3519fc2..f723b1ccd 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -82,6 +82,25 @@ impl DocMetadata { } } +/// The time exported files record: PDF `CreationDate` and `ModDate`, the PNG `Creation Time` +/// entry, a zip entry's date, and a saved document's `created` and `modified`. It is the clock, +/// unless `SOURCE_DATE_EPOCH` is set, which replaces it so that the same commands produce the same +/// bytes twice (#2880). +/// +/// The raw clock stays in [`now_unix`], which is what a heartbeat is judged by and what seeds +/// encryption: neither may be pinned by the environment. +pub fn output_time() -> Option { + stamp_from_env(|n| std::env::var(n).ok()).or_else(now_unix) +} + +/// `SOURCE_DATE_EPOCH` as Unix seconds: the reproducible-builds variable +/// (), a non-negative decimal integer of +/// seconds since the epoch. `None` when it is unset, blank, negative or not an integer, and then +/// the clock decides — an export is never refused over the environment. +fn stamp_from_env(get: impl Fn(&str) -> Option) -> Option { + get("SOURCE_DATE_EPOCH")?.trim().parse::().ok().filter(|t| *t >= 0) +} + /// The current time as Unix seconds (UTC); `None` where the platform has no clock (the web build). #[cfg(not(target_arch = "wasm32"))] pub fn now_unix() -> Option { @@ -148,4 +167,21 @@ mod tests { ); assert!(DocMetadata::default().png_text("").is_empty()); } + + /// `SOURCE_DATE_EPOCH` replaces the clock in exported output only when it is a non-negative + /// decimal integer; anything else leaves the clock to decide, so an export is never refused. + #[test] + fn source_date_epoch_is_read_only_as_a_non_negative_integer() { + let env = |v: Option<&'static str>| move |n: &str| v.filter(|_| n == "SOURCE_DATE_EPOCH").map(str::to_owned); + assert_eq!(stamp_from_env(env(Some("1791208980"))), Some(1_791_208_980)); + assert_eq!(stamp_from_env(env(Some("0"))), Some(0)); + assert_eq!(stamp_from_env(env(Some(" 1791208980 "))), Some(1_791_208_980)); + for bad in ["", " ", "-1", "later", "1791208980.5", "1e9", "99999999999999999999"] { + assert_eq!(stamp_from_env(env(Some(bad))), None, "{bad:?}"); + } + assert_eq!(stamp_from_env(env(None)), None, "unset"); + // Another variable never stands in for it. + assert_eq!(stamp_from_env(|_| Some("1791208980".to_owned())), Some(1_791_208_980)); + assert_eq!(stamp_from_env(|n: &str| (n == "TZ").then(|| "1791208980".to_owned())), None); + } } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 023a1a050..5aa3fecad 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -278,7 +278,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { if transparent && flat.is_some() { enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into()); } - let created = vectorcraft_doc::metadata::now_unix(); + let created = vectorcraft_doc::metadata::output_time(); let mut renderer = vectorcraft_render::Renderer::new(); for (board, region, origin) in pages { let rect = vectorcraft_eps::preview_rect(region, origin); diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index f69e06454..9fb7e06db 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -110,7 +110,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result if boards.len() > 1 { enc.warnings.push(SEPARATE.to_string()); } - let created = vectorcraft_doc::metadata::now_unix(); + let created = vectorcraft_doc::metadata::output_time(); let failed = vectorcraft_render::decode_failures(); for b in boards { let one = super::save::artboard_doc(doc, b); diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 7c3eec364..c1ecbb2e6 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -242,7 +242,7 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result Result { 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))?; - let now = vectorcraft_doc::metadata::now_unix(); + let now = vectorcraft_doc::metadata::output_time(); Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() }) } diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 288ac51ec..0da52a9a4 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -387,7 +387,7 @@ impl<'a> Naming<'a> { let prefix = file_part(o.prefix.as_deref().unwrap_or("")); let when = match o.date.as_deref() { Some(d) => naming::parse_date(d).ok_or_else(|| bad(C, format!("date `{d}`: YYYY-MM-DD")))?, - None => vectorcraft_doc::metadata::now_unix().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil), + None => vectorcraft_doc::metadata::output_time().map_or([1970, 1, 1, 0, 0, 0], vectorcraft_doc::metadata::civil), }; let per_file = match o.counter_per.as_deref() { None | Some("item") => false, diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f39c47b34..6acc37220 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -500,7 +500,7 @@ pub(crate) fn date_param(p: &Value, key: &str, cmd: &str) -> Result>) -> Option { - let t = given.unwrap_or_else(vectorcraft_doc::metadata::now_unix); + let t = given.unwrap_or_else(vectorcraft_doc::metadata::output_time); s.note_journal(key, serde_json::json!(t)); t } diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index ecbba8e45..20efbf361 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -400,7 +400,7 @@ fn check_no_overwrite(root: &Path, entries: &[Entry], sources: &[&str]) -> Resul /// The MS-DOS time and date a zip entry records: now (UTC), else 1980-01-01. fn dos_time() -> (u16, u16) { - let Some(t) = vectorcraft_doc::metadata::now_unix() else { return (0, 0x21) }; + let Some(t) = vectorcraft_doc::metadata::output_time() else { return (0, 0x21) }; let [y, mo, d, h, mi, s] = vectorcraft_doc::metadata::civil(t); let date = (((y - 1980).clamp(0, 127) << 9) | (mo << 5) | d) as u16; (((h << 11) | (mi << 5) | (s / 2)) as u16, date) diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests_fileinfo.rs index 4c6c46b94..7b6e63485 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests_fileinfo.rs @@ -175,9 +175,9 @@ fn assert_dated(bytes: &[u8], created: std::ops::RangeInclusive, modified: /// Run `f` → (its result, the time of writing: the Unix seconds before and after it). fn written(f: impl FnOnce() -> T) -> (T, std::ops::RangeInclusive) { - let before = vectorcraft_doc::metadata::now_unix().unwrap(); + let before = vectorcraft_doc::metadata::output_time().unwrap(); let out = f(); - (out, before..=vectorcraft_doc::metadata::now_unix().unwrap()) + (out, before..=vectorcraft_doc::metadata::output_time().unwrap()) } #[test] diff --git a/crates/engine/src/tests_nativefile.rs b/crates/engine/src/tests_nativefile.rs index 4561423c1..a9d694c88 100644 --- a/crates/engine/src/tests_nativefile.rs +++ b/crates/engine/src/tests_nativefile.rs @@ -160,3 +160,20 @@ fn newer_keys_and_legacy_profiles_survive_open_and_save() { let v2 = json_of(&saved(&mut s, json!({"version": 2}))); assert_eq!(v2["document"]["unknown"]["colorProfiles"]["rgb"], "sRGB IEC61966-2.1"); } + +/// A save records the date the document was made in, so the same commands twice gave different +/// bytes (#2880). A pinned date makes them identical — `file.new {created}` here, and +/// `SOURCE_DATE_EPOCH` for an export that takes no date param. +#[test] +fn a_pinned_date_makes_saves_byte_identical() { + let once = |t: i64| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 200, "created": t})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + saved(&mut s, json!({})) + }; + let at = 1_791_208_980; + assert_eq!(once(at), once(at), "the same document and date give the same bytes"); + // The date really is in the bytes, so the comparison above is not vacuous. + assert_ne!(once(at), once(at + 1), "another date shows in the bytes"); +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 2722acfa8..910ba4426 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -70,7 +70,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone())); // The time of writing (none on the web) dates the editing data and the page information. - let now = vectorcraft_doc::metadata::now_unix(); + let now = vectorcraft_doc::metadata::output_time(); 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)?; diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index d6844dd33..38e31b42a 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -355,7 +355,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result, let set = &opts.settings; let job = Job::new(doc, set, opts.postscript)?; let title = opts.title.clone().unwrap_or_else(|| job.doc.title.clone()); - let created = opts.created.or_else(vectorcraft_doc::metadata::now_unix); + let created = opts.created.or_else(vectorcraft_doc::metadata::output_time); let sep = Separator::new(&job.doc, set); let plates: Vec = (0..job.plates.len()).filter_map(|p| job.ink(Some(p))).map(|i| sep.plate(&i.name)).collect(); let marks = set.marks.printer_marks(); diff --git a/crates/pdf/src/tests_editing.rs b/crates/pdf/src/tests_editing.rs index 512d4594b..69dbbc538 100644 --- a/crates/pdf/src/tests_editing.rs +++ b/crates/pdf/src/tests_editing.rs @@ -53,9 +53,9 @@ fn the_native_document_comes_back_intact() { 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| vectorcraft_doc::metadata::civil(t.unwrap())[0]; - let before = year(vectorcraft_doc::metadata::now_unix()); + let before = year(vectorcraft_doc::metadata::output_time()); let text = String::from_utf8_lossy(&pdf_with(Some(b"native"), false).bytes).into_owned(); - let after = year(vectorcraft_doc::metadata::now_unix()); + let after = year(vectorcraft_doc::metadata::output_time()); 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"); } diff --git a/crates/pdf/src/tests_marks.rs b/crates/pdf/src/tests_marks.rs index 439d06fc6..0c682b8d5 100644 --- a/crates/pdf/src/tests_marks.rs +++ b/crates/pdf/src/tests_marks.rs @@ -229,7 +229,7 @@ fn page_information_prints_the_time_of_the_export_not_the_creation_date() { }); out }; - let now = || vectorcraft_doc::metadata::now_unix().unwrap(); + let now = || vectorcraft_doc::metadata::output_time().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; diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs index 272338683..5b58ec260 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -398,13 +398,13 @@ fn the_worker_makes_the_formats_the_session_makes() { // to another second since the Copy, they are compared again after another Copy. let check = |app: &mut VectorcraftApp, what: &str, mimes: &[&str]| { for _ in 0..100 { - let second = vectorcraft_doc::metadata::now_unix(); + let second = vectorcraft_doc::metadata::output_time(); run(app, "edit.copy", json!({})); assert_eq!(app.background.jobs.len(), 1, "{what}: made on the worker"); background::wait_all(app); assert_eq!(written(&board), mimes, "{what}"); let session = app.session.clipboard_flavours(); - let dated = mimes.contains(&PDF) && vectorcraft_doc::metadata::now_unix() != second; + let dated = mimes.contains(&PDF) && vectorcraft_doc::metadata::output_time() != second; if board.borrow().flavours == session || !dated { assert_eq!(board.borrow().flavours, session, "{what}"); return; diff --git a/docs/mcp.md b/docs/mcp.md index 48bc08c22..a74597c53 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -821,6 +821,25 @@ Not remembered: Color Settings, the document, anything else. Nothing is read or desktop app starts, the presets it kept in its preferences file before this folder existed are moved into it; after that the folder is the one place they live, so a preset deleted by `vectorcraft-cli` is gone in the app too. +### Reproducible output + +PNG, PDF, EPS, legacy `.ai`, packaged zips and saved `.vectorcraft` files record the time they were +made in (PDF `CreationDate` and `ModDate`, the PNG `Creation Time` entry, a zip entry's date, a +document's `created` and `modified`), so the same commands run twice gave different bytes though the +pixels matched (#2880). Setting **`SOURCE_DATE_EPOCH`** to a non-negative whole number of seconds +since the epoch records that instant instead of the clock, and the bytes repeat +(): + +```sh +SOURCE_DATE_EPOCH=1791208980 vectorcraft-cli run --cmd file.new --params '{}' --export out.pdf +``` + +A command that takes its own date (`file.new {created}`, a save's `modified`) still wins over the +variable. A blank, negative or non-numeric value is ignored and the clock decides, so an export is +never refused over the environment. The variable does not reach the recovery heartbeat or the +random seed PDF encryption draws on, which must follow the real clock. SVG output carries no time +and was already reproducible. + ### Parameter keys `command_run`, each step of `command_batch`, `run --cmd … --params` and the control channel's `engine.execute` check