@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.
Files changed (82) hide show
  1. package/dist/{chunk-CX3LH47J.js → chunk-74GO3FVS.js} +3 -3
  2. package/dist/{chunk-4HJ6AK2Q.js → chunk-7ARKUCQT.js} +52 -13
  3. package/dist/chunk-7ARKUCQT.js.map +1 -0
  4. package/dist/{chunk-LXYLOOST.js → chunk-DTDF6QDP.js} +3 -3
  5. package/dist/chunk-DTDF6QDP.js.map +1 -0
  6. package/dist/{chunk-ZZ7D4DN6.js → chunk-EW54IRRS.js} +39 -9
  7. package/dist/chunk-EW54IRRS.js.map +1 -0
  8. package/dist/{chunk-H7T5DXUU.js → chunk-FE6OJV6O.js} +95 -14
  9. package/dist/chunk-FE6OJV6O.js.map +1 -0
  10. package/dist/{chunk-XTLQXR4U.js → chunk-KURGXM4I.js} +3 -3
  11. package/dist/{chunk-2CSZJEAL.js → chunk-NNHKUXKA.js} +292 -70
  12. package/dist/chunk-NNHKUXKA.js.map +1 -0
  13. package/dist/{chunk-2PRVLAU3.js → chunk-QFLDYKCR.js} +55 -17
  14. package/dist/chunk-QFLDYKCR.js.map +1 -0
  15. package/dist/{chunk-CVNVD7VH.js → chunk-RLU7UFYU.js} +5 -5
  16. package/dist/{chunk-X4USWOQ2.js → chunk-SC67HYQJ.js} +2 -2
  17. package/dist/{chunk-HBSCRS5P.js → chunk-UGYF5AZE.js} +3 -3
  18. package/dist/{chunk-2EFDWKC6.js → chunk-WQSHGBLN.js} +86 -23
  19. package/dist/chunk-WQSHGBLN.js.map +1 -0
  20. package/dist/container/index.d.ts +10 -4
  21. package/dist/container/index.js +9 -4
  22. package/dist/container/index.js.map +1 -1
  23. package/dist/docx/index.d.ts +2 -2
  24. package/dist/docx/index.js +5 -4
  25. package/dist/{export-Djev9BLU.d.ts → export-D2NkylDT.d.ts} +1 -1
  26. package/dist/{extract-LNOA7DBK.js → extract-OJ7ZQV6P.js} +5 -5
  27. package/dist/html/index.js +1 -1
  28. package/dist/{import-Yivc3kZM.d.ts → import-K8mfc0fz.d.ts} +1 -1
  29. package/dist/index.d.ts +8 -7
  30. package/dist/index.js +15 -13
  31. package/dist/infer/index.d.ts +4 -4
  32. package/dist/infer/index.js +5 -5
  33. package/dist/{layouts-7OUUVAGU.js → layouts-5VDIRPIJ.js} +4 -4
  34. package/dist/{layouts-CZ0bdsNL.d.ts → layouts-BHrgZ5FS.d.ts} +1 -1
  35. package/dist/ooxml/index.d.ts +4 -4
  36. package/dist/ooxml/index.js +4 -4
  37. package/dist/pptx/index.d.ts +5 -5
  38. package/dist/pptx/index.js +5 -5
  39. package/dist/{reader-Gof6OrJX.d.ts → reader-B9L8Ucbj.d.ts} +3 -3
  40. package/dist/registry/index.d.ts +20 -6
  41. package/dist/registry/index.js +6 -4
  42. package/dist/{themeReader-DPw4J5Ss.d.ts → themeReader-DJKErl_j.d.ts} +1 -1
  43. package/dist/xlsx/index.d.ts +4 -4
  44. package/dist/xlsx/index.js +4 -4
  45. package/dist/{zipLimits-bGYMS9cI.d.ts → zipLimits-BOKCB7qk.d.ts} +2 -0
  46. package/package.json +2 -2
  47. package/src/__tests__/convert.test.ts +143 -1
  48. package/src/__tests__/docxExport.test.ts +34 -0
  49. package/src/__tests__/docxImport.test.ts +121 -0
  50. package/src/__tests__/html.test.ts +4 -0
  51. package/src/__tests__/ooxml.test.ts +17 -0
  52. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  53. package/src/container/index.ts +17 -3
  54. package/src/docx/export.ts +113 -5
  55. package/src/docx/import.ts +324 -69
  56. package/src/html/htmlTemplate.ts +2 -2
  57. package/src/index.ts +3 -0
  58. package/src/infer/index.ts +35 -4
  59. package/src/ooxml/namespaces.ts +21 -0
  60. package/src/ooxml/reader.ts +55 -14
  61. package/src/pptx/layouts.ts +68 -14
  62. package/src/registry/convert.ts +115 -31
  63. package/src/registry/defaultFormats.ts +5 -2
  64. package/src/registry/index.ts +3 -1
  65. package/src/registry/types.ts +18 -1
  66. package/src/shared/boundedZipArchive.ts +49 -8
  67. package/src/shared/zipEntryCount.ts +59 -5
  68. package/src/shared/zipLimits.ts +10 -0
  69. package/dist/chunk-2CSZJEAL.js.map +0 -1
  70. package/dist/chunk-2EFDWKC6.js.map +0 -1
  71. package/dist/chunk-2PRVLAU3.js.map +0 -1
  72. package/dist/chunk-4HJ6AK2Q.js.map +0 -1
  73. package/dist/chunk-H7T5DXUU.js.map +0 -1
  74. package/dist/chunk-LXYLOOST.js.map +0 -1
  75. package/dist/chunk-ZZ7D4DN6.js.map +0 -1
  76. /package/dist/{chunk-CX3LH47J.js.map → chunk-74GO3FVS.js.map} +0 -0
  77. /package/dist/{chunk-XTLQXR4U.js.map → chunk-KURGXM4I.js.map} +0 -0
  78. /package/dist/{chunk-CVNVD7VH.js.map → chunk-RLU7UFYU.js.map} +0 -0
  79. /package/dist/{chunk-X4USWOQ2.js.map → chunk-SC67HYQJ.js.map} +0 -0
  80. /package/dist/{chunk-HBSCRS5P.js.map → chunk-UGYF5AZE.js.map} +0 -0
  81. /package/dist/{extract-LNOA7DBK.js.map → extract-OJ7ZQV6P.js.map} +0 -0
  82. /package/dist/{layouts-7OUUVAGU.js.map → layouts-5VDIRPIJ.js.map} +0 -0
@@ -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
- if (await looksLikePdf(data)) {
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
  }
@@ -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
 
@@ -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 an xmldom
9
- * fallback in bare Node/SSR) to parse XML.
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 bare Node. Browser DOMParser is preferred; the
34
- * pure-JS xmldom implementation is the package-owned fallback for Node/SSR.
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
- const nativeParser = (globalThis as { DOMParser?: DomParserLike }).DOMParser;
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(archive: BoundedZipArchive): Promise<ContentTypeMap> {
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
- return {
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
  }
@@ -24,7 +24,12 @@ import type {
24
24
  TextLayer,
25
25
  TextStyle,
26
26
  } from '@bendyline/squisq/schemas';
27
- import { getPartRelationships, getPartXml, openPackage } from '../ooxml/reader.js';
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(pkg: OoxmlPackage, masterPath: string): Promise<MasterIndex> {
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(pkg: OoxmlPackage): Promise<{ cx: number; cy: number }> {
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(pkg: OoxmlPackage): Promise<string[]> {
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 (const slidePath of slidePaths) {
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(pkg, layoutPath, sldSz, pxPerPt, hints, masterCache);
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
- for (const layout of ranked.slice(maxTemplates)) {
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 = analysis.layouts.map((l, i): PptxLayoutSummary => {
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
- return {
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
  }