Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/deterministic-zip.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 16 additions & 1 deletion src/api/idempotent-resave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
});
17 changes: 15 additions & 2 deletions src/internal/opc/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,22 @@ export interface WriteZipEntry {
* fflate's terms); `"deflate"` uses level 6.
*/
export function writeZip(entries: readonly WriteZipEntry[]): Uint8Array {
const input: Record<string, [Uint8Array, { level: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 }]> = {};
const input: Record<string, [Uint8Array, ZipEntryOptions]> = {};
// 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;
Loading