From d09833d9755392a3f646392a69622c0608fc32c9 Mon Sep 17 00:00:00 2001 From: baseballyama Date: Fri, 9 Oct 2026 13:32:44 +0900 Subject: [PATCH] fix: the same document always saves to the same bytes fflate stamps each ZIP entry with the current time when none is given, so two saves of an unchanged document differed whenever they straddled a two-second boundary of the DOS timestamp. That made idempotent-resave.test.ts fail now and then (CI on main after #32). Every entry now carries a fixed date. Co-Authored-By: Claude Opus 5.5 --- .changeset/deterministic-zip.md | 5 +++++ src/api/idempotent-resave.test.ts | 17 ++++++++++++++++- src/internal/opc/zip.ts | 17 +++++++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 .changeset/deterministic-zip.md diff --git a/.changeset/deterministic-zip.md b/.changeset/deterministic-zip.md new file mode 100644 index 0000000..896dc20 --- /dev/null +++ b/.changeset/deterministic-zip.md @@ -0,0 +1,5 @@ +--- +"@office-kit/docx": patch +--- + +`toUint8Array` now produces the same bytes for the same document whenever it is called. The ZIP entries used to carry the time of saving, so two saves of an unchanged document could differ; every entry is now dated 1980-01-01 00:00 (the earliest ZIP date). Word and other readers ignore these dates. diff --git a/src/api/idempotent-resave.test.ts b/src/api/idempotent-resave.test.ts index 4103359..0b649c5 100644 --- a/src/api/idempotent-resave.test.ts +++ b/src/api/idempotent-resave.test.ts @@ -8,7 +8,7 @@ // save+open cycles. If they differ, the library is non-deterministic // or re-flushes side parts unnecessarily, and we'd want to know. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { addBookmark, addBulletList, @@ -98,4 +98,19 @@ describe("idempotent re-save", () => { const bytesC = reSaveCycle(bytesB); expect(bytesC).toEqual(bytesB); }); + + describe("saving at another time", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("produces the same bytes (no save time in the archive)", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 0, 1, 12, 0, 0)); + const doc = createDocx({ paragraphs: ["body"] }); + const first = toUint8Array(doc); + vi.setSystemTime(new Date(2026, 5, 30, 23, 59, 59)); + expect(toUint8Array(doc)).toEqual(first); + }); + }); }); diff --git a/src/internal/opc/zip.ts b/src/internal/opc/zip.ts index 991c707..9a4bce4 100644 --- a/src/internal/opc/zip.ts +++ b/src/internal/opc/zip.ts @@ -30,9 +30,22 @@ export interface WriteZipEntry { * fflate's terms); `"deflate"` uses level 6. */ export function writeZip(entries: readonly WriteZipEntry[]): Uint8Array { - const input: Record = {}; + const input: Record = {}; + // Built per call: fflate encodes the local-time fields, and a Date made from + // them reads back the same in every time zone. + const mtime = new Date(ZIP_EPOCH_YEAR, 0, 1); for (const entry of entries) { - input[entry.name] = [entry.data, { level: entry.compression === "store" ? 0 : 6 }]; + input[entry.name] = [entry.data, { level: entry.compression === "store" ? 0 : 6, mtime }]; } return zipSync(input); } + +interface ZipEntryOptions { + level: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + mtime: Date; +} + +// Every entry carries this timestamp (the earliest a ZIP's DOS date can hold) +// rather than the time of saving, which fflate uses by default: then the same +// document always serializes to the same bytes. Word ignores entry times. +const ZIP_EPOCH_YEAR = 1980;