@bendyline/squisq-formats 1.4.2 → 2.0.1
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/README.md +48 -2
- package/dist/chunk-2KPARF2P.js +99 -0
- package/dist/chunk-2KPARF2P.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
- package/dist/chunk-ABVI556T.js.map +1 -0
- package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
- package/dist/chunk-CRAVSMPZ.js.map +1 -0
- package/dist/chunk-FE6OJV6O.js +560 -0
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-QRVN6A6E.js +127 -0
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/chunk-U32AG3G3.js +195 -0
- package/dist/chunk-U32AG3G3.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
- package/dist/chunk-VJJM2SSH.js.map +1 -0
- package/dist/chunk-VSYHZECT.js +778 -0
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/chunk-XKUMNGBW.js +131 -0
- package/dist/chunk-XKUMNGBW.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/container/index.d.ts +13 -3
- package/dist/container/index.js +23 -20
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-D2NkylDT.d.ts +69 -0
- package/dist/extract-H6RXJMHP.js +15 -0
- package/dist/extract-H6RXJMHP.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-K8mfc0fz.d.ts +136 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -12
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-BHrgZ5FS.d.ts +150 -0
- package/dist/layouts-QVPK3ZCU.js +12 -0
- package/dist/layouts-QVPK3ZCU.js.map +1 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-B9L8Ucbj.d.ts +140 -0
- package/dist/registry/index.d.ts +60 -8
- package/dist/registry/index.js +9 -4
- package/dist/themeReader-DJKErl_j.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +310 -1
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +76 -2
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +37 -32
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +23 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +199 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +129 -29
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1222 -0
- package/src/registry/convert.ts +168 -53
- package/src/registry/defaultFormats.ts +135 -45
- package/src/registry/index.ts +6 -1
- package/src/registry/types.ts +69 -6
- package/src/shared/boundedZipArchive.ts +383 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +151 -0
- package/src/shared/zipLimits.ts +296 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import JSZip from 'jszip';
|
|
3
|
+
import { getPartXml, openPackage } from '../ooxml/reader';
|
|
4
|
+
import type { OoxmlPackage } from '../ooxml/types';
|
|
5
|
+
import { openBoundedZipArchive, validateZipArchive, ZipSafetyError } from '../shared/zipSafety';
|
|
6
|
+
import { declaredZipEntryCount } from '../shared/zipEntryCount';
|
|
7
|
+
|
|
8
|
+
interface InstrumentedStream {
|
|
9
|
+
pause(): InstrumentedStream;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface StreamableEntry {
|
|
13
|
+
internalStream(type: 'uint8array'): InstrumentedStream;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function makeZip(
|
|
17
|
+
files: Record<string, string | Uint8Array>,
|
|
18
|
+
compression: 'STORE' | 'DEFLATE' = 'DEFLATE',
|
|
19
|
+
): Promise<Uint8Array> {
|
|
20
|
+
const zip = new JSZip();
|
|
21
|
+
for (const [path, data] of Object.entries(files)) zip.file(path, data);
|
|
22
|
+
return zip.generateAsync({ type: 'uint8array', compression });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function patchMemberMetadata(
|
|
26
|
+
source: Uint8Array,
|
|
27
|
+
path: string,
|
|
28
|
+
patch: { uncompressedSize?: number; crc32?: number },
|
|
29
|
+
): Uint8Array {
|
|
30
|
+
const bytes = source.slice();
|
|
31
|
+
const view = new DataView(bytes.buffer);
|
|
32
|
+
let patchedCentral = false;
|
|
33
|
+
|
|
34
|
+
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
35
|
+
const signature = view.getUint32(offset, true);
|
|
36
|
+
let nameOffset: number;
|
|
37
|
+
let nameLengthOffset: number;
|
|
38
|
+
let uncompressedSizeOffset: number;
|
|
39
|
+
let crcOffset: number;
|
|
40
|
+
if (signature === 0x02014b50) {
|
|
41
|
+
nameOffset = offset + 46;
|
|
42
|
+
nameLengthOffset = offset + 28;
|
|
43
|
+
uncompressedSizeOffset = offset + 24;
|
|
44
|
+
crcOffset = offset + 16;
|
|
45
|
+
} else if (signature === 0x04034b50) {
|
|
46
|
+
nameOffset = offset + 30;
|
|
47
|
+
nameLengthOffset = offset + 26;
|
|
48
|
+
uncompressedSizeOffset = offset + 22;
|
|
49
|
+
crcOffset = offset + 14;
|
|
50
|
+
} else {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
55
|
+
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
56
|
+
if (name !== path) continue;
|
|
57
|
+
if (patch.uncompressedSize !== undefined) {
|
|
58
|
+
view.setUint32(uncompressedSizeOffset, patch.uncompressedSize, true);
|
|
59
|
+
}
|
|
60
|
+
if (patch.crc32 !== undefined) view.setUint32(crcOffset, patch.crc32, true);
|
|
61
|
+
if (signature === 0x02014b50) patchedCentral = true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
expect(patchedCentral).toBe(true);
|
|
65
|
+
return bytes;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renameZipMember(source: Uint8Array, from: string, to: string): Uint8Array {
|
|
69
|
+
expect(to.length).toBe(from.length);
|
|
70
|
+
const bytes = source.slice();
|
|
71
|
+
const view = new DataView(bytes.buffer);
|
|
72
|
+
const replacement = new TextEncoder().encode(to);
|
|
73
|
+
let renamedCentral = false;
|
|
74
|
+
for (let offset = 0; offset <= bytes.byteLength - 4; offset++) {
|
|
75
|
+
const signature = view.getUint32(offset, true);
|
|
76
|
+
const isCentral = signature === 0x02014b50;
|
|
77
|
+
const isLocal = signature === 0x04034b50;
|
|
78
|
+
if (!isCentral && !isLocal) continue;
|
|
79
|
+
const nameLengthOffset = offset + (isCentral ? 28 : 26);
|
|
80
|
+
const nameOffset = offset + (isCentral ? 46 : 30);
|
|
81
|
+
const nameLength = view.getUint16(nameLengthOffset, true);
|
|
82
|
+
const name = new TextDecoder().decode(bytes.subarray(nameOffset, nameOffset + nameLength));
|
|
83
|
+
if (name !== from) continue;
|
|
84
|
+
bytes.set(replacement, nameOffset);
|
|
85
|
+
if (isCentral) renamedCentral = true;
|
|
86
|
+
}
|
|
87
|
+
expect(renamedCentral).toBe(true);
|
|
88
|
+
return bytes;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('bounded JSZip reads', () => {
|
|
92
|
+
it('preflights duplicate central records before JSZip collapses their names', async () => {
|
|
93
|
+
const valid = await makeZip({ 'one.txt': '1', 'two.txt': '2' }, 'STORE');
|
|
94
|
+
const duplicate = renameZipMember(valid, 'two.txt', 'one.txt');
|
|
95
|
+
const parsed = await JSZip.loadAsync(duplicate);
|
|
96
|
+
expect(Object.values(parsed.files).filter((entry) => !entry.dir)).toHaveLength(1);
|
|
97
|
+
|
|
98
|
+
const blob = new Blob([duplicate.slice().buffer]);
|
|
99
|
+
await expect(openBoundedZipArchive(blob, { maxEntries: 1 })).rejects.toMatchObject({
|
|
100
|
+
code: 'too-many-entries',
|
|
101
|
+
limit: 1,
|
|
102
|
+
actual: 2,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('counts directory-only central records toward the entry limit', async () => {
|
|
107
|
+
const zip = new JSZip();
|
|
108
|
+
zip.folder('one');
|
|
109
|
+
zip.folder('two');
|
|
110
|
+
const data = await zip.generateAsync({ type: 'uint8array' });
|
|
111
|
+
await expect(openBoundedZipArchive(data, { maxEntries: 1 })).rejects.toMatchObject({
|
|
112
|
+
code: 'too-many-entries',
|
|
113
|
+
actual: 2,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('finds the EOCD record with a comment containing signature-like bytes', async () => {
|
|
118
|
+
const zip = new JSZip();
|
|
119
|
+
zip.file('one.txt', '1');
|
|
120
|
+
zip.file('two.txt', '2');
|
|
121
|
+
const data = await zip.generateAsync({
|
|
122
|
+
type: 'uint8array',
|
|
123
|
+
comment: 'looks like PK\u0005\u0006 but is only a comment',
|
|
124
|
+
});
|
|
125
|
+
expect(declaredZipEntryCount(data)).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reads ZIP64 record counts and falls back when ZIP64 metadata is ambiguous', () => {
|
|
129
|
+
const zip64 = new Uint8Array(98);
|
|
130
|
+
const view = new DataView(zip64.buffer);
|
|
131
|
+
view.setUint32(0, 0x06064b50, true);
|
|
132
|
+
view.setUint32(32, 3, true);
|
|
133
|
+
view.setUint32(56, 0x07064b50, true);
|
|
134
|
+
view.setUint32(64, 0, true);
|
|
135
|
+
view.setUint32(76, 0x06054b50, true);
|
|
136
|
+
view.setUint16(86, 0xffff, true);
|
|
137
|
+
expect(declaredZipEntryCount(zip64)).toBe(3);
|
|
138
|
+
|
|
139
|
+
zip64.fill(0, 56, 76);
|
|
140
|
+
expect(declaredZipEntryCount(zip64)).toBeUndefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('maps Blob read failures to a structured invalid-archive error', async () => {
|
|
144
|
+
const unreadable = {
|
|
145
|
+
size: 10,
|
|
146
|
+
slice: () => unreadable,
|
|
147
|
+
arrayBuffer: () => Promise.reject(new Error('read failed')),
|
|
148
|
+
} as unknown as Blob;
|
|
149
|
+
await expect(openBoundedZipArchive(unreadable)).rejects.toMatchObject({
|
|
150
|
+
code: 'invalid-archive',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('returns structured errors while preserving established messages', async () => {
|
|
155
|
+
const data = await makeZip({ 'large.txt': '12345' }, 'STORE');
|
|
156
|
+
let error: unknown;
|
|
157
|
+
try {
|
|
158
|
+
await openBoundedZipArchive(data, { maxEntryUncompressedBytes: 4 });
|
|
159
|
+
} catch (caught: unknown) {
|
|
160
|
+
error = caught;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(error).toBeInstanceOf(ZipSafetyError);
|
|
164
|
+
expect(error).toMatchObject({
|
|
165
|
+
name: 'ZipSafetyError',
|
|
166
|
+
code: 'entry-too-large',
|
|
167
|
+
path: 'large.txt',
|
|
168
|
+
limit: 4,
|
|
169
|
+
actual: 5,
|
|
170
|
+
});
|
|
171
|
+
expect((error as Error).message).toMatch(/exceeds 4 byte per-entry limit/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('rejects a high-ratio member from central-directory metadata', async () => {
|
|
175
|
+
const data = await makeZip({ 'repeated.bin': new Uint8Array(128 * 1024) });
|
|
176
|
+
await expect(openBoundedZipArchive(data, { maxCompressionRatio: 2 })).rejects.toMatchObject({
|
|
177
|
+
code: 'compression-ratio-exceeded',
|
|
178
|
+
path: 'repeated.bin',
|
|
179
|
+
limit: 2,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('enforces the default compression-ratio ceiling against a single-member bomb', async () => {
|
|
184
|
+
const data = await makeZip({ 'zeros.bin': new Uint8Array(2 * 1024 * 1024) });
|
|
185
|
+
await expect(openBoundedZipArchive(data)).rejects.toMatchObject({
|
|
186
|
+
code: 'compression-ratio-exceeded',
|
|
187
|
+
path: 'zeros.bin',
|
|
188
|
+
limit: 1000,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('halts an underdeclared member on its first emitted chunk without retaining it', async () => {
|
|
193
|
+
const valid = await makeZip({ 'bomb.txt': new Uint8Array(1024 * 1024) });
|
|
194
|
+
const forged = patchMemberMetadata(valid, 'bomb.txt', { uncompressedSize: 1 });
|
|
195
|
+
const archive = await openBoundedZipArchive(forged);
|
|
196
|
+
|
|
197
|
+
await expect(archive.read('bomb.txt')).rejects.toMatchObject({
|
|
198
|
+
code: 'size-mismatch',
|
|
199
|
+
path: 'bomb.txt',
|
|
200
|
+
});
|
|
201
|
+
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('pauses every concurrent JSZip stream when one read breaches its bound', async () => {
|
|
205
|
+
const data = await makeZip(
|
|
206
|
+
{
|
|
207
|
+
'one.bin': new Uint8Array(64 * 1024),
|
|
208
|
+
'two.bin': new Uint8Array(64 * 1024),
|
|
209
|
+
},
|
|
210
|
+
'STORE',
|
|
211
|
+
);
|
|
212
|
+
const archive = await openBoundedZipArchive(data);
|
|
213
|
+
const pauseSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
214
|
+
|
|
215
|
+
for (const metadata of archive.entries) {
|
|
216
|
+
const entry = metadata.entry as unknown as StreamableEntry;
|
|
217
|
+
const original = entry.internalStream.bind(entry);
|
|
218
|
+
entry.internalStream = ((type: 'uint8array') => {
|
|
219
|
+
const stream = original(type);
|
|
220
|
+
const pause = vi.fn(stream.pause.bind(stream));
|
|
221
|
+
stream.pause = pause;
|
|
222
|
+
pauseSpies.push(pause);
|
|
223
|
+
return stream;
|
|
224
|
+
}) as StreamableEntry['internalStream'];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const results = await Promise.allSettled([
|
|
228
|
+
archive.read('one.bin', 1),
|
|
229
|
+
archive.read('two.bin', 1),
|
|
230
|
+
]);
|
|
231
|
+
expect(results.every((result) => result.status === 'rejected')).toBe(true);
|
|
232
|
+
expect(pauseSpies).toHaveLength(2);
|
|
233
|
+
expect(pauseSpies.every((pause) => pause.mock.calls.length > 0)).toBe(true);
|
|
234
|
+
expect(archive.emittedUncompressedBytes).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('caches successful part reads without charging the aggregate budget twice', async () => {
|
|
238
|
+
const data = await makeZip({ 'part.xml': '<part />' }, 'STORE');
|
|
239
|
+
const archive = await openBoundedZipArchive(data);
|
|
240
|
+
const first = await archive.read('part.xml');
|
|
241
|
+
const emittedAfterFirstRead = archive.emittedUncompressedBytes;
|
|
242
|
+
const second = await archive.read('part.xml');
|
|
243
|
+
|
|
244
|
+
expect(second).toBe(first);
|
|
245
|
+
expect(archive.emittedUncompressedBytes).toBe(emittedAfterFirstRead);
|
|
246
|
+
expect(emittedAfterFirstRead).toBe(new TextEncoder().encode('<part />').byteLength);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('checks CRC32 while streaming instead of eagerly inflating during load', async () => {
|
|
250
|
+
const valid = await makeZip({ 'content.txt': 'integrity' }, 'STORE');
|
|
251
|
+
const forged = patchMemberMetadata(valid, 'content.txt', { crc32: 0 });
|
|
252
|
+
const archive = await openBoundedZipArchive(forged);
|
|
253
|
+
|
|
254
|
+
await expect(archive.read('content.txt')).rejects.toMatchObject({
|
|
255
|
+
code: 'crc-mismatch',
|
|
256
|
+
path: 'content.txt',
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not reject an otherwise valid JSZip object solely for unavailable internals', async () => {
|
|
261
|
+
const data = await makeZip({ 'content.txt': 'okay' }, 'STORE');
|
|
262
|
+
const zip = await JSZip.loadAsync(data);
|
|
263
|
+
const entry = zip.file('content.txt') as unknown as {
|
|
264
|
+
_data: { uncompressedSize?: number; compressedSize?: number; crc32?: number };
|
|
265
|
+
};
|
|
266
|
+
delete entry._data.uncompressedSize;
|
|
267
|
+
delete entry._data.compressedSize;
|
|
268
|
+
delete entry._data.crc32;
|
|
269
|
+
|
|
270
|
+
expect(() => validateZipArchive(zip)).not.toThrow();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects manually constructed OOXML packages that bypass bounded archive opening', async () => {
|
|
274
|
+
const forged = {
|
|
275
|
+
contentTypes: { overrides: new Map(), defaults: new Map() },
|
|
276
|
+
rootRelationships: [],
|
|
277
|
+
} as unknown as OoxmlPackage;
|
|
278
|
+
|
|
279
|
+
await expect(getPartXml(forged, 'custom.xml')).rejects.toThrow(
|
|
280
|
+
'Invalid OoxmlPackage: create packages with openPackage().',
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('bounds mandatory OOXML metadata independently of large media allowances', async () => {
|
|
285
|
+
const oversizedContentTypes = `<Types>${' '.repeat(1024 * 1024)}</Types>`;
|
|
286
|
+
const data = await makeZip(
|
|
287
|
+
{
|
|
288
|
+
'[Content_Types].xml': oversizedContentTypes,
|
|
289
|
+
'word/media/large.bin': new Uint8Array(2 * 1024 * 1024),
|
|
290
|
+
},
|
|
291
|
+
'STORE',
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
295
|
+
code: 'entry-too-large',
|
|
296
|
+
path: '[Content_Types].xml',
|
|
297
|
+
limit: 1024 * 1024,
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('bounds OOXML relationship metadata before DOM parsing', async () => {
|
|
302
|
+
const oversizedRelationships = `<Relationships>${' '.repeat(4 * 1024 * 1024)}</Relationships>`;
|
|
303
|
+
const data = await makeZip(
|
|
304
|
+
{
|
|
305
|
+
'[Content_Types].xml': '<Types />',
|
|
306
|
+
'_rels/.rels': oversizedRelationships,
|
|
307
|
+
},
|
|
308
|
+
'STORE',
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
await expect(openPackage(data.slice().buffer)).rejects.toMatchObject({
|
|
312
|
+
code: 'entry-too-large',
|
|
313
|
+
path: '_rels/.rels',
|
|
314
|
+
limit: 4 * 1024 * 1024,
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
package/src/container/index.ts
CHANGED
|
@@ -14,6 +14,24 @@
|
|
|
14
14
|
import JSZip from 'jszip';
|
|
15
15
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
16
16
|
import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
17
|
+
import {
|
|
18
|
+
assertSafeZipPath,
|
|
19
|
+
openBoundedZipArchive,
|
|
20
|
+
ZipSafetyError,
|
|
21
|
+
type ZipSafetyLimits,
|
|
22
|
+
type ZipSafetyErrorCode,
|
|
23
|
+
type ZipSafetyErrorOptions,
|
|
24
|
+
} from '../shared/zipSafety.js';
|
|
25
|
+
|
|
26
|
+
export type ZipToContainerOptions = ZipSafetyLimits;
|
|
27
|
+
export interface ContainerToZipOptions {
|
|
28
|
+
/** STORE avoids producing archives that violate strict import compression-ratio policies. */
|
|
29
|
+
compression?: 'STORE' | 'DEFLATE';
|
|
30
|
+
/** JSZip DEFLATE level; ignored for STORE. */
|
|
31
|
+
compressionLevel?: number;
|
|
32
|
+
}
|
|
33
|
+
export { ZipSafetyError };
|
|
34
|
+
export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
17
35
|
|
|
18
36
|
/**
|
|
19
37
|
* Serialize a ContentContainer to a ZIP blob.
|
|
@@ -24,21 +42,30 @@ import { MemoryContentContainer } from '@bendyline/squisq/storage';
|
|
|
24
42
|
* @param container — The container to serialize
|
|
25
43
|
* @returns A Blob containing the ZIP archive
|
|
26
44
|
*/
|
|
27
|
-
export async function containerToZip(
|
|
45
|
+
export async function containerToZip(
|
|
46
|
+
container: ContentContainer,
|
|
47
|
+
options: ContainerToZipOptions = {},
|
|
48
|
+
): Promise<Blob> {
|
|
28
49
|
const zip = new JSZip();
|
|
29
50
|
const entries = await container.listFiles();
|
|
30
51
|
|
|
31
52
|
for (const entry of entries) {
|
|
53
|
+
assertSafeZipPath(entry.path);
|
|
32
54
|
const data = await container.readFile(entry.path);
|
|
33
55
|
if (data) {
|
|
34
56
|
zip.file(entry.path, new Uint8Array(data));
|
|
35
57
|
}
|
|
36
58
|
}
|
|
37
59
|
|
|
60
|
+
const compression = options.compression ?? 'DEFLATE';
|
|
61
|
+
const compressionLevel = options.compressionLevel ?? 6;
|
|
62
|
+
if (!Number.isSafeInteger(compressionLevel) || compressionLevel < 1 || compressionLevel > 9) {
|
|
63
|
+
throw new Error('ZIP compression level must be an integer between 1 and 9');
|
|
64
|
+
}
|
|
38
65
|
return zip.generateAsync({
|
|
39
66
|
type: 'blob',
|
|
40
|
-
compression
|
|
41
|
-
compressionOptions: { level:
|
|
67
|
+
compression,
|
|
68
|
+
...(compression === 'DEFLATE' ? { compressionOptions: { level: compressionLevel } } : {}),
|
|
42
69
|
});
|
|
43
70
|
}
|
|
44
71
|
|
|
@@ -54,36 +81,14 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
|
|
|
54
81
|
*/
|
|
55
82
|
export async function zipToContainer(
|
|
56
83
|
zipData: ArrayBuffer | Uint8Array | Blob,
|
|
84
|
+
options: ZipToContainerOptions = {},
|
|
57
85
|
): Promise<MemoryContentContainer> {
|
|
58
|
-
const
|
|
86
|
+
const archive = await openBoundedZipArchive(zipData, options);
|
|
59
87
|
const container = new MemoryContentContainer();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (zipEntry.dir) return;
|
|
66
|
-
|
|
67
|
-
// Strip leading slash if present
|
|
68
|
-
const entryPath = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath;
|
|
69
|
-
if (!entryPath) return;
|
|
70
|
-
|
|
71
|
-
// Path-traversal protection: reject absolute paths, backslashes, and .. segments
|
|
72
|
-
if (
|
|
73
|
-
entryPath.startsWith('/') ||
|
|
74
|
-
entryPath.includes('\\') ||
|
|
75
|
-
entryPath.split('/').some((seg) => seg === '..')
|
|
76
|
-
) {
|
|
77
|
-
return;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
filePromises.push(
|
|
81
|
-
zipEntry.async('arraybuffer').then((data) => {
|
|
82
|
-
return container.writeFile(entryPath, data);
|
|
83
|
-
}),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
await Promise.all(filePromises);
|
|
88
|
+
for (const { path } of archive.entries) {
|
|
89
|
+
const data = await archive.read(path);
|
|
90
|
+
if (data) await container.writeFile(path, data);
|
|
91
|
+
archive.release(path);
|
|
92
|
+
}
|
|
88
93
|
return container;
|
|
89
94
|
}
|
package/src/docx/export.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { Doc, Theme } from '@bendyline/squisq/schemas';
|
|
20
|
+
import type { Doc, Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
21
21
|
import { resolveFontFamily } from '@bendyline/squisq/schemas';
|
|
22
22
|
import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
23
23
|
import type {
|
|
@@ -105,6 +105,8 @@ export interface DocxExportOptions {
|
|
|
105
105
|
* the theme's primary color to headings.
|
|
106
106
|
*/
|
|
107
107
|
themeId?: string;
|
|
108
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
109
|
+
themeRegistry?: ThemeRegistry;
|
|
108
110
|
/**
|
|
109
111
|
* Pre-resolved image data keyed by image URL/path as it appears in the
|
|
110
112
|
* markdown source. When provided, images are embedded in the .docx file
|
|
@@ -221,7 +223,7 @@ class ExportContext {
|
|
|
221
223
|
let themeBackgroundColor: string | undefined;
|
|
222
224
|
|
|
223
225
|
if (options.themeId) {
|
|
224
|
-
const theme: Theme = resolveThemeForDoc(doc, options.themeId);
|
|
226
|
+
const theme: Theme = resolveThemeForDoc(doc, options.themeId, options.themeRegistry);
|
|
225
227
|
// Theme fonts arrive as CSS stacks (e.g. `"Oswald", Impact,
|
|
226
228
|
// "Arial Black", sans-serif`). Word's `w:ascii` attribute is a
|
|
227
229
|
// single font name — passing the whole stack is treated as a
|
package/src/docx/import.ts
CHANGED
|
@@ -48,6 +48,7 @@ import type {
|
|
|
48
48
|
} from '@bendyline/squisq/markdown';
|
|
49
49
|
|
|
50
50
|
import { openPackage, getPartXml, getPartBinary, getPartRelationships } from '../ooxml/reader.js';
|
|
51
|
+
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
51
52
|
import type { OoxmlPackage, Relationship } from '../ooxml/types.js';
|
|
52
53
|
import { NS_WML, NS_R } from '../ooxml/namespaces.js';
|
|
53
54
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
@@ -68,7 +69,7 @@ import {
|
|
|
68
69
|
/**
|
|
69
70
|
* Options for DOCX import.
|
|
70
71
|
*/
|
|
71
|
-
export interface DocxImportOptions {
|
|
72
|
+
export interface DocxImportOptions extends OoxmlOpenOptions {
|
|
72
73
|
/**
|
|
73
74
|
* Whether to extract embedded images as base64 data URIs.
|
|
74
75
|
* When false, images are represented as `[Image]` placeholders.
|
|
@@ -88,7 +89,7 @@ export async function docxToMarkdownDoc(
|
|
|
88
89
|
data: ArrayBuffer | Blob,
|
|
89
90
|
options: DocxImportOptions = {},
|
|
90
91
|
): Promise<MarkdownDocument> {
|
|
91
|
-
const pkg = await openPackage(data);
|
|
92
|
+
const pkg = await openPackage(data, options);
|
|
92
93
|
const ctx = await buildImportContext(pkg, options);
|
|
93
94
|
|
|
94
95
|
const documentXml = await getPartXml(pkg, 'word/document.xml');
|
|
@@ -138,7 +139,7 @@ export async function docxToContainer(
|
|
|
138
139
|
data: ArrayBuffer | Blob,
|
|
139
140
|
options: DocxImportOptions = {},
|
|
140
141
|
): Promise<ContentContainer> {
|
|
141
|
-
const pkg = await openPackage(data);
|
|
142
|
+
const pkg = await openPackage(data, options);
|
|
142
143
|
const ctx = await buildImportContext(pkg, { ...options, extractImages: true });
|
|
143
144
|
|
|
144
145
|
const documentXml = await getPartXml(pkg, 'word/document.xml');
|