@nowline/export-xlsx 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +66 -0
- package/dist/duration.d.ts +4 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +55 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +299 -0
- package/dist/index.js.map +1 -0
- package/dist/zip-normalize.d.ts +10 -0
- package/dist/zip-normalize.d.ts.map +1 -0
- package/dist/zip-normalize.js +128 -0
- package/dist/zip-normalize.js.map +1 -0
- package/package.json +37 -0
- package/src/duration.ts +49 -0
- package/src/index.ts +384 -0
- package/src/zip-normalize.ts +149 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ZIP timestamp normalizer.
|
|
2
|
+
//
|
|
3
|
+
// XLSX is a ZIP archive. ExcelJS uses JSZip internally, and JSZip's default
|
|
4
|
+
// per-entry mtime is `new Date()` — which means two consecutive calls to
|
|
5
|
+
// `wb.xlsx.writeBuffer()` produce different bytes whenever the wall clock
|
|
6
|
+
// advances between them. The workbook's own `created`/`modified` properties
|
|
7
|
+
// are pinned in `index.ts`, but the per-entry ZIP headers are not.
|
|
8
|
+
//
|
|
9
|
+
// This module overwrites the `last mod time` / `last mod date` fields in
|
|
10
|
+
// every Local File Header and Central Directory File Header with a
|
|
11
|
+
// deterministic value derived from a fixed Date. CRC32s, compressed sizes,
|
|
12
|
+
// and the compressed payloads themselves are not part of those fields, so
|
|
13
|
+
// patching is safe and does not require re-deflating any data.
|
|
14
|
+
//
|
|
15
|
+
// References:
|
|
16
|
+
// - APPNOTE.TXT (ZIP spec) §4.3.7, §4.4.6, §4.5
|
|
17
|
+
// - DOS date/time encoding: APPNOTE.TXT §4.4.6
|
|
18
|
+
|
|
19
|
+
const SIG_LFH = 0x04034b50; // "PK\x03\x04"
|
|
20
|
+
const SIG_CDFH = 0x02014b50; // "PK\x01\x02"
|
|
21
|
+
const SIG_EOCD = 0x06054b50; // "PK\x05\x06"
|
|
22
|
+
|
|
23
|
+
const EXTRA_EXTENDED_TIMESTAMP = 0x5455; // "UT" — Info-ZIP extended timestamp
|
|
24
|
+
const EXTRA_NTFS = 0x000a; // NTFS file times
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Overwrite every per-entry timestamp inside a ZIP buffer with a
|
|
28
|
+
* deterministic value derived from `date`. Mutates `bytes` in place and
|
|
29
|
+
* returns it for chaining.
|
|
30
|
+
*
|
|
31
|
+
* Throws if the buffer is not a well-formed single-disk ZIP (no EOCD found,
|
|
32
|
+
* or the central-directory walk hits a bad signature).
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeZipTimestamps(bytes: Uint8Array, date: Date): Uint8Array {
|
|
35
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
36
|
+
const dosTime = toDosTime(date);
|
|
37
|
+
const dosDate = toDosDate(date);
|
|
38
|
+
const unixSeconds = Math.floor(date.getTime() / 1000);
|
|
39
|
+
|
|
40
|
+
const eocdOffset = findEocd(view);
|
|
41
|
+
const cdSize = view.getUint32(eocdOffset + 12, true);
|
|
42
|
+
const cdOffset = view.getUint32(eocdOffset + 16, true);
|
|
43
|
+
const cdEnd = cdOffset + cdSize;
|
|
44
|
+
|
|
45
|
+
let p = cdOffset;
|
|
46
|
+
while (p < cdEnd) {
|
|
47
|
+
if (view.getUint32(p, true) !== SIG_CDFH) {
|
|
48
|
+
throw new Error(`normalizeZipTimestamps: bad CDFH signature at offset ${p}`);
|
|
49
|
+
}
|
|
50
|
+
const nameLen = view.getUint16(p + 28, true);
|
|
51
|
+
const extraLen = view.getUint16(p + 30, true);
|
|
52
|
+
const commentLen = view.getUint16(p + 32, true);
|
|
53
|
+
const lfhOffset = view.getUint32(p + 42, true);
|
|
54
|
+
|
|
55
|
+
view.setUint16(p + 12, dosTime, true);
|
|
56
|
+
view.setUint16(p + 14, dosDate, true);
|
|
57
|
+
patchExtraTimestamps(view, p + 46 + nameLen, extraLen, unixSeconds);
|
|
58
|
+
|
|
59
|
+
if (view.getUint32(lfhOffset, true) !== SIG_LFH) {
|
|
60
|
+
throw new Error(`normalizeZipTimestamps: bad LFH signature at offset ${lfhOffset}`);
|
|
61
|
+
}
|
|
62
|
+
const lfhNameLen = view.getUint16(lfhOffset + 26, true);
|
|
63
|
+
const lfhExtraLen = view.getUint16(lfhOffset + 28, true);
|
|
64
|
+
|
|
65
|
+
view.setUint16(lfhOffset + 10, dosTime, true);
|
|
66
|
+
view.setUint16(lfhOffset + 12, dosDate, true);
|
|
67
|
+
patchExtraTimestamps(view, lfhOffset + 30 + lfhNameLen, lfhExtraLen, unixSeconds);
|
|
68
|
+
|
|
69
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return bytes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function patchExtraTimestamps(
|
|
76
|
+
view: DataView,
|
|
77
|
+
start: number,
|
|
78
|
+
length: number,
|
|
79
|
+
unixSeconds: number,
|
|
80
|
+
): void {
|
|
81
|
+
let p = start;
|
|
82
|
+
const end = start + length;
|
|
83
|
+
while (p + 4 <= end) {
|
|
84
|
+
const id = view.getUint16(p, true);
|
|
85
|
+
const size = view.getUint16(p + 2, true);
|
|
86
|
+
const dataStart = p + 4;
|
|
87
|
+
const dataEnd = dataStart + size;
|
|
88
|
+
if (dataEnd > end) return;
|
|
89
|
+
|
|
90
|
+
if (id === EXTRA_EXTENDED_TIMESTAMP && size >= 5) {
|
|
91
|
+
// Layout: flags(1) | mtime(4) | [atime(4)] | [ctime(4)]
|
|
92
|
+
const flags = view.getUint8(dataStart);
|
|
93
|
+
let off = dataStart + 1;
|
|
94
|
+
if (off + 4 <= dataEnd) {
|
|
95
|
+
view.setUint32(off, unixSeconds, true);
|
|
96
|
+
off += 4;
|
|
97
|
+
}
|
|
98
|
+
if ((flags & 0x02) !== 0 && off + 4 <= dataEnd) {
|
|
99
|
+
view.setUint32(off, unixSeconds, true);
|
|
100
|
+
off += 4;
|
|
101
|
+
}
|
|
102
|
+
if ((flags & 0x04) !== 0 && off + 4 <= dataEnd) {
|
|
103
|
+
view.setUint32(off, unixSeconds, true);
|
|
104
|
+
}
|
|
105
|
+
} else if (id === EXTRA_NTFS && size >= 32) {
|
|
106
|
+
// Layout: reserved(4) | tag1=0x0001 | size1=24 | mtime(8) | atime(8) | ctime(8)
|
|
107
|
+
// Times are Win32 FILETIME (100-ns intervals since 1601-01-01).
|
|
108
|
+
const fileTime = unixSecondsToFiletime(unixSeconds);
|
|
109
|
+
const attrStart = dataStart + 4 + 4; // skip reserved + tag/size
|
|
110
|
+
view.setBigUint64(attrStart + 0, fileTime, true);
|
|
111
|
+
view.setBigUint64(attrStart + 8, fileTime, true);
|
|
112
|
+
view.setBigUint64(attrStart + 16, fileTime, true);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
p = dataEnd;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findEocd(view: DataView): number {
|
|
120
|
+
const len = view.byteLength;
|
|
121
|
+
const minSize = 22;
|
|
122
|
+
const maxComment = 0xffff;
|
|
123
|
+
const searchStart = Math.max(0, len - minSize - maxComment);
|
|
124
|
+
for (let i = len - minSize; i >= searchStart; i--) {
|
|
125
|
+
if (view.getUint32(i, true) === SIG_EOCD) return i;
|
|
126
|
+
}
|
|
127
|
+
throw new Error('normalizeZipTimestamps: end-of-central-directory record not found');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toDosTime(d: Date): number {
|
|
131
|
+
return (
|
|
132
|
+
((d.getUTCHours() & 0x1f) << 11) |
|
|
133
|
+
((d.getUTCMinutes() & 0x3f) << 5) |
|
|
134
|
+
(Math.floor(d.getUTCSeconds() / 2) & 0x1f)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function toDosDate(d: Date): number {
|
|
139
|
+
// DOS year is offset from 1980; clamp to that floor so any pre-1980 Date
|
|
140
|
+
// produces a valid (zero) field rather than overflowing.
|
|
141
|
+
const year = Math.max(0, d.getUTCFullYear() - 1980);
|
|
142
|
+
return ((year & 0x7f) << 9) | (((d.getUTCMonth() + 1) & 0xf) << 5) | (d.getUTCDate() & 0x1f);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const FILETIME_EPOCH_OFFSET = 11644473600n; // seconds between 1601-01-01 and 1970-01-01
|
|
146
|
+
|
|
147
|
+
function unixSecondsToFiletime(unixSeconds: number): bigint {
|
|
148
|
+
return (BigInt(unixSeconds) + FILETIME_EPOCH_OFFSET) * 10000000n;
|
|
149
|
+
}
|