@bendyline/squisq-formats 1.4.2 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/chunk-2KPARF2P.js +99 -0
- package/dist/chunk-2KPARF2P.js.map +1 -0
- package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
- package/dist/chunk-4VUWTSGM.js.map +1 -0
- package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
- package/dist/chunk-ABVI556T.js.map +1 -0
- package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
- package/dist/chunk-CRAVSMPZ.js.map +1 -0
- package/dist/chunk-FE6OJV6O.js +560 -0
- package/dist/chunk-FE6OJV6O.js.map +1 -0
- package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
- package/dist/chunk-GVS2XXV6.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
- package/dist/chunk-HTW2M27H.js.map +1 -0
- package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
- package/dist/chunk-LXYLOOST.js.map +1 -0
- package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
- package/dist/chunk-MLX2BOJC.js.map +1 -0
- package/dist/chunk-NKAJPJ4G.js +106 -0
- package/dist/chunk-NKAJPJ4G.js.map +1 -0
- package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
- package/dist/chunk-ODL3SSPT.js +18 -0
- package/dist/chunk-ODL3SSPT.js.map +1 -0
- package/dist/chunk-QRVN6A6E.js +127 -0
- package/dist/chunk-QRVN6A6E.js.map +1 -0
- package/dist/chunk-U32AG3G3.js +195 -0
- package/dist/chunk-U32AG3G3.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
- package/dist/chunk-VJJM2SSH.js.map +1 -0
- package/dist/chunk-VSYHZECT.js +778 -0
- package/dist/chunk-VSYHZECT.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
- package/dist/chunk-WC7WULGV.js.map +1 -0
- package/dist/chunk-XKUMNGBW.js +131 -0
- package/dist/chunk-XKUMNGBW.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/container/index.d.ts +13 -3
- package/dist/container/index.js +23 -20
- package/dist/container/index.js.map +1 -1
- package/dist/docx/index.d.ts +6 -2
- package/dist/docx/index.js +5 -3
- package/dist/epub/index.d.ts +3 -1
- package/dist/epub/index.js +3 -3
- package/dist/export-D2NkylDT.d.ts +69 -0
- package/dist/extract-H6RXJMHP.js +15 -0
- package/dist/extract-H6RXJMHP.js.map +1 -0
- package/dist/html/index.d.ts +15 -85
- package/dist/html/index.js +3 -3
- package/dist/images-7FBWPKE3.js +7 -0
- package/dist/images-7FBWPKE3.js.map +1 -0
- package/dist/import-DTkDxHmZ.d.ts +84 -0
- package/dist/import-K8mfc0fz.d.ts +136 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +33 -12
- package/dist/infer/index.d.ts +138 -0
- package/dist/infer/index.js +28 -0
- package/dist/infer/index.js.map +1 -0
- package/dist/layouts-BHrgZ5FS.d.ts +150 -0
- package/dist/layouts-QVPK3ZCU.js +12 -0
- package/dist/layouts-QVPK3ZCU.js.map +1 -0
- package/dist/mapTheme-IR27S6IV.js +11 -0
- package/dist/mapTheme-IR27S6IV.js.map +1 -0
- package/dist/ooxml/index.d.ts +21 -127
- package/dist/ooxml/index.js +31 -9
- package/dist/pdf/index.d.ts +3 -1
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.d.ts +8 -108
- package/dist/pptx/index.js +13 -4
- package/dist/reader-B9L8Ucbj.d.ts +140 -0
- package/dist/registry/index.d.ts +60 -8
- package/dist/registry/index.js +9 -4
- package/dist/themeReader-DJKErl_j.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +310 -1
- package/src/__tests__/epub.test.ts +102 -1
- package/src/__tests__/html.test.ts +62 -0
- package/src/__tests__/inferTheme.test.ts +135 -0
- package/src/__tests__/lossyWarnings.test.ts +41 -0
- package/src/__tests__/ooxml.test.ts +76 -2
- package/src/__tests__/ooxmlCancellation.test.ts +113 -0
- package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
- package/src/__tests__/plainHtml.test.ts +15 -1
- package/src/__tests__/plainHtmlBundle.test.ts +18 -0
- package/src/__tests__/pptxImport.test.ts +30 -0
- package/src/__tests__/pptxInferFixtures.ts +314 -0
- package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
- package/src/__tests__/zipSafety.test.ts +317 -0
- package/src/container/index.ts +37 -32
- package/src/docx/export.ts +4 -2
- package/src/docx/import.ts +4 -3
- package/src/epub/export.ts +94 -25
- package/src/html/docsHtmlBundle.ts +6 -2
- package/src/html/htmlTemplate.ts +31 -6
- package/src/html/imageUtils.ts +14 -5
- package/src/html/index.ts +12 -1
- package/src/html/plainHtml.ts +7 -3
- package/src/html/plainHtmlBundle.ts +11 -9
- package/src/index.ts +23 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +199 -0
- package/src/infer/mapTheme.ts +176 -0
- package/src/infer/types.ts +27 -0
- package/src/ooxml/index.ts +14 -0
- package/src/ooxml/readUtils.ts +44 -0
- package/src/ooxml/reader.ts +129 -29
- package/src/ooxml/themeReader.ts +197 -0
- package/src/ooxml/types.ts +8 -9
- package/src/ooxml/writer.ts +28 -10
- package/src/pdf/export.ts +4 -2
- package/src/pdf/import.ts +40 -34
- package/src/pptx/export.ts +4 -2
- package/src/pptx/import.ts +214 -32
- package/src/pptx/index.ts +14 -0
- package/src/pptx/layouts.ts +1222 -0
- package/src/registry/convert.ts +168 -53
- package/src/registry/defaultFormats.ts +135 -45
- package/src/registry/index.ts +6 -1
- package/src/registry/types.ts +69 -6
- package/src/shared/boundedZipArchive.ts +383 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +151 -0
- package/src/shared/zipLimits.ts +296 -0
- package/src/shared/zipSafety.ts +19 -0
- package/src/xlsx/import.ts +3 -2
- package/dist/chunk-46FMDOWZ.js.map +0 -1
- package/dist/chunk-5LSSWZPU.js.map +0 -1
- package/dist/chunk-D7IFPWNZ.js.map +0 -1
- package/dist/chunk-EHLGMSTE.js.map +0 -1
- package/dist/chunk-MJGRI6XR.js.map +0 -1
- package/dist/chunk-RTS5XBZ7.js.map +0 -1
- package/dist/chunk-SHLPSADL.js.map +0 -1
- package/dist/chunk-WWBIKRNP.js.map +0 -1
- package/dist/chunk-YESS7VY3.js.map +0 -1
- /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
package/src/ooxml/reader.ts
CHANGED
|
@@ -5,12 +5,67 @@
|
|
|
5
5
|
* structural metadata: [Content_Types].xml, relationships, and
|
|
6
6
|
* core properties.
|
|
7
7
|
*
|
|
8
|
-
* Uses JSZip
|
|
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
|
|
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(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
47
|
-
if (
|
|
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
|
|
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(
|
|
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
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
const
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
return
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ooxml/types.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
21
|
-
|
|
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
|
/**
|
package/src/ooxml/writer.ts
CHANGED
|
@@ -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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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;
|