@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.
Files changed (140) hide show
  1. package/README.md +48 -2
  2. package/dist/chunk-2KPARF2P.js +99 -0
  3. package/dist/chunk-2KPARF2P.js.map +1 -0
  4. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  5. package/dist/chunk-4VUWTSGM.js.map +1 -0
  6. package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
  7. package/dist/chunk-ABVI556T.js.map +1 -0
  8. package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
  9. package/dist/chunk-CRAVSMPZ.js.map +1 -0
  10. package/dist/chunk-FE6OJV6O.js +560 -0
  11. package/dist/chunk-FE6OJV6O.js.map +1 -0
  12. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  13. package/dist/chunk-GVS2XXV6.js.map +1 -0
  14. package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
  15. package/dist/chunk-HTW2M27H.js.map +1 -0
  16. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  17. package/dist/chunk-LXYLOOST.js.map +1 -0
  18. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  19. package/dist/chunk-MLX2BOJC.js.map +1 -0
  20. package/dist/chunk-NKAJPJ4G.js +106 -0
  21. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  22. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  23. package/dist/chunk-ODL3SSPT.js +18 -0
  24. package/dist/chunk-ODL3SSPT.js.map +1 -0
  25. package/dist/chunk-QRVN6A6E.js +127 -0
  26. package/dist/chunk-QRVN6A6E.js.map +1 -0
  27. package/dist/chunk-U32AG3G3.js +195 -0
  28. package/dist/chunk-U32AG3G3.js.map +1 -0
  29. package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
  30. package/dist/chunk-VJJM2SSH.js.map +1 -0
  31. package/dist/chunk-VSYHZECT.js +778 -0
  32. package/dist/chunk-VSYHZECT.js.map +1 -0
  33. package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
  34. package/dist/chunk-WC7WULGV.js.map +1 -0
  35. package/dist/chunk-XKUMNGBW.js +131 -0
  36. package/dist/chunk-XKUMNGBW.js.map +1 -0
  37. package/dist/chunk-YRT7GQ5Y.js +28 -0
  38. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  39. package/dist/container/index.d.ts +13 -3
  40. package/dist/container/index.js +23 -20
  41. package/dist/container/index.js.map +1 -1
  42. package/dist/docx/index.d.ts +6 -2
  43. package/dist/docx/index.js +5 -3
  44. package/dist/epub/index.d.ts +3 -1
  45. package/dist/epub/index.js +3 -3
  46. package/dist/export-D2NkylDT.d.ts +69 -0
  47. package/dist/extract-H6RXJMHP.js +15 -0
  48. package/dist/extract-H6RXJMHP.js.map +1 -0
  49. package/dist/html/index.d.ts +15 -85
  50. package/dist/html/index.js +3 -3
  51. package/dist/images-7FBWPKE3.js +7 -0
  52. package/dist/images-7FBWPKE3.js.map +1 -0
  53. package/dist/import-DTkDxHmZ.d.ts +84 -0
  54. package/dist/import-K8mfc0fz.d.ts +136 -0
  55. package/dist/index.d.ts +14 -4
  56. package/dist/index.js +33 -12
  57. package/dist/infer/index.d.ts +138 -0
  58. package/dist/infer/index.js +28 -0
  59. package/dist/infer/index.js.map +1 -0
  60. package/dist/layouts-BHrgZ5FS.d.ts +150 -0
  61. package/dist/layouts-QVPK3ZCU.js +12 -0
  62. package/dist/layouts-QVPK3ZCU.js.map +1 -0
  63. package/dist/mapTheme-IR27S6IV.js +11 -0
  64. package/dist/mapTheme-IR27S6IV.js.map +1 -0
  65. package/dist/ooxml/index.d.ts +21 -127
  66. package/dist/ooxml/index.js +31 -9
  67. package/dist/pdf/index.d.ts +3 -1
  68. package/dist/pdf/index.js +1 -1
  69. package/dist/pptx/index.d.ts +8 -108
  70. package/dist/pptx/index.js +13 -4
  71. package/dist/reader-B9L8Ucbj.d.ts +140 -0
  72. package/dist/registry/index.d.ts +60 -8
  73. package/dist/registry/index.js +9 -4
  74. package/dist/themeReader-DJKErl_j.d.ts +56 -0
  75. package/dist/xlsx/index.d.ts +6 -66
  76. package/dist/xlsx/index.js +4 -2
  77. package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +310 -1
  81. package/src/__tests__/epub.test.ts +102 -1
  82. package/src/__tests__/html.test.ts +62 -0
  83. package/src/__tests__/inferTheme.test.ts +135 -0
  84. package/src/__tests__/lossyWarnings.test.ts +41 -0
  85. package/src/__tests__/ooxml.test.ts +76 -2
  86. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  87. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  88. package/src/__tests__/plainHtml.test.ts +15 -1
  89. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  90. package/src/__tests__/pptxImport.test.ts +30 -0
  91. package/src/__tests__/pptxInferFixtures.ts +314 -0
  92. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  93. package/src/__tests__/zipSafety.test.ts +317 -0
  94. package/src/container/index.ts +37 -32
  95. package/src/docx/export.ts +4 -2
  96. package/src/docx/import.ts +4 -3
  97. package/src/epub/export.ts +94 -25
  98. package/src/html/docsHtmlBundle.ts +6 -2
  99. package/src/html/htmlTemplate.ts +31 -6
  100. package/src/html/imageUtils.ts +14 -5
  101. package/src/html/index.ts +12 -1
  102. package/src/html/plainHtml.ts +7 -3
  103. package/src/html/plainHtmlBundle.ts +11 -9
  104. package/src/index.ts +23 -0
  105. package/src/infer/extract.ts +127 -0
  106. package/src/infer/index.ts +199 -0
  107. package/src/infer/mapTheme.ts +176 -0
  108. package/src/infer/types.ts +27 -0
  109. package/src/ooxml/index.ts +14 -0
  110. package/src/ooxml/readUtils.ts +44 -0
  111. package/src/ooxml/reader.ts +129 -29
  112. package/src/ooxml/themeReader.ts +197 -0
  113. package/src/ooxml/types.ts +8 -9
  114. package/src/ooxml/writer.ts +28 -10
  115. package/src/pdf/export.ts +4 -2
  116. package/src/pdf/import.ts +40 -34
  117. package/src/pptx/export.ts +4 -2
  118. package/src/pptx/import.ts +214 -32
  119. package/src/pptx/index.ts +14 -0
  120. package/src/pptx/layouts.ts +1222 -0
  121. package/src/registry/convert.ts +168 -53
  122. package/src/registry/defaultFormats.ts +135 -45
  123. package/src/registry/index.ts +6 -1
  124. package/src/registry/types.ts +69 -6
  125. package/src/shared/boundedZipArchive.ts +383 -0
  126. package/src/shared/fidelity.ts +130 -0
  127. package/src/shared/zipEntryCount.ts +151 -0
  128. package/src/shared/zipLimits.ts +296 -0
  129. package/src/shared/zipSafety.ts +19 -0
  130. package/src/xlsx/import.ts +3 -2
  131. package/dist/chunk-46FMDOWZ.js.map +0 -1
  132. package/dist/chunk-5LSSWZPU.js.map +0 -1
  133. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  134. package/dist/chunk-EHLGMSTE.js.map +0 -1
  135. package/dist/chunk-MJGRI6XR.js.map +0 -1
  136. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  137. package/dist/chunk-SHLPSADL.js.map +0 -1
  138. package/dist/chunk-WWBIKRNP.js.map +0 -1
  139. package/dist/chunk-YESS7VY3.js.map +0 -1
  140. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -5,12 +5,67 @@
5
5
  * structural metadata: [Content_Types].xml, relationships, and
6
6
  * core properties.
7
7
  *
8
- * Uses JSZip to unzip and the browser's DOMParser 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.
9
10
  */
10
11
 
11
- import JSZip from 'jszip';
12
+ import { DOMParser as XmldomDOMParser } from '@xmldom/xmldom';
12
13
  import type { OoxmlPackage, ContentTypeMap, Relationship, CoreProperties } from './types.js';
13
14
  import { NS_RELATIONSHIPS, NS_DC, NS_DCTERMS, NS_CORE_PROPERTIES } from './namespaces.js';
15
+ import {
16
+ openBoundedZipArchive,
17
+ type BoundedZipArchive,
18
+ type ZipSafetyLimits,
19
+ } from '../shared/zipSafety.js';
20
+
21
+ export type OoxmlOpenOptions = ZipSafetyLimits;
22
+
23
+ type DomParserLike = new () => {
24
+ parseFromString(source: string, mimeType: string): unknown;
25
+ };
26
+
27
+ /** Safe read sessions for opaque packages opened by this module. */
28
+ const packageArchives = new WeakMap<OoxmlPackage, BoundedZipArchive>();
29
+ const packageSignals = new WeakMap<OoxmlPackage, AbortSignal>();
30
+ const MAX_CONTENT_TYPES_BYTES = 1024 * 1024;
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
+ }
47
+
48
+ /**
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.
52
+ */
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;
60
+ const Parser = nativeParser ?? (XmldomDOMParser as unknown as DomParserLike);
61
+ const doc = new Parser().parseFromString(source, 'application/xml') as Document;
62
+ const parserErrors = doc.getElementsByTagName('parsererror');
63
+ if (parserErrors.length > 0) {
64
+ throw new Error(`Invalid OOXML XML part: ${parserErrors[0]?.textContent ?? 'parse error'}`);
65
+ }
66
+ throwIfOoxmlAborted(signal);
67
+ return doc;
68
+ }
14
69
 
15
70
  // ============================================
16
71
  // Package Opening
@@ -22,14 +77,26 @@ import { NS_RELATIONSHIPS, NS_DC, NS_DCTERMS, NS_CORE_PROPERTIES } from './names
22
77
  * Parses the ZIP archive, [Content_Types].xml, and root relationships.
23
78
  *
24
79
  * @param data - The raw .docx/.pptx/.xlsx file as ArrayBuffer or Blob
80
+ * @param limits - Optional archive count, size, and compression-ratio limits
25
81
  * @returns A parsed OoxmlPackage
26
82
  */
27
- export async function openPackage(data: ArrayBuffer | Blob): Promise<OoxmlPackage> {
28
- const zip = await JSZip.loadAsync(data);
29
- const contentTypes = await parseContentTypes(zip);
30
- const rootRelationships = await parseRelationships(zip, '');
83
+ export async function openPackage(
84
+ data: ArrayBuffer | Blob,
85
+ limits: OoxmlOpenOptions = {},
86
+ ): Promise<OoxmlPackage> {
87
+ throwIfOoxmlAborted(limits.signal);
88
+ const archive = await openBoundedZipArchive(data, limits);
89
+ const contentTypes = await parseContentTypes(archive, limits.signal);
90
+ const rootRelationships = await parseRelationships(archive, '', limits.signal);
91
+ throwIfOoxmlAborted(limits.signal);
31
92
 
32
- return { zip, contentTypes, rootRelationships };
93
+ const pkg = {
94
+ contentTypes,
95
+ rootRelationships,
96
+ } as unknown as OoxmlPackage;
97
+ packageArchives.set(pkg, archive);
98
+ if (limits.signal) packageSignals.set(pkg, limits.signal);
99
+ return pkg;
33
100
  }
34
101
 
35
102
  // ============================================
@@ -39,19 +106,21 @@ export async function openPackage(data: ArrayBuffer | Blob): Promise<OoxmlPackag
39
106
  /**
40
107
  * Parse [Content_Types].xml from the archive.
41
108
  */
42
- async function parseContentTypes(zip: JSZip): Promise<ContentTypeMap> {
109
+ async function parseContentTypes(
110
+ archive: BoundedZipArchive,
111
+ signal?: AbortSignal,
112
+ ): Promise<ContentTypeMap> {
43
113
  const overrides = new Map<string, string>();
44
114
  const defaults = new Map<string, string>();
45
115
 
46
- const file = zip.file('[Content_Types].xml');
47
- if (!file) return { overrides, defaults };
48
-
49
- const text = await file.async('text');
50
- const doc = new DOMParser().parseFromString(text, 'application/xml');
116
+ const text = await archive.readText('[Content_Types].xml', MAX_CONTENT_TYPES_BYTES);
117
+ if (text === null) return { overrides, defaults };
118
+ const doc = parseXml(text, signal);
51
119
 
52
120
  // Parse <Default Extension="rels" ContentType="..." />
53
121
  const defaultEls = doc.getElementsByTagName('Default');
54
122
  for (let i = 0; i < defaultEls.length; i++) {
123
+ await cancellationCheckpoint(i, signal);
55
124
  const el = defaultEls[i];
56
125
  const ext = el.getAttribute('Extension');
57
126
  const ct = el.getAttribute('ContentType');
@@ -61,6 +130,7 @@ async function parseContentTypes(zip: JSZip): Promise<ContentTypeMap> {
61
130
  // Parse <Override PartName="/word/document.xml" ContentType="..." />
62
131
  const overrideEls = doc.getElementsByTagName('Override');
63
132
  for (let i = 0; i < overrideEls.length; i++) {
133
+ await cancellationCheckpoint(i, signal);
64
134
  const el = overrideEls[i];
65
135
  const partName = el.getAttribute('PartName');
66
136
  const ct = el.getAttribute('ContentType');
@@ -89,7 +159,7 @@ export async function getPartRelationships(
89
159
  pkg: OoxmlPackage,
90
160
  partPath: string,
91
161
  ): Promise<Relationship[]> {
92
- return parseRelationships(pkg.zip, partPath);
162
+ return parseRelationships(pkg, partPath, packageSignals.get(pkg));
93
163
  }
94
164
 
95
165
  /**
@@ -98,14 +168,17 @@ export async function getPartRelationships(
98
168
  * For root relationships, relsPath = "_rels/.rels".
99
169
  * For part relationships, relsPath = "<dir>/_rels/<filename>.rels".
100
170
  */
101
- async function parseRelationships(zip: JSZip, partPath: string): Promise<Relationship[]> {
171
+ async function parseRelationships(
172
+ source: OoxmlPackage | BoundedZipArchive,
173
+ partPath: string,
174
+ signal?: AbortSignal,
175
+ ): Promise<Relationship[]> {
102
176
  const relsPath = partPath === '' ? '_rels/.rels' : buildRelsPath(partPath);
103
177
 
104
- const file = zip.file(relsPath);
105
- if (!file) return [];
106
-
107
- const text = await file.async('text');
108
- const doc = new DOMParser().parseFromString(text, 'application/xml');
178
+ const bytes = await readArchivePart(source, relsPath, MAX_RELATIONSHIPS_BYTES);
179
+ if (!bytes) return [];
180
+ const text = new TextDecoder().decode(bytes);
181
+ const doc = parseXml(text, signal);
109
182
  const result: Relationship[] = [];
110
183
 
111
184
  const els = doc.getElementsByTagNameNS(NS_RELATIONSHIPS, 'Relationship');
@@ -113,6 +186,7 @@ async function parseRelationships(zip: JSZip, partPath: string): Promise<Relatio
113
186
  const fallbackEls = els.length > 0 ? els : doc.getElementsByTagName('Relationship');
114
187
 
115
188
  for (let i = 0; i < fallbackEls.length; i++) {
189
+ await cancellationCheckpoint(i, signal);
116
190
  const el = fallbackEls[i];
117
191
  const id = el.getAttribute('Id');
118
192
  const type = el.getAttribute('Type');
@@ -159,11 +233,10 @@ function buildRelsPath(partPath: string): string {
159
233
  * @returns Parsed XML Document, or null if the part doesn't exist
160
234
  */
161
235
  export async function getPartXml(pkg: OoxmlPackage, partPath: string): Promise<Document | null> {
162
- const file = pkg.zip.file(partPath);
163
- if (!file) return null;
164
-
165
- const text = await file.async('text');
166
- return new DOMParser().parseFromString(text, 'application/xml');
236
+ const signal = packageSignals.get(pkg);
237
+ throwIfOoxmlAborted(signal);
238
+ const bytes = await readPackagePart(pkg, partPath);
239
+ return bytes ? parseXml(new TextDecoder().decode(bytes), signal) : null;
167
240
  }
168
241
 
169
242
  /**
@@ -177,9 +250,32 @@ export async function getPartBinary(
177
250
  pkg: OoxmlPackage,
178
251
  partPath: string,
179
252
  ): Promise<ArrayBuffer | null> {
180
- const file = pkg.zip.file(partPath);
181
- if (!file) return null;
182
- return file.async('arraybuffer');
253
+ throwIfOoxmlAborted(packageSignals.get(pkg));
254
+ const bytes = await readPackagePart(pkg, partPath);
255
+ return bytes ? bytes.slice().buffer : null;
256
+ }
257
+
258
+ function readArchivePart(
259
+ source: OoxmlPackage | BoundedZipArchive,
260
+ path: string,
261
+ maxBytes?: number,
262
+ ): Promise<Uint8Array | null> {
263
+ return 'entries' in source
264
+ ? source.read(path, maxBytes)
265
+ : readPackagePart(source, path, maxBytes);
266
+ }
267
+
268
+ async function readPackagePart(
269
+ pkg: OoxmlPackage,
270
+ path: string,
271
+ maxBytes?: number,
272
+ ): Promise<Uint8Array | null> {
273
+ throwIfOoxmlAborted(packageSignals.get(pkg));
274
+ const archive = packageArchives.get(pkg);
275
+ if (!archive) {
276
+ throw new TypeError('Invalid OoxmlPackage: create packages with openPackage().');
277
+ }
278
+ return archive.read(path, maxBytes);
183
279
  }
184
280
 
185
281
  // ============================================
@@ -193,6 +289,8 @@ export async function getPartBinary(
193
289
  * @returns Parsed core properties (all fields optional)
194
290
  */
195
291
  export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperties> {
292
+ const signal = packageSignals.get(pkg);
293
+ throwIfOoxmlAborted(signal);
196
294
  const doc = await getPartXml(pkg, 'docProps/core.xml');
197
295
  if (!doc) return {};
198
296
 
@@ -204,7 +302,7 @@ export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperti
204
302
  return undefined;
205
303
  }
206
304
 
207
- return {
305
+ const properties = {
208
306
  title: getText(NS_DC, 'title'),
209
307
  subject: getText(NS_DC, 'subject'),
210
308
  creator: getText(NS_DC, 'creator'),
@@ -215,4 +313,6 @@ export async function getCoreProperties(pkg: OoxmlPackage): Promise<CoreProperti
215
313
  created: getText(NS_DCTERMS, 'created'),
216
314
  modified: getText(NS_DCTERMS, 'modified'),
217
315
  };
316
+ throwIfOoxmlAborted(signal);
317
+ return properties;
218
318
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * OOXML theme reader — parses a `theme1.xml` part (DrawingML `a:theme`)
3
+ * into normalized color and font schemes.
4
+ *
5
+ * Shared by DOCX/PPTX/XLSX theme inference: all three formats carry the same
6
+ * theme part shape, differing only in where the part hangs (document/master/
7
+ * workbook relationships). This module never fails hard — unreadable slots
8
+ * degrade to fallbacks or absence, with a warning per degradation.
9
+ */
10
+
11
+ import type { OoxmlPackage } from './types.js';
12
+ import { getPartRelationships, getPartXml } from './reader.js';
13
+ import { NS_DRAWINGML, REL_THEME } from './namespaces.js';
14
+ import { baseDirOf, findRelByType, resolveTarget } from './readUtils.js';
15
+
16
+ /**
17
+ * The clrScheme slots as normalized `#rrggbb` strings. The four surface/text
18
+ * slots always resolve (falling back to black/white when degraded); accent and
19
+ * hyperlink slots are omitted when unresolvable.
20
+ */
21
+ export interface OoxmlColorScheme {
22
+ dk1: string;
23
+ lt1: string;
24
+ dk2: string;
25
+ lt2: string;
26
+ accent1?: string;
27
+ accent2?: string;
28
+ accent3?: string;
29
+ accent4?: string;
30
+ accent5?: string;
31
+ accent6?: string;
32
+ hlink?: string;
33
+ folHlink?: string;
34
+ }
35
+
36
+ export interface OoxmlFontScheme {
37
+ /** `a:fontScheme > a:majorFont > a:latin@typeface` (headings). */
38
+ majorLatin?: string;
39
+ /** `a:fontScheme > a:minorFont > a:latin@typeface` (body). */
40
+ minorLatin?: string;
41
+ }
42
+
43
+ export interface OoxmlTheme {
44
+ /** `a:theme@name` (e.g. "Office Theme", "Ion"). */
45
+ name?: string;
46
+ /** Absent when the theme carries no parseable `a:clrScheme`. */
47
+ colors?: OoxmlColorScheme;
48
+ /** Absent when the theme carries no `a:fontScheme` latin typefaces. */
49
+ fonts?: OoxmlFontScheme;
50
+ warnings: string[];
51
+ }
52
+
53
+ const HEX6_RE = /^[0-9a-fA-F]{6}$/;
54
+
55
+ /** Well-known sysClr values that resolve without a `lastClr` hint. */
56
+ const SYS_COLOR_DEFAULTS: Record<string, string> = {
57
+ windowText: '000000',
58
+ window: 'ffffff',
59
+ };
60
+
61
+ /**
62
+ * Resolve one clrScheme slot element to a `#rrggbb` string, or null.
63
+ * Only the base color matters for theming — trailing transform children
64
+ * (`a:lumMod`, `a:tint`, …) are ignored.
65
+ */
66
+ function resolveSchemeColor(slot: Element): string | null {
67
+ for (let i = 0; i < slot.childNodes.length; i++) {
68
+ const child = slot.childNodes[i];
69
+ if (!child || child.nodeType !== 1) continue;
70
+ const el = child as Element;
71
+ if (el.localName === 'srgbClr') {
72
+ const val = el.getAttribute('val');
73
+ return val && HEX6_RE.test(val) ? `#${val.toLowerCase()}` : null;
74
+ }
75
+ if (el.localName === 'sysClr') {
76
+ const lastClr = el.getAttribute('lastClr');
77
+ if (lastClr && HEX6_RE.test(lastClr)) return `#${lastClr.toLowerCase()}`;
78
+ const val = el.getAttribute('val');
79
+ const known = val ? SYS_COLOR_DEFAULTS[val] : undefined;
80
+ return known ? `#${known}` : null;
81
+ }
82
+ // Any other color form (schemeClr self-reference, prstClr, …) is
83
+ // unresolvable without a full DrawingML color pipeline.
84
+ return null;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function firstNS(parent: Element | Document, local: string): Element | null {
90
+ const els = parent.getElementsByTagNameNS(NS_DRAWINGML, local);
91
+ return els.length > 0 ? els[0]! : null;
92
+ }
93
+
94
+ function parseColorScheme(clrScheme: Element, warnings: string[]): OoxmlColorScheme {
95
+ const resolve = (slotName: string): string | null => {
96
+ const slot = firstNS(clrScheme, slotName);
97
+ if (!slot) return null;
98
+ return resolveSchemeColor(slot);
99
+ };
100
+
101
+ const dk1 = resolve('dk1');
102
+ const lt1 = resolve('lt1');
103
+ if (!dk1) warnings.push('theme: dk1 color could not be resolved; using #000000');
104
+ if (!lt1) warnings.push('theme: lt1 color could not be resolved; using #ffffff');
105
+
106
+ const dk2 = resolve('dk2');
107
+ const lt2 = resolve('lt2');
108
+
109
+ const colors: OoxmlColorScheme = {
110
+ dk1: dk1 ?? '#000000',
111
+ lt1: lt1 ?? '#ffffff',
112
+ dk2: dk2 ?? dk1 ?? '#000000',
113
+ lt2: lt2 ?? lt1 ?? '#ffffff',
114
+ };
115
+
116
+ for (const slot of ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6'] as const) {
117
+ const hex = resolve(slot);
118
+ if (hex) colors[slot] = hex;
119
+ else warnings.push(`theme: ${slot} color could not be resolved; dropped`);
120
+ }
121
+ for (const slot of ['hlink', 'folHlink'] as const) {
122
+ const hex = resolve(slot);
123
+ if (hex) colors[slot] = hex;
124
+ }
125
+ return colors;
126
+ }
127
+
128
+ function parseFontScheme(fontScheme: Element, warnings: string[]): OoxmlFontScheme | undefined {
129
+ const readLatin = (fontName: 'majorFont' | 'minorFont'): string | undefined => {
130
+ const font = firstNS(fontScheme, fontName);
131
+ if (!font) return undefined;
132
+ const latin = firstNS(font, 'latin');
133
+ const typeface = latin?.getAttribute('typeface')?.trim();
134
+ if (!typeface) {
135
+ warnings.push(`theme: ${fontName} has no latin typeface`);
136
+ return undefined;
137
+ }
138
+ return typeface;
139
+ };
140
+
141
+ const majorLatin = readLatin('majorFont');
142
+ const minorLatin = readLatin('minorFont');
143
+ if (!majorLatin && !minorLatin) return undefined;
144
+ return {
145
+ ...(majorLatin ? { majorLatin } : {}),
146
+ ...(minorLatin ? { minorLatin } : {}),
147
+ };
148
+ }
149
+
150
+ /** Parse an already-loaded theme part Document. */
151
+ export function parseThemeXml(themeDoc: Document): OoxmlTheme {
152
+ const warnings: string[] = [];
153
+
154
+ const root = firstNS(themeDoc, 'theme');
155
+ const name = root?.getAttribute('name')?.trim() || undefined;
156
+
157
+ const clrScheme = firstNS(themeDoc, 'clrScheme');
158
+ let colors: OoxmlColorScheme | undefined;
159
+ if (clrScheme) {
160
+ colors = parseColorScheme(clrScheme, warnings);
161
+ } else {
162
+ warnings.push('theme: no clrScheme found');
163
+ }
164
+
165
+ const fontScheme = firstNS(themeDoc, 'fontScheme');
166
+ let fonts: OoxmlFontScheme | undefined;
167
+ if (fontScheme) {
168
+ fonts = parseFontScheme(fontScheme, warnings);
169
+ } else {
170
+ warnings.push('theme: no fontScheme found');
171
+ }
172
+
173
+ return {
174
+ ...(name ? { name } : {}),
175
+ ...(colors ? { colors } : {}),
176
+ ...(fonts ? { fonts } : {}),
177
+ warnings,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Resolve the theme part related from `fromPartPath` (via `REL_THEME`) and
183
+ * parse it. Returns null when the part has no theme relationship or the
184
+ * target part is missing — callers decide whether that's an error.
185
+ */
186
+ export async function readThemePart(
187
+ pkg: OoxmlPackage,
188
+ fromPartPath: string,
189
+ ): Promise<OoxmlTheme | null> {
190
+ const rels = await getPartRelationships(pkg, fromPartPath);
191
+ const rel = findRelByType(rels, REL_THEME);
192
+ if (!rel) return null;
193
+ const themePath = resolveTarget(baseDirOf(fromPartPath), rel.target);
194
+ const doc = await getPartXml(pkg, themePath);
195
+ if (!doc) return null;
196
+ return parseThemeXml(doc);
197
+ }
@@ -3,26 +3,25 @@
3
3
  *
4
4
  * Shared type definitions for all Office Open XML formats (DOCX, PPTX, XLSX).
5
5
  * These model the common structural elements of the OOXML package format:
6
- * ZIP archive, relationships, content types, and core properties.
6
+ * relationships, content types, and core properties.
7
7
  */
8
8
 
9
- import type JSZip from 'jszip';
10
-
11
9
  // ============================================
12
10
  // Package
13
11
  // ============================================
14
12
 
15
13
  /**
16
- * An opened OOXML package wraps the JSZip archive plus parsed
17
- * structural metadata (content types, root relationships).
14
+ * An opened OOXML package. Instances are created only by `openPackage`; the
15
+ * archive remains private so every part read goes through the bounded reader.
18
16
  */
17
+ declare const ooxmlPackageBrand: unique symbol;
19
18
  export interface OoxmlPackage {
20
- /** The underlying JSZip archive */
21
- zip: JSZip;
19
+ /** Nominal brand: callers cannot manually construct a safety-bypassing package. */
20
+ readonly [ooxmlPackageBrand]: never;
22
21
  /** Parsed [Content_Types].xml entries */
23
- contentTypes: ContentTypeMap;
22
+ readonly contentTypes: ContentTypeMap;
24
23
  /** Root-level relationships (_rels/.rels) */
25
- rootRelationships: Relationship[];
24
+ readonly rootRelationships: readonly Relationship[];
26
25
  }
27
26
 
28
27
  /**
@@ -20,6 +20,7 @@ import {
20
20
  REL_CORE_PROPERTIES,
21
21
  } from './namespaces.js';
22
22
  import { xmlDeclaration, escapeXml } from './xmlUtils.js';
23
+ import { assertSafeZipPath } from '../shared/zipSafety.js';
23
24
 
24
25
  // ============================================
25
26
  // Package Builder
@@ -99,14 +100,17 @@ export function createPackage(): OoxmlPackageBuilder {
99
100
 
100
101
  return {
101
102
  addPart(path, content, contentType) {
103
+ assertSafeZipPath(path);
102
104
  parts.push({ path, content, contentType });
103
105
  },
104
106
 
105
107
  addBinaryPart(path, data, contentType) {
108
+ assertSafeZipPath(path);
106
109
  parts.push({ path, binaryContent: data, contentType });
107
110
  },
108
111
 
109
112
  addRelationship(sourcePart, rel) {
113
+ if (sourcePart) assertSafeZipPath(sourcePart);
110
114
  relationships.push({ sourcePart, relationship: rel });
111
115
  },
112
116
 
@@ -139,6 +143,10 @@ function assemble(
139
143
  coreProps?: CoreProperties,
140
144
  ): JSZip {
141
145
  const zip = new JSZip();
146
+ // Assembly must be idempotent: callers may request both Blob and
147
+ // ArrayBuffer representations from one builder. Never append generated
148
+ // relationships to the builder-owned array.
149
+ const assembledRelationships = relationships.slice();
142
150
 
143
151
  // Write content parts
144
152
  for (const part of parts) {
@@ -154,22 +162,32 @@ function assemble(
154
162
  const coreXml = buildCorePropertiesXml(coreProps);
155
163
  zip.file('docProps/core.xml', coreXml);
156
164
 
157
- // Add relationship to core properties
158
- relationships.push({
159
- sourcePart: '',
160
- relationship: {
161
- id: `rId${relationships.length + 100}`,
162
- type: REL_CORE_PROPERTIES,
163
- target: 'docProps/core.xml',
164
- },
165
- });
165
+ // Add the generated relationship unless the caller already supplied it.
166
+ if (
167
+ !assembledRelationships.some(
168
+ ({ sourcePart, relationship }) =>
169
+ sourcePart === '' && relationship.type === REL_CORE_PROPERTIES,
170
+ )
171
+ ) {
172
+ const ids = new Set(assembledRelationships.map(({ relationship }) => relationship.id));
173
+ let nextId = 100;
174
+ while (ids.has(`rId${nextId}`)) nextId++;
175
+ assembledRelationships.push({
176
+ sourcePart: '',
177
+ relationship: {
178
+ id: `rId${nextId}`,
179
+ type: REL_CORE_PROPERTIES,
180
+ target: 'docProps/core.xml',
181
+ },
182
+ });
183
+ }
166
184
  }
167
185
 
168
186
  // Build and write [Content_Types].xml
169
187
  zip.file('[Content_Types].xml', buildContentTypesXml(parts, coreProps));
170
188
 
171
189
  // Build and write _rels/*.rels files
172
- const relsBySource = groupRelationshipsBySource(relationships);
190
+ const relsBySource = groupRelationshipsBySource(assembledRelationships);
173
191
  for (const [sourcePart, rels] of relsBySource) {
174
192
  const relsPath = sourcePart === '' ? '_rels/.rels' : buildRelsPath(sourcePart);
175
193
  zip.file(relsPath, buildRelationshipsXml(rels));
package/src/pdf/export.ts CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { PDFDocument, StandardFonts, rgb, PDFFont, PDFPage } from 'pdf-lib';
23
23
 
24
- import type { Doc } from '@bendyline/squisq/schemas';
24
+ import type { Doc, ThemeRegistry } from '@bendyline/squisq/schemas';
25
25
  import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
26
26
  import type {
27
27
  MarkdownDocument,
@@ -110,6 +110,8 @@ export interface PdfExportOptions {
110
110
  * Font changes are not supported (pdf-lib uses standard 14 PDF fonts only).
111
111
  */
112
112
  themeId?: string;
113
+ /** Explicit caller-owned registry for non-document custom themes. */
114
+ themeRegistry?: ThemeRegistry;
113
115
  }
114
116
 
115
117
  /**
@@ -219,7 +221,7 @@ async function createExportContext(
219
221
  // including inline custom themes (resolved doc-scoped).
220
222
  const themeId = options.themeId ?? readFrontmatterThemeId(doc.frontmatter);
221
223
  if (themeId) {
222
- const theme = resolveThemeForDoc(doc, themeId);
224
+ const theme = resolveThemeForDoc(doc, themeId, options.themeRegistry);
223
225
  if (theme.colors) {
224
226
  colorText = hexToRgb(theme.colors.text) ?? COLOR_TEXT;
225
227
  colorHeading = hexToRgb(theme.colors.primary) ?? COLOR_HEADING;