@bendyline/squisq-formats 2.0.0 → 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/dist/{chunk-CX3LH47J.js → chunk-2KPARF2P.js} +3 -3
- package/dist/{chunk-CVNVD7VH.js → chunk-ABVI556T.js} +5 -5
- package/dist/{chunk-2CSZJEAL.js → chunk-CRAVSMPZ.js} +3 -3
- package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-2EFDWKC6.js → chunk-HTW2M27H.js} +86 -23
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-ZZ7D4DN6.js → chunk-QRVN6A6E.js} +39 -9
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/{chunk-XTLQXR4U.js → chunk-U32AG3G3.js} +3 -3
- package/dist/{chunk-HBSCRS5P.js → chunk-VJJM2SSH.js} +3 -3
- package/dist/{chunk-2PRVLAU3.js → chunk-VSYHZECT.js} +55 -17
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-4HJ6AK2Q.js → chunk-WC7WULGV.js} +44 -13
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/{chunk-X4USWOQ2.js → chunk-XKUMNGBW.js} +2 -2
- package/dist/container/index.d.ts +10 -4
- package/dist/container/index.js +9 -4
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +2 -2
- package/dist/docx/index.js +4 -4
- package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
- package/dist/{extract-LNOA7DBK.js → extract-H6RXJMHP.js} +5 -5
- package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +14 -12
- package/dist/infer/index.d.ts +4 -4
- package/dist/infer/index.js +5 -5
- package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
- package/dist/{layouts-7OUUVAGU.js → layouts-QVPK3ZCU.js} +4 -4
- package/dist/ooxml/index.d.ts +4 -4
- package/dist/ooxml/index.js +4 -4
- package/dist/pptx/index.d.ts +5 -5
- package/dist/pptx/index.js +5 -5
- package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
- package/dist/registry/index.d.ts +20 -6
- package/dist/registry/index.js +6 -4
- package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
- package/dist/xlsx/index.d.ts +4 -4
- package/dist/xlsx/index.js +4 -4
- package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
- package/package.json +2 -2
- package/src/__tests__/convert.test.ts +143 -1
- package/src/__tests__/ooxml.test.ts +17 -0
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/container/index.ts +17 -3
- package/src/index.ts +3 -0
- package/src/infer/index.ts +35 -4
- package/src/ooxml/reader.ts +55 -14
- package/src/pptx/layouts.ts +68 -14
- package/src/registry/convert.ts +115 -31
- package/src/registry/defaultFormats.ts +5 -2
- package/src/registry/index.ts +3 -1
- package/src/registry/types.ts +18 -1
- package/src/shared/boundedZipArchive.ts +49 -8
- package/src/shared/zipEntryCount.ts +59 -5
- package/src/shared/zipLimits.ts +10 -0
- package/dist/chunk-2EFDWKC6.js.map +0 -1
- package/dist/chunk-2PRVLAU3.js.map +0 -1
- package/dist/chunk-4HJ6AK2Q.js.map +0 -1
- package/dist/chunk-H7T5DXUU.js.map +0 -1
- package/dist/chunk-ZZ7D4DN6.js.map +0 -1
- /package/dist/{chunk-CX3LH47J.js.map → chunk-2KPARF2P.js.map} +0 -0
- /package/dist/{chunk-CVNVD7VH.js.map → chunk-ABVI556T.js.map} +0 -0
- /package/dist/{chunk-2CSZJEAL.js.map → chunk-CRAVSMPZ.js.map} +0 -0
- /package/dist/{chunk-XTLQXR4U.js.map → chunk-U32AG3G3.js.map} +0 -0
- /package/dist/{chunk-HBSCRS5P.js.map → chunk-VJJM2SSH.js.map} +0 -0
- /package/dist/{chunk-X4USWOQ2.js.map → chunk-XKUMNGBW.js.map} +0 -0
- /package/dist/{extract-LNOA7DBK.js.map → extract-H6RXJMHP.js.map} +0 -0
- /package/dist/{layouts-7OUUVAGU.js.map → layouts-QVPK3ZCU.js.map} +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { inferThemeFromFile } from '../infer/index';
|
|
3
|
+
import { getPartXml, openPackage } from '../ooxml/reader';
|
|
4
|
+
import { inspectPptxLayouts } from '../pptx/layouts';
|
|
5
|
+
import { prepareZipInput } from '../shared/zipEntryCount';
|
|
6
|
+
import { buildThemedPptx } from './pptxInferFixtures';
|
|
7
|
+
|
|
8
|
+
function blockingBlob(onRead: () => void): Blob {
|
|
9
|
+
const blob = {
|
|
10
|
+
size: 128,
|
|
11
|
+
slice: () => blob,
|
|
12
|
+
arrayBuffer: () => {
|
|
13
|
+
onRead();
|
|
14
|
+
return new Promise<ArrayBuffer>(() => undefined);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
return blob as unknown as Blob;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('OOXML cancellation', () => {
|
|
21
|
+
it('preserves the exact reason while theme inference reads Blob input', async () => {
|
|
22
|
+
let markStarted: (() => void) | undefined;
|
|
23
|
+
const started = new Promise<void>((resolve) => {
|
|
24
|
+
markStarted = resolve;
|
|
25
|
+
});
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const reason = new Error('stop inference');
|
|
28
|
+
const pending = inferThemeFromFile(
|
|
29
|
+
blockingBlob(() => markStarted?.()),
|
|
30
|
+
{
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
await started;
|
|
36
|
+
controller.abort(reason);
|
|
37
|
+
|
|
38
|
+
await expect(pending).rejects.toBe(reason);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('preserves the exact reason while layout inspection opens Blob input', async () => {
|
|
42
|
+
let markStarted: (() => void) | undefined;
|
|
43
|
+
const started = new Promise<void>((resolve) => {
|
|
44
|
+
markStarted = resolve;
|
|
45
|
+
});
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const reason = new Error('stop layout inspection');
|
|
48
|
+
const pending = inspectPptxLayouts(
|
|
49
|
+
blockingBlob(() => markStarted?.()),
|
|
50
|
+
{
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await started;
|
|
56
|
+
controller.abort(reason);
|
|
57
|
+
|
|
58
|
+
await expect(pending).rejects.toBe(reason);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('propagates package cancellation through later OOXML part traversal', async () => {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const pkg = await openPackage(await buildThemedPptx(), { signal: controller.signal });
|
|
64
|
+
const reason = new Error('stop OOXML traversal');
|
|
65
|
+
controller.abort(reason);
|
|
66
|
+
|
|
67
|
+
await expect(getPartXml(pkg, 'ppt/presentation.xml')).rejects.toBe(reason);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('handles a synchronously re-entrant FileReader abort exactly once', async () => {
|
|
71
|
+
const previous = globalThis.FileReader;
|
|
72
|
+
let abortCalls = 0;
|
|
73
|
+
class ReentrantFileReader {
|
|
74
|
+
result: ArrayBuffer | null = null;
|
|
75
|
+
error: DOMException | null = null;
|
|
76
|
+
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
77
|
+
onerror: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
78
|
+
onabort: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
79
|
+
readAsArrayBuffer(): void {
|
|
80
|
+
// Remain pending until the caller aborts.
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
abort(): void {
|
|
84
|
+
abortCalls += 1;
|
|
85
|
+
this.onabort?.({} as ProgressEvent<FileReader>);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperty(globalThis, 'FileReader', {
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: ReentrantFileReader,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const blobLike = { size: 1, slice: () => blobLike } as unknown as Blob;
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const reason = new Error('stop FileReader');
|
|
97
|
+
try {
|
|
98
|
+
const pending = prepareZipInput(blobLike, controller.signal);
|
|
99
|
+
controller.abort(reason);
|
|
100
|
+
await expect(pending).rejects.toBe(reason);
|
|
101
|
+
expect(abortCalls).toBe(1);
|
|
102
|
+
} finally {
|
|
103
|
+
if (previous === undefined) Reflect.deleteProperty(globalThis, 'FileReader');
|
|
104
|
+
else {
|
|
105
|
+
Object.defineProperty(globalThis, 'FileReader', {
|
|
106
|
+
configurable: true,
|
|
107
|
+
writable: true,
|
|
108
|
+
value: previous,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/container/index.ts
CHANGED
|
@@ -24,6 +24,12 @@ import {
|
|
|
24
24
|
} from '../shared/zipSafety.js';
|
|
25
25
|
|
|
26
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
|
+
}
|
|
27
33
|
export { ZipSafetyError };
|
|
28
34
|
export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
29
35
|
|
|
@@ -36,7 +42,10 @@ export type { ZipSafetyLimits, ZipSafetyErrorCode, ZipSafetyErrorOptions };
|
|
|
36
42
|
* @param container — The container to serialize
|
|
37
43
|
* @returns A Blob containing the ZIP archive
|
|
38
44
|
*/
|
|
39
|
-
export async function containerToZip(
|
|
45
|
+
export async function containerToZip(
|
|
46
|
+
container: ContentContainer,
|
|
47
|
+
options: ContainerToZipOptions = {},
|
|
48
|
+
): Promise<Blob> {
|
|
40
49
|
const zip = new JSZip();
|
|
41
50
|
const entries = await container.listFiles();
|
|
42
51
|
|
|
@@ -48,10 +57,15 @@ export async function containerToZip(container: ContentContainer): Promise<Blob>
|
|
|
48
57
|
}
|
|
49
58
|
}
|
|
50
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
|
+
}
|
|
51
65
|
return zip.generateAsync({
|
|
52
66
|
type: 'blob',
|
|
53
|
-
compression
|
|
54
|
-
compressionOptions: { level:
|
|
67
|
+
compression,
|
|
68
|
+
...(compression === 'DEFLATE' ? { compressionOptions: { level: compressionLevel } } : {}),
|
|
55
69
|
});
|
|
56
70
|
}
|
|
57
71
|
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export type {
|
|
|
76
76
|
// Format registry + programmatic convert()
|
|
77
77
|
export {
|
|
78
78
|
convert,
|
|
79
|
+
prepareConversion,
|
|
79
80
|
createRegistry,
|
|
80
81
|
defaultRegistry,
|
|
81
82
|
defaultFormats,
|
|
@@ -103,4 +104,6 @@ export type {
|
|
|
103
104
|
BuiltinFormatOptions,
|
|
104
105
|
MarkdownFormatOptions,
|
|
105
106
|
DbkFormatOptions,
|
|
107
|
+
PreparedConversion,
|
|
108
|
+
PreparedExportOptions,
|
|
106
109
|
} from './registry/index.js';
|
package/src/infer/index.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { CustomTemplateDefinition, Theme } from '@bendyline/squisq/schemas';
|
|
19
|
-
import { openPackage } from '../ooxml/reader.js';
|
|
19
|
+
import { openPackage, throwIfOoxmlAborted } from '../ooxml/reader.js';
|
|
20
20
|
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
21
21
|
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
22
22
|
import { ConversionError } from '../registry/errors.js';
|
|
@@ -53,7 +53,8 @@ export interface InferredFileTheme {
|
|
|
53
53
|
warnings: string[];
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function looksLikePdf(data: ArrayBuffer | Blob): Promise<boolean> {
|
|
56
|
+
async function looksLikePdf(data: ArrayBuffer | Blob, signal?: AbortSignal): Promise<boolean> {
|
|
57
|
+
throwIfOoxmlAborted(signal);
|
|
57
58
|
// Duck-typed (not instanceof): buffers routinely cross realms in test
|
|
58
59
|
// environments and web workers. Blobs carry `size`; ArrayBuffers carry
|
|
59
60
|
// `byteLength`.
|
|
@@ -64,16 +65,39 @@ async function looksLikePdf(data: ArrayBuffer | Blob): Promise<boolean> {
|
|
|
64
65
|
typeof blob.slice === 'function' &&
|
|
65
66
|
typeof blob.arrayBuffer === 'function'
|
|
66
67
|
) {
|
|
67
|
-
head = new Uint8Array(await blob.slice(0, 5).arrayBuffer());
|
|
68
|
+
head = new Uint8Array(await waitForAbortable(blob.slice(0, 5).arrayBuffer(), signal));
|
|
68
69
|
} else if (typeof (data as ArrayBuffer).byteLength === 'number') {
|
|
69
70
|
const buf = data as ArrayBuffer;
|
|
70
71
|
head = new Uint8Array(buf, 0, Math.min(5, buf.byteLength));
|
|
71
72
|
}
|
|
73
|
+
throwIfOoxmlAborted(signal);
|
|
72
74
|
if (!head || head.length < 4) return false;
|
|
73
75
|
// '%PDF'
|
|
74
76
|
return head[0] === 0x25 && head[1] === 0x50 && head[2] === 0x44 && head[3] === 0x46;
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
function waitForAbortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
80
|
+
throwIfOoxmlAborted(signal);
|
|
81
|
+
if (!signal) return work;
|
|
82
|
+
return new Promise<T>((resolve, reject) => {
|
|
83
|
+
let settled = false;
|
|
84
|
+
const finish = (complete: () => void): void => {
|
|
85
|
+
if (settled) return;
|
|
86
|
+
settled = true;
|
|
87
|
+
signal.removeEventListener('abort', handleAbort);
|
|
88
|
+
complete();
|
|
89
|
+
};
|
|
90
|
+
const handleAbort = (): void =>
|
|
91
|
+
finish(() => reject(signal.reason ?? new Error('OOXML operation was cancelled')));
|
|
92
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
93
|
+
if (signal.aborted) handleAbort();
|
|
94
|
+
work.then(
|
|
95
|
+
(value) => finish(() => resolve(value)),
|
|
96
|
+
(error: unknown) => finish(() => reject(error)),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
/**
|
|
78
102
|
* Classify an opened package from its already-parsed content types.
|
|
79
103
|
* (Deliberately not the registry's byte sniffer — that re-parses the zip
|
|
@@ -110,7 +134,8 @@ export async function inferThemeFromFile(
|
|
|
110
134
|
data: ArrayBuffer | Blob,
|
|
111
135
|
options: InferThemeOptions = {},
|
|
112
136
|
): Promise<InferredFileTheme> {
|
|
113
|
-
|
|
137
|
+
throwIfOoxmlAborted(options.signal);
|
|
138
|
+
if (await looksLikePdf(data, options.signal)) {
|
|
114
139
|
throw new ConversionError(
|
|
115
140
|
'unsupported-input',
|
|
116
141
|
'PDF theme inference is not supported — PDF files carry no theme color/font tables.',
|
|
@@ -122,6 +147,7 @@ export async function inferThemeFromFile(
|
|
|
122
147
|
try {
|
|
123
148
|
pkg = await openPackage(data, options);
|
|
124
149
|
} catch (err: unknown) {
|
|
150
|
+
if (options.signal?.aborted) throw options.signal.reason ?? err;
|
|
125
151
|
throw new ConversionError(
|
|
126
152
|
'invalid-input',
|
|
127
153
|
'Could not read this file as a Word, PowerPoint, or Excel document.',
|
|
@@ -130,6 +156,7 @@ export async function inferThemeFromFile(
|
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
const format = options.format ?? sniffOoxmlFormat(pkg);
|
|
159
|
+
throwIfOoxmlAborted(options.signal);
|
|
133
160
|
if (!format) {
|
|
134
161
|
throw new ConversionError(
|
|
135
162
|
'invalid-input',
|
|
@@ -138,6 +165,7 @@ export async function inferThemeFromFile(
|
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
const extraction = await EXTRACTORS[format](pkg);
|
|
168
|
+
throwIfOoxmlAborted(options.signal);
|
|
141
169
|
if (!extraction) {
|
|
142
170
|
throw new ConversionError('invalid-input', 'No theme part found in this file.', { format });
|
|
143
171
|
}
|
|
@@ -145,6 +173,7 @@ export async function inferThemeFromFile(
|
|
|
145
173
|
const { theme, warnings: mapWarnings } = compileExtractedTheme(extraction, {
|
|
146
174
|
nameHint: options.nameHint,
|
|
147
175
|
});
|
|
176
|
+
throwIfOoxmlAborted(options.signal);
|
|
148
177
|
const warnings = [...extraction.warnings, ...mapWarnings];
|
|
149
178
|
|
|
150
179
|
let layouts: CustomTemplateDefinition[] | undefined;
|
|
@@ -155,6 +184,7 @@ export async function inferThemeFromFile(
|
|
|
155
184
|
const { analyzePptxLayouts } = await import('../pptx/layouts.js');
|
|
156
185
|
const analysis = await analyzePptxLayouts(pkg, {
|
|
157
186
|
colors: colorHintsFromExtraction(extraction),
|
|
187
|
+
signal: options.signal,
|
|
158
188
|
});
|
|
159
189
|
warnings.push(...analysis.warnings);
|
|
160
190
|
const defs = analysis.layouts
|
|
@@ -164,5 +194,6 @@ export async function inferThemeFromFile(
|
|
|
164
194
|
}
|
|
165
195
|
}
|
|
166
196
|
|
|
197
|
+
throwIfOoxmlAborted(options.signal);
|
|
167
198
|
return { theme, extraction, ...(layouts ? { layouts } : {}), warnings };
|
|
168
199
|
}
|
package/src/ooxml/reader.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* structural metadata: [Content_Types].xml, relationships, and
|
|
6
6
|
* core properties.
|
|
7
7
|
*
|
|
8
|
-
* Uses bounded JSZip member streams and a platform DOMParser (with
|
|
9
|
-
*
|
|
8
|
+
* Uses bounded JSZip member streams and a platform DOMParser (with the
|
|
9
|
+
* package-owned xmldom parser in Node/SSR) to parse XML.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { DOMParser as XmldomDOMParser } from '@xmldom/xmldom';
|
|
@@ -26,21 +26,44 @@ type DomParserLike = new () => {
|
|
|
26
26
|
|
|
27
27
|
/** Safe read sessions for opaque packages opened by this module. */
|
|
28
28
|
const packageArchives = new WeakMap<OoxmlPackage, BoundedZipArchive>();
|
|
29
|
+
const packageSignals = new WeakMap<OoxmlPackage, AbortSignal>();
|
|
29
30
|
const MAX_CONTENT_TYPES_BYTES = 1024 * 1024;
|
|
30
31
|
const MAX_RELATIONSHIPS_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
const CANCELLATION_CHECKPOINT_INTERVAL = 256;
|
|
33
|
+
|
|
34
|
+
/** Preserve the caller's exact cancellation reason across OOXML traversal. */
|
|
35
|
+
export function throwIfOoxmlAborted(signal?: AbortSignal): void {
|
|
36
|
+
if (!signal?.aborted) return;
|
|
37
|
+
throw signal.reason ?? new Error('OOXML operation was cancelled');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function cancellationCheckpoint(index: number, signal?: AbortSignal): Promise<void> {
|
|
41
|
+
throwIfOoxmlAborted(signal);
|
|
42
|
+
if (index > 0 && index % CANCELLATION_CHECKPOINT_INTERVAL === 0) {
|
|
43
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
44
|
+
throwIfOoxmlAborted(signal);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
31
47
|
|
|
32
48
|
/**
|
|
33
|
-
* Parse XML in browsers and
|
|
34
|
-
*
|
|
49
|
+
* Parse XML in browsers and Node. Test runners and SSR hosts can install a
|
|
50
|
+
* partial browser DOMParser whose namespace behavior differs from browsers,
|
|
51
|
+
* so Node always uses the package-owned, namespace-aware xmldom parser.
|
|
35
52
|
*/
|
|
36
|
-
function parseXml(source: string): Document {
|
|
37
|
-
|
|
53
|
+
function parseXml(source: string, signal?: AbortSignal): Document {
|
|
54
|
+
throwIfOoxmlAborted(signal);
|
|
55
|
+
const runtime = globalThis as {
|
|
56
|
+
DOMParser?: DomParserLike;
|
|
57
|
+
process?: { versions?: { node?: string } };
|
|
58
|
+
};
|
|
59
|
+
const nativeParser = runtime.process?.versions?.node ? undefined : runtime.DOMParser;
|
|
38
60
|
const Parser = nativeParser ?? (XmldomDOMParser as unknown as DomParserLike);
|
|
39
61
|
const doc = new Parser().parseFromString(source, 'application/xml') as Document;
|
|
40
62
|
const parserErrors = doc.getElementsByTagName('parsererror');
|
|
41
63
|
if (parserErrors.length > 0) {
|
|
42
64
|
throw new Error(`Invalid OOXML XML part: ${parserErrors[0]?.textContent ?? 'parse error'}`);
|
|
43
65
|
}
|
|
66
|
+
throwIfOoxmlAborted(signal);
|
|
44
67
|
return doc;
|
|
45
68
|
}
|
|
46
69
|
|
|
@@ -61,15 +84,18 @@ export async function openPackage(
|
|
|
61
84
|
data: ArrayBuffer | Blob,
|
|
62
85
|
limits: OoxmlOpenOptions = {},
|
|
63
86
|
): Promise<OoxmlPackage> {
|
|
87
|
+
throwIfOoxmlAborted(limits.signal);
|
|
64
88
|
const archive = await openBoundedZipArchive(data, limits);
|
|
65
|
-
const contentTypes = await parseContentTypes(archive);
|
|
66
|
-
const rootRelationships = await parseRelationships(archive, '');
|
|
89
|
+
const contentTypes = await parseContentTypes(archive, limits.signal);
|
|
90
|
+
const rootRelationships = await parseRelationships(archive, '', limits.signal);
|
|
91
|
+
throwIfOoxmlAborted(limits.signal);
|
|
67
92
|
|
|
68
93
|
const pkg = {
|
|
69
94
|
contentTypes,
|
|
70
95
|
rootRelationships,
|
|
71
96
|
} as unknown as OoxmlPackage;
|
|
72
97
|
packageArchives.set(pkg, archive);
|
|
98
|
+
if (limits.signal) packageSignals.set(pkg, limits.signal);
|
|
73
99
|
return pkg;
|
|
74
100
|
}
|
|
75
101
|
|
|
@@ -80,17 +106,21 @@ export async function openPackage(
|
|
|
80
106
|
/**
|
|
81
107
|
* Parse [Content_Types].xml from the archive.
|
|
82
108
|
*/
|
|
83
|
-
async function parseContentTypes(
|
|
109
|
+
async function parseContentTypes(
|
|
110
|
+
archive: BoundedZipArchive,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<ContentTypeMap> {
|
|
84
113
|
const overrides = new Map<string, string>();
|
|
85
114
|
const defaults = new Map<string, string>();
|
|
86
115
|
|
|
87
116
|
const text = await archive.readText('[Content_Types].xml', MAX_CONTENT_TYPES_BYTES);
|
|
88
117
|
if (text === null) return { overrides, defaults };
|
|
89
|
-
const doc = parseXml(text);
|
|
118
|
+
const doc = parseXml(text, signal);
|
|
90
119
|
|
|
91
120
|
// Parse <Default Extension="rels" ContentType="..." />
|
|
92
121
|
const defaultEls = doc.getElementsByTagName('Default');
|
|
93
122
|
for (let i = 0; i < defaultEls.length; i++) {
|
|
123
|
+
await cancellationCheckpoint(i, signal);
|
|
94
124
|
const el = defaultEls[i];
|
|
95
125
|
const ext = el.getAttribute('Extension');
|
|
96
126
|
const ct = el.getAttribute('ContentType');
|
|
@@ -100,6 +130,7 @@ async function parseContentTypes(archive: BoundedZipArchive): Promise<ContentTyp
|
|
|
100
130
|
// Parse <Override PartName="/word/document.xml" ContentType="..." />
|
|
101
131
|
const overrideEls = doc.getElementsByTagName('Override');
|
|
102
132
|
for (let i = 0; i < overrideEls.length; i++) {
|
|
133
|
+
await cancellationCheckpoint(i, signal);
|
|
103
134
|
const el = overrideEls[i];
|
|
104
135
|
const partName = el.getAttribute('PartName');
|
|
105
136
|
const ct = el.getAttribute('ContentType');
|
|
@@ -128,7 +159,7 @@ export async function getPartRelationships(
|
|
|
128
159
|
pkg: OoxmlPackage,
|
|
129
160
|
partPath: string,
|
|
130
161
|
): Promise<Relationship[]> {
|
|
131
|
-
return parseRelationships(pkg, partPath);
|
|
162
|
+
return parseRelationships(pkg, partPath, packageSignals.get(pkg));
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/**
|
|
@@ -140,13 +171,14 @@ export async function getPartRelationships(
|
|
|
140
171
|
async function parseRelationships(
|
|
141
172
|
source: OoxmlPackage | BoundedZipArchive,
|
|
142
173
|
partPath: string,
|
|
174
|
+
signal?: AbortSignal,
|
|
143
175
|
): Promise<Relationship[]> {
|
|
144
176
|
const relsPath = partPath === '' ? '_rels/.rels' : buildRelsPath(partPath);
|
|
145
177
|
|
|
146
178
|
const bytes = await readArchivePart(source, relsPath, MAX_RELATIONSHIPS_BYTES);
|
|
147
179
|
if (!bytes) return [];
|
|
148
180
|
const text = new TextDecoder().decode(bytes);
|
|
149
|
-
const doc = parseXml(text);
|
|
181
|
+
const doc = parseXml(text, signal);
|
|
150
182
|
const result: Relationship[] = [];
|
|
151
183
|
|
|
152
184
|
const els = doc.getElementsByTagNameNS(NS_RELATIONSHIPS, 'Relationship');
|
|
@@ -154,6 +186,7 @@ async function parseRelationships(
|
|
|
154
186
|
const fallbackEls = els.length > 0 ? els : doc.getElementsByTagName('Relationship');
|
|
155
187
|
|
|
156
188
|
for (let i = 0; i < fallbackEls.length; i++) {
|
|
189
|
+
await cancellationCheckpoint(i, signal);
|
|
157
190
|
const el = fallbackEls[i];
|
|
158
191
|
const id = el.getAttribute('Id');
|
|
159
192
|
const type = el.getAttribute('Type');
|
|
@@ -200,8 +233,10 @@ function buildRelsPath(partPath: string): string {
|
|
|
200
233
|
* @returns Parsed XML Document, or null if the part doesn't exist
|
|
201
234
|
*/
|
|
202
235
|
export async function getPartXml(pkg: OoxmlPackage, partPath: string): Promise<Document | null> {
|
|
236
|
+
const signal = packageSignals.get(pkg);
|
|
237
|
+
throwIfOoxmlAborted(signal);
|
|
203
238
|
const bytes = await readPackagePart(pkg, partPath);
|
|
204
|
-
return bytes ? parseXml(new TextDecoder().decode(bytes)) : null;
|
|
239
|
+
return bytes ? parseXml(new TextDecoder().decode(bytes), signal) : null;
|
|
205
240
|
}
|
|
206
241
|
|
|
207
242
|
/**
|
|
@@ -215,6 +250,7 @@ export async function getPartBinary(
|
|
|
215
250
|
pkg: OoxmlPackage,
|
|
216
251
|
partPath: string,
|
|
217
252
|
): Promise<ArrayBuffer | null> {
|
|
253
|
+
throwIfOoxmlAborted(packageSignals.get(pkg));
|
|
218
254
|
const bytes = await readPackagePart(pkg, partPath);
|
|
219
255
|
return bytes ? bytes.slice().buffer : null;
|
|
220
256
|
}
|
|
@@ -234,6 +270,7 @@ async function readPackagePart(
|
|
|
234
270
|
path: string,
|
|
235
271
|
maxBytes?: number,
|
|
236
272
|
): Promise<Uint8Array | null> {
|
|
273
|
+
throwIfOoxmlAborted(packageSignals.get(pkg));
|
|
237
274
|
const archive = packageArchives.get(pkg);
|
|
238
275
|
if (!archive) {
|
|
239
276
|
throw new TypeError('Invalid OoxmlPackage: create packages with openPackage().');
|
|
@@ -252,6 +289,8 @@ async function readPackagePart(
|
|
|
252
289
|
* @returns Parsed core properties (all fields optional)
|
|
253
290
|
*/
|
|
254
291
|
export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperties> {
|
|
292
|
+
const signal = packageSignals.get(pkg);
|
|
293
|
+
throwIfOoxmlAborted(signal);
|
|
255
294
|
const doc = await getPartXml(pkg, 'docProps/core.xml');
|
|
256
295
|
if (!doc) return {};
|
|
257
296
|
|
|
@@ -263,7 +302,7 @@ export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperti
|
|
|
263
302
|
return undefined;
|
|
264
303
|
}
|
|
265
304
|
|
|
266
|
-
|
|
305
|
+
const properties = {
|
|
267
306
|
title: getText(NS_DC, 'title'),
|
|
268
307
|
subject: getText(NS_DC, 'subject'),
|
|
269
308
|
creator: getText(NS_DC, 'creator'),
|
|
@@ -274,4 +313,6 @@ export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperti
|
|
|
274
313
|
created: getText(NS_DCTERMS, 'created'),
|
|
275
314
|
modified: getText(NS_DCTERMS, 'modified'),
|
|
276
315
|
};
|
|
316
|
+
throwIfOoxmlAborted(signal);
|
|
317
|
+
return properties;
|
|
277
318
|
}
|