@bendyline/squisq-formats 2.0.0 → 2.1.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/dist/{chunk-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
- package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
- package/dist/chunk-7ARKUCQT.js.map +1 -0
- package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
- package/dist/chunk-DTDF6QDP.js.map +1 -0
- package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
- package/dist/chunk-EW54IRRS.js.map +1 -0
- package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
- package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
- package/dist/chunk-NNHKUXKA.js.map +1 -0
- package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
- package/dist/chunk-QFLDYKCR.js.map +1 -0
- package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
- package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
- package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
- package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
- package/dist/chunk-WQSHGBLN.js.map +1 -0
- 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 +5 -4
- package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
- package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
- package/dist/html/index.js +1 -1
- package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +15 -13
- package/dist/infer/index.d.ts +4 -4
- package/dist/infer/index.js +5 -5
- package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
- package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
- 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__/docxExport.test.ts +34 -0
- package/src/__tests__/docxImport.test.ts +121 -0
- package/src/__tests__/html.test.ts +4 -0
- 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/docx/export.ts +113 -5
- package/src/docx/import.ts +324 -69
- package/src/html/htmlTemplate.ts +2 -2
- package/src/index.ts +3 -0
- package/src/infer/index.ts +35 -4
- package/src/ooxml/namespaces.ts +21 -0
- 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-2CSZJEAL.js.map +0 -1
- 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-LXYLOOST.js.map +0 -1
- package/dist/chunk-ZZ7D4DN6.js.map +0 -1
- /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
- /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
- /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
- /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
- /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
- /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
- /package/dist/{layouts-7OUUVAGU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
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/namespaces.ts
CHANGED
|
@@ -59,6 +59,18 @@ export const REL_IMAGE =
|
|
|
59
59
|
export const REL_FOOTNOTES =
|
|
60
60
|
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes';
|
|
61
61
|
|
|
62
|
+
/** Relationship type: Endnotes */
|
|
63
|
+
export const REL_ENDNOTES =
|
|
64
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes';
|
|
65
|
+
|
|
66
|
+
/** Relationship type: Header */
|
|
67
|
+
export const REL_HEADER =
|
|
68
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/header';
|
|
69
|
+
|
|
70
|
+
/** Relationship type: Footer */
|
|
71
|
+
export const REL_FOOTER =
|
|
72
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer';
|
|
73
|
+
|
|
62
74
|
/** Relationship type: Theme */
|
|
63
75
|
export const REL_THEME =
|
|
64
76
|
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme';
|
|
@@ -156,6 +168,15 @@ export const CONTENT_TYPE_DOCX_FONT_TABLE =
|
|
|
156
168
|
export const CONTENT_TYPE_DOCX_FOOTNOTES =
|
|
157
169
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml';
|
|
158
170
|
|
|
171
|
+
export const CONTENT_TYPE_DOCX_ENDNOTES =
|
|
172
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml';
|
|
173
|
+
|
|
174
|
+
export const CONTENT_TYPE_DOCX_HEADER =
|
|
175
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
|
|
176
|
+
|
|
177
|
+
export const CONTENT_TYPE_DOCX_FOOTER =
|
|
178
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
|
|
179
|
+
|
|
159
180
|
export const CONTENT_TYPE_PPTX_PRESENTATION =
|
|
160
181
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml';
|
|
161
182
|
|
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
|
}
|
package/src/pptx/layouts.ts
CHANGED
|
@@ -24,7 +24,12 @@ import type {
|
|
|
24
24
|
TextLayer,
|
|
25
25
|
TextStyle,
|
|
26
26
|
} from '@bendyline/squisq/schemas';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
getPartRelationships,
|
|
29
|
+
getPartXml,
|
|
30
|
+
openPackage,
|
|
31
|
+
throwIfOoxmlAborted,
|
|
32
|
+
} from '../ooxml/reader.js';
|
|
28
33
|
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
29
34
|
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
30
35
|
import {
|
|
@@ -162,6 +167,7 @@ const DEFAULT_SLIDE_SIZE = { cx: 12192000, cy: 6858000 };
|
|
|
162
167
|
const CHROME_PH_TYPES = new Set(['dt', 'ftr', 'sldNum', 'hdr']);
|
|
163
168
|
const MAX_INFERRED_TEMPLATES = 12;
|
|
164
169
|
const MAX_DECORATIONS = 4;
|
|
170
|
+
const CANCELLATION_CHECKPOINT_INTERVAL = 128;
|
|
165
171
|
/** Decorative rects smaller than this share of the slide are clutter. */
|
|
166
172
|
const MIN_DECORATION_AREA_PCT = 2;
|
|
167
173
|
|
|
@@ -173,6 +179,14 @@ const DEFAULT_TITLE_PT = 44;
|
|
|
173
179
|
const DEFAULT_SUBTITLE_PT = 24;
|
|
174
180
|
const DEFAULT_BODY_PT = 22;
|
|
175
181
|
|
|
182
|
+
async function cancellationCheckpoint(index: number, signal?: AbortSignal): Promise<void> {
|
|
183
|
+
throwIfOoxmlAborted(signal);
|
|
184
|
+
if (index > 0 && index % CANCELLATION_CHECKPOINT_INTERVAL === 0) {
|
|
185
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
186
|
+
throwIfOoxmlAborted(signal);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
// ── Small helpers ────────────────────────────────────────────────────
|
|
177
191
|
|
|
178
192
|
function firstPml(parent: Element | Document, local: string): Element | null {
|
|
@@ -316,7 +330,12 @@ interface MasterIndex {
|
|
|
316
330
|
bodyStyle?: PlaceholderTextStyle;
|
|
317
331
|
}
|
|
318
332
|
|
|
319
|
-
async function readMasterIndex(
|
|
333
|
+
async function readMasterIndex(
|
|
334
|
+
pkg: OoxmlPackage,
|
|
335
|
+
masterPath: string,
|
|
336
|
+
signal?: AbortSignal,
|
|
337
|
+
): Promise<MasterIndex> {
|
|
338
|
+
throwIfOoxmlAborted(signal);
|
|
320
339
|
const index: MasterIndex = { name: '', exact: new Map(), baseIdx: new Map(), base: new Map() };
|
|
321
340
|
const doc = await getPartXml(pkg, masterPath);
|
|
322
341
|
if (!doc) return index;
|
|
@@ -333,6 +352,7 @@ async function readMasterIndex(pkg: OoxmlPackage, masterPath: string): Promise<M
|
|
|
333
352
|
|
|
334
353
|
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
335
354
|
for (let i = 0; i < shapes.length; i++) {
|
|
355
|
+
await cancellationCheckpoint(i, signal);
|
|
336
356
|
const sp = shapes[i]!;
|
|
337
357
|
const ph = firstPml(sp, 'ph');
|
|
338
358
|
if (!ph) continue;
|
|
@@ -425,7 +445,9 @@ async function extractLayout(
|
|
|
425
445
|
pxPerPt: number,
|
|
426
446
|
hints: PptxColorHints,
|
|
427
447
|
masterCache: Map<string, Promise<MasterIndex>>,
|
|
448
|
+
signal?: AbortSignal,
|
|
428
449
|
): Promise<ExtractedSlideLayout | null> {
|
|
450
|
+
throwIfOoxmlAborted(signal);
|
|
429
451
|
const doc = await getPartXml(pkg, layoutPath);
|
|
430
452
|
if (!doc) return null;
|
|
431
453
|
|
|
@@ -440,7 +462,7 @@ async function extractLayout(
|
|
|
440
462
|
masterPath = resolveTarget(baseDirOf(layoutPath), masterRel.target);
|
|
441
463
|
let cached = masterCache.get(masterPath);
|
|
442
464
|
if (!cached) {
|
|
443
|
-
cached = readMasterIndex(pkg, masterPath);
|
|
465
|
+
cached = readMasterIndex(pkg, masterPath, signal);
|
|
444
466
|
masterCache.set(masterPath, cached);
|
|
445
467
|
}
|
|
446
468
|
master = await cached;
|
|
@@ -451,6 +473,7 @@ async function extractLayout(
|
|
|
451
473
|
|
|
452
474
|
const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
|
|
453
475
|
for (let i = 0; i < shapes.length; i++) {
|
|
476
|
+
await cancellationCheckpoint(i, signal);
|
|
454
477
|
const sp = shapes[i]!;
|
|
455
478
|
const ph = firstPml(sp, 'ph');
|
|
456
479
|
|
|
@@ -1003,7 +1026,11 @@ function resolveVerdict(
|
|
|
1003
1026
|
|
|
1004
1027
|
// ── Deck-level analysis ──────────────────────────────────────────────
|
|
1005
1028
|
|
|
1006
|
-
async function readSlideSize(
|
|
1029
|
+
async function readSlideSize(
|
|
1030
|
+
pkg: OoxmlPackage,
|
|
1031
|
+
signal?: AbortSignal,
|
|
1032
|
+
): Promise<{ cx: number; cy: number }> {
|
|
1033
|
+
throwIfOoxmlAborted(signal);
|
|
1007
1034
|
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1008
1035
|
const sldSz = pres ? firstPml(pres, 'sldSz') : null;
|
|
1009
1036
|
const cx = intAttr(sldSz, 'cx');
|
|
@@ -1012,7 +1039,11 @@ async function readSlideSize(pkg: OoxmlPackage): Promise<{ cx: number; cy: numbe
|
|
|
1012
1039
|
return { ...DEFAULT_SLIDE_SIZE };
|
|
1013
1040
|
}
|
|
1014
1041
|
|
|
1015
|
-
async function orderedSlidePathsForLayouts(
|
|
1042
|
+
async function orderedSlidePathsForLayouts(
|
|
1043
|
+
pkg: OoxmlPackage,
|
|
1044
|
+
signal?: AbortSignal,
|
|
1045
|
+
): Promise<string[]> {
|
|
1046
|
+
throwIfOoxmlAborted(signal);
|
|
1016
1047
|
const pres = await getPartXml(pkg, 'ppt/presentation.xml');
|
|
1017
1048
|
if (!pres) return [];
|
|
1018
1049
|
const rels = await getPartRelationships(pkg, 'ppt/presentation.xml');
|
|
@@ -1020,6 +1051,7 @@ async function orderedSlidePathsForLayouts(pkg: OoxmlPackage): Promise<string[]>
|
|
|
1020
1051
|
const out: string[] = [];
|
|
1021
1052
|
const ids = pres.getElementsByTagNameNS(NS_PML, 'sldId');
|
|
1022
1053
|
for (let i = 0; i < ids.length; i++) {
|
|
1054
|
+
await cancellationCheckpoint(i, signal);
|
|
1023
1055
|
const rid = attrNS(ids[i]!, NS_R, 'id', 'r:id');
|
|
1024
1056
|
const target = rid ? relById.get(rid) : undefined;
|
|
1025
1057
|
if (target) out.push(resolveTarget('ppt', target));
|
|
@@ -1043,20 +1075,23 @@ export async function analyzePptxLayouts(
|
|
|
1043
1075
|
pkg: OoxmlPackage,
|
|
1044
1076
|
options: AnalyzePptxLayoutsOptions = {},
|
|
1045
1077
|
): Promise<PptxLayoutInference> {
|
|
1078
|
+
throwIfOoxmlAborted(options.signal);
|
|
1046
1079
|
const warnings: string[] = [];
|
|
1047
1080
|
const hints = options.colors ?? {};
|
|
1048
1081
|
const maxTemplates = options.maxTemplates ?? MAX_INFERRED_TEMPLATES;
|
|
1049
1082
|
|
|
1050
|
-
const sldSz = await readSlideSize(pkg);
|
|
1083
|
+
const sldSz = await readSlideSize(pkg, options.signal);
|
|
1051
1084
|
const viewport = viewportFor(sldSz);
|
|
1052
1085
|
const pxPerPt = viewport.height / (sldSz.cy / 12700);
|
|
1053
1086
|
|
|
1054
1087
|
// Layouts used by slides, in first-use order, with usage counts.
|
|
1055
|
-
const slidePaths = await orderedSlidePathsForLayouts(pkg);
|
|
1088
|
+
const slidePaths = await orderedSlidePathsForLayouts(pkg, options.signal);
|
|
1056
1089
|
const layoutPathBySlide = new Map<string, string>();
|
|
1057
1090
|
const usage = new Map<string, number>();
|
|
1058
1091
|
const orderedLayoutPaths: string[] = [];
|
|
1059
|
-
for (
|
|
1092
|
+
for (let slideIndex = 0; slideIndex < slidePaths.length; slideIndex++) {
|
|
1093
|
+
await cancellationCheckpoint(slideIndex, options.signal);
|
|
1094
|
+
const slidePath = slidePaths[slideIndex]!;
|
|
1060
1095
|
const rels = await getPartRelationships(pkg, slidePath);
|
|
1061
1096
|
const layoutRel = findRelByType(rels, REL_SLIDE_LAYOUT);
|
|
1062
1097
|
if (!layoutRel) continue;
|
|
@@ -1068,7 +1103,9 @@ export async function analyzePptxLayouts(
|
|
|
1068
1103
|
|
|
1069
1104
|
if (options.includeUnused) {
|
|
1070
1105
|
const unused: string[] = [];
|
|
1106
|
+
let partIndex = 0;
|
|
1071
1107
|
for (const [partPath, contentType] of pkg.contentTypes.overrides) {
|
|
1108
|
+
await cancellationCheckpoint(partIndex++, options.signal);
|
|
1072
1109
|
if (contentType === CONTENT_TYPE_PPTX_SLIDE_LAYOUT && !usage.has(partPath)) {
|
|
1073
1110
|
unused.push(partPath);
|
|
1074
1111
|
}
|
|
@@ -1082,8 +1119,17 @@ export async function analyzePptxLayouts(
|
|
|
1082
1119
|
const layouts: AnalyzedLayout[] = [];
|
|
1083
1120
|
const usedNames = new Set<string>();
|
|
1084
1121
|
for (let i = 0; i < orderedLayoutPaths.length; i++) {
|
|
1122
|
+
await cancellationCheckpoint(i, options.signal);
|
|
1085
1123
|
const layoutPath = orderedLayoutPaths[i]!;
|
|
1086
|
-
const extracted = await extractLayout(
|
|
1124
|
+
const extracted = await extractLayout(
|
|
1125
|
+
pkg,
|
|
1126
|
+
layoutPath,
|
|
1127
|
+
sldSz,
|
|
1128
|
+
pxPerPt,
|
|
1129
|
+
hints,
|
|
1130
|
+
masterCache,
|
|
1131
|
+
options.signal,
|
|
1132
|
+
);
|
|
1087
1133
|
if (!extracted) {
|
|
1088
1134
|
warnings.push(`layout ${layoutPath} could not be read; its slides import plain`);
|
|
1089
1135
|
continue;
|
|
@@ -1108,7 +1154,10 @@ export async function analyzePptxLayouts(
|
|
|
1108
1154
|
const customs = layouts.filter((l) => l.verdict.kind === 'custom');
|
|
1109
1155
|
if (customs.length > maxTemplates) {
|
|
1110
1156
|
const ranked = [...customs].sort((a, b) => b.extracted.slideCount - a.extracted.slideCount);
|
|
1111
|
-
|
|
1157
|
+
const excess = ranked.slice(maxTemplates);
|
|
1158
|
+
for (let index = 0; index < excess.length; index++) {
|
|
1159
|
+
await cancellationCheckpoint(index, options.signal);
|
|
1160
|
+
const layout = excess[index]!;
|
|
1112
1161
|
layout.verdict = { kind: 'plain' };
|
|
1113
1162
|
layout.notes.push('dropped: more inferred layouts than the template cap');
|
|
1114
1163
|
}
|
|
@@ -1118,6 +1167,7 @@ export async function analyzePptxLayouts(
|
|
|
1118
1167
|
}
|
|
1119
1168
|
|
|
1120
1169
|
const byLayoutPath = new Map(layouts.map((l) => [l.extracted.partPath, l]));
|
|
1170
|
+
throwIfOoxmlAborted(options.signal);
|
|
1121
1171
|
return { slideSize: sldSz, layouts, byLayoutPath, layoutPathBySlide, warnings };
|
|
1122
1172
|
}
|
|
1123
1173
|
|
|
@@ -1148,11 +1198,15 @@ export async function inspectPptxLayouts(
|
|
|
1148
1198
|
data: ArrayBuffer | Blob,
|
|
1149
1199
|
options: InspectPptxLayoutsOptions = {},
|
|
1150
1200
|
): Promise<{ layouts: PptxLayoutSummary[]; slideSize: { cx: number; cy: number } }> {
|
|
1201
|
+
throwIfOoxmlAborted(options.signal);
|
|
1151
1202
|
const pkg = await openPackage(data, options);
|
|
1152
1203
|
const analysis = await analyzePptxLayouts(pkg, options);
|
|
1153
|
-
const layouts
|
|
1204
|
+
const layouts: PptxLayoutSummary[] = [];
|
|
1205
|
+
for (let i = 0; i < analysis.layouts.length; i++) {
|
|
1206
|
+
await cancellationCheckpoint(i, options.signal);
|
|
1207
|
+
const l = analysis.layouts[i]!;
|
|
1154
1208
|
const { extracted, verdict, notes } = l;
|
|
1155
|
-
|
|
1209
|
+
layouts.push({
|
|
1156
1210
|
layoutPath: extracted.partPath,
|
|
1157
1211
|
name: extracted.name || `Layout ${i + 1}`,
|
|
1158
1212
|
...(extracted.masterName ? { masterName: extracted.masterName } : {}),
|
|
@@ -1162,7 +1216,7 @@ export async function inspectPptxLayouts(
|
|
|
1162
1216
|
...(verdict.kind === 'builtin' ? { builtinTemplate: verdict.template } : {}),
|
|
1163
1217
|
...(verdict.kind === 'custom' ? { customTemplate: verdict.def } : {}),
|
|
1164
1218
|
...(notes.length > 0 ? { notes } : {}),
|
|
1165
|
-
};
|
|
1166
|
-
}
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1167
1221
|
return { layouts, slideSize: analysis.slideSize };
|
|
1168
1222
|
}
|