@bendyline/squisq-formats 1.4.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -2
- package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
- package/dist/chunk-2CSZJEAL.js.map +1 -0
- package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
- package/dist/chunk-2EFDWKC6.js.map +1 -0
- package/dist/chunk-2PRVLAU3.js +740 -0
- package/dist/chunk-2PRVLAU3.js.map +1 -0
- package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
- package/dist/chunk-4HJ6AK2Q.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-CVNVD7VH.js} +154 -39
- package/dist/chunk-CVNVD7VH.js.map +1 -0
- package/dist/chunk-CX3LH47J.js +99 -0
- package/dist/chunk-CX3LH47J.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-H7T5DXUU.js +479 -0
- package/dist/chunk-H7T5DXUU.js.map +1 -0
- package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
- package/dist/chunk-HBSCRS5P.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-X4USWOQ2.js +131 -0
- package/dist/chunk-X4USWOQ2.js.map +1 -0
- package/dist/chunk-XTLQXR4U.js +195 -0
- package/dist/chunk-XTLQXR4U.js.map +1 -0
- package/dist/chunk-YRT7GQ5Y.js +28 -0
- package/dist/chunk-YRT7GQ5Y.js.map +1 -0
- package/dist/chunk-ZZ7D4DN6.js +97 -0
- package/dist/chunk-ZZ7D4DN6.js.map +1 -0
- package/dist/container/index.d.ts +6 -2
- package/dist/container/index.js +15 -17
- 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-Djev9BLU.d.ts +69 -0
- package/dist/extract-LNOA7DBK.js +15 -0
- package/dist/extract-LNOA7DBK.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-Yivc3kZM.d.ts +136 -0
- package/dist/index.d.ts +13 -4
- package/dist/index.js +30 -11
- 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-7OUUVAGU.js +12 -0
- package/dist/layouts-7OUUVAGU.js.map +1 -0
- package/dist/layouts-CZ0bdsNL.d.ts +150 -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-Gof6OrJX.d.ts +140 -0
- package/dist/registry/index.d.ts +46 -8
- package/dist/registry/index.js +5 -2
- package/dist/themeReader-DPw4J5Ss.d.ts +56 -0
- package/dist/xlsx/index.d.ts +6 -66
- package/dist/xlsx/index.js +4 -2
- package/dist/zipLimits-bGYMS9cI.d.ts +29 -0
- package/package.json +8 -2
- package/src/__tests__/container.test.ts +41 -0
- package/src/__tests__/convert.test.ts +167 -0
- 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 +59 -2
- 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 +20 -29
- 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 +20 -0
- package/src/infer/extract.ts +127 -0
- package/src/infer/index.ts +168 -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 +87 -28
- 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 +1168 -0
- package/src/registry/convert.ts +54 -23
- package/src/registry/defaultFormats.ts +131 -44
- package/src/registry/index.ts +3 -0
- package/src/registry/types.ts +52 -6
- package/src/shared/boundedZipArchive.ts +342 -0
- package/src/shared/fidelity.ts +130 -0
- package/src/shared/zipEntryCount.ts +97 -0
- package/src/shared/zipLimits.ts +286 -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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map a raw OOXML theme extraction onto a compiled Squisq `Theme`.
|
|
3
|
+
*
|
|
4
|
+
* Only a handful of seeds are taken from the file (lead accents, background,
|
|
5
|
+
* text, major/minor fonts); `compileTheme` derives the rest of the palette
|
|
6
|
+
* via OKLCh so the result is always a complete, validated theme that stays
|
|
7
|
+
* re-editable in the customizer (seeds are recorded on `Theme.seedColors`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
DeepPartial,
|
|
12
|
+
Theme,
|
|
13
|
+
ThemeColorScheme,
|
|
14
|
+
ThemeSeedColors,
|
|
15
|
+
} from '@bendyline/squisq/schemas';
|
|
16
|
+
import {
|
|
17
|
+
accentToColorScheme,
|
|
18
|
+
compileTheme,
|
|
19
|
+
contrastRatio,
|
|
20
|
+
isHex,
|
|
21
|
+
matchFontFamily,
|
|
22
|
+
relativeLuminance,
|
|
23
|
+
} from '@bendyline/squisq/schemas';
|
|
24
|
+
import type { PptxColorHints } from '../pptx/layouts.js';
|
|
25
|
+
import type { ExtractedFileTheme } from './types.js';
|
|
26
|
+
|
|
27
|
+
export interface CompileExtractedOptions {
|
|
28
|
+
/** Preferred display name for the compiled theme (e.g. the file's basename). */
|
|
29
|
+
nameHint?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MappedThemePartial {
|
|
33
|
+
partial: DeepPartial<Theme>;
|
|
34
|
+
warnings: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Neutral primary used when a theme resolves no usable accent slots. */
|
|
38
|
+
const FALLBACK_PRIMARY = '#3182ce';
|
|
39
|
+
|
|
40
|
+
/** Accents closer to the background than this contrast are dropped (near-invisible). */
|
|
41
|
+
const MIN_ACCENT_CONTRAST = 1.3;
|
|
42
|
+
|
|
43
|
+
const ACCENT_SLOTS = ['accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6'] as const;
|
|
44
|
+
|
|
45
|
+
/** Companion slot providing `backgroundLight` for each background slot. */
|
|
46
|
+
const COMPANION_SLOT = { lt1: 'lt2', dk1: 'dk2', lt2: 'lt1', dk2: 'dk1' } as const;
|
|
47
|
+
|
|
48
|
+
function slugify(name: string): string {
|
|
49
|
+
const slug = name
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
52
|
+
.replace(/^-+|-+$/g, '');
|
|
53
|
+
return slug || 'imported';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the `DeepPartial<Theme>` for an extraction. Exported for tests; most
|
|
58
|
+
* callers want {@link compileExtractedTheme}.
|
|
59
|
+
*/
|
|
60
|
+
export function extractedThemeToPartial(
|
|
61
|
+
extraction: ExtractedFileTheme,
|
|
62
|
+
opts: CompileExtractedOptions = {},
|
|
63
|
+
): MappedThemePartial {
|
|
64
|
+
const warnings: string[] = [];
|
|
65
|
+
const { colors, colorMap, fonts } = extraction;
|
|
66
|
+
|
|
67
|
+
const name = opts.nameHint?.trim() || extraction.themeName || 'Imported Theme';
|
|
68
|
+
const partial: DeepPartial<Theme> = {
|
|
69
|
+
id: `custom-${slugify(name)}`,
|
|
70
|
+
name,
|
|
71
|
+
description: `Imported from a ${extraction.sourceFormat} file${
|
|
72
|
+
extraction.themeName ? ` (theme "${extraction.themeName}")` : ''
|
|
73
|
+
}.`,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (colors) {
|
|
77
|
+
const primary = colors.accent1 ?? colors.accent2;
|
|
78
|
+
if (!primary) {
|
|
79
|
+
warnings.push('theme: no usable accent colors; primary defaults to Squisq blue');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let background: string | undefined = colors[colorMap.bg1];
|
|
83
|
+
let text: string | undefined = colors[colorMap.tx1];
|
|
84
|
+
// Pathological color maps can land background and text on similar
|
|
85
|
+
// surfaces; when they don't contrast, let the compiler derive both
|
|
86
|
+
// from the primary instead of shipping unreadable seeds.
|
|
87
|
+
if (
|
|
88
|
+
background &&
|
|
89
|
+
text &&
|
|
90
|
+
Math.abs(relativeLuminance(background) - relativeLuminance(text)) < 0.2
|
|
91
|
+
) {
|
|
92
|
+
warnings.push('theme: background and text colors are too close; deriving surfaces instead');
|
|
93
|
+
background = undefined;
|
|
94
|
+
text = undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const seeds: ThemeSeedColors = {
|
|
98
|
+
primary: primary ?? FALLBACK_PRIMARY,
|
|
99
|
+
...(colors.accent2 ? { secondary: colors.accent2 } : {}),
|
|
100
|
+
...(colors.accent3 ? { accent: colors.accent3 } : {}),
|
|
101
|
+
...(background ? { background } : {}),
|
|
102
|
+
...(text ? { text } : {}),
|
|
103
|
+
};
|
|
104
|
+
partial.seedColors = seeds;
|
|
105
|
+
|
|
106
|
+
const explicitColors: DeepPartial<Theme>['colors'] = {};
|
|
107
|
+
const companion = background ? colors[COMPANION_SLOT[colorMap.bg1]] : undefined;
|
|
108
|
+
if (background && companion && companion !== background) {
|
|
109
|
+
explicitColors.backgroundLight = companion;
|
|
110
|
+
}
|
|
111
|
+
if (colors.hlink && isHex(colors.hlink)) {
|
|
112
|
+
explicitColors.highlight = colors.hlink;
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(explicitColors).length > 0) partial.colors = explicitColors;
|
|
115
|
+
|
|
116
|
+
const schemes: Record<string, ThemeColorScheme> = {};
|
|
117
|
+
const seenHexes = new Set<string>();
|
|
118
|
+
const schemeBackground = background ?? colors[colorMap.bg1];
|
|
119
|
+
for (const slot of ACCENT_SLOTS) {
|
|
120
|
+
const hex = colors[slot];
|
|
121
|
+
if (!hex || seenHexes.has(hex)) continue;
|
|
122
|
+
if (schemeBackground && contrastRatio(hex, schemeBackground) < MIN_ACCENT_CONTRAST) {
|
|
123
|
+
warnings.push(`theme: ${slot} is too close to the background; dropped from accents`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
seenHexes.add(hex);
|
|
127
|
+
schemes[slot] = accentToColorScheme(hex);
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(schemes).length > 0) partial.colorSchemes = schemes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (fonts?.majorLatin || fonts?.minorLatin) {
|
|
133
|
+
partial.typography = {
|
|
134
|
+
...(fonts.majorLatin ? { titleFont: matchFontFamily(fonts.majorLatin) } : {}),
|
|
135
|
+
...(fonts.minorLatin ? { bodyFont: matchFontFamily(fonts.minorLatin) } : {}),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { partial, warnings };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compile an extraction into a full validated Squisq theme. Mapping warnings
|
|
144
|
+
* (dropped accents, low-contrast surfaces) are returned alongside; extraction
|
|
145
|
+
* warnings stay on `extraction.warnings`.
|
|
146
|
+
*/
|
|
147
|
+
export function compileExtractedTheme(
|
|
148
|
+
extraction: ExtractedFileTheme,
|
|
149
|
+
opts: CompileExtractedOptions = {},
|
|
150
|
+
): { theme: Theme; warnings: string[] } {
|
|
151
|
+
const { partial, warnings } = extractedThemeToPartial(extraction, opts);
|
|
152
|
+
return { theme: compileTheme(partial), warnings };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the color hints the PPTX layout generator uses to resolve scheme
|
|
157
|
+
* color references (`a:schemeClr val="accent1"`, `bg1`, `tx1`, …) on
|
|
158
|
+
* decorative shapes.
|
|
159
|
+
*/
|
|
160
|
+
export function colorHintsFromExtraction(extraction: ExtractedFileTheme): PptxColorHints {
|
|
161
|
+
const { colors, colorMap } = extraction;
|
|
162
|
+
if (!colors) return {};
|
|
163
|
+
const schemeColors: Record<string, string> = {};
|
|
164
|
+
for (const [slot, hex] of Object.entries(colors)) {
|
|
165
|
+
if (typeof hex === 'string') schemeColors[slot] = hex;
|
|
166
|
+
}
|
|
167
|
+
const background = colors[colorMap.bg1];
|
|
168
|
+
const text = colors[colorMap.tx1];
|
|
169
|
+
if (background) schemeColors['bg1'] = background;
|
|
170
|
+
if (text) schemeColors['tx1'] = text;
|
|
171
|
+
return {
|
|
172
|
+
...(background ? { background } : {}),
|
|
173
|
+
...(text ? { text } : {}),
|
|
174
|
+
schemeColors,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for file-import theme inference.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { OoxmlColorScheme, OoxmlFontScheme } from '../ooxml/themeReader.js';
|
|
6
|
+
|
|
7
|
+
/** Source formats theme inference supports. PDF carries no theme tables. */
|
|
8
|
+
export type InferSourceFormat = 'docx' | 'pptx' | 'xlsx';
|
|
9
|
+
|
|
10
|
+
/** The four surface/text scheme slots a color map can point at. */
|
|
11
|
+
export type SchemeSlot = 'dk1' | 'lt1' | 'dk2' | 'lt2';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Raw theme extraction from one office file, before mapping onto a Squisq
|
|
15
|
+
* `Theme`. `colorMap` records which scheme slots back the document's
|
|
16
|
+
* background/text — PPTX masters carry `<p:clrMap bg1=… tx1=…>` and DOCX may
|
|
17
|
+
* carry `w:clrSchemeMapping`; dark decks map `bg1="dk1"` and invert here.
|
|
18
|
+
*/
|
|
19
|
+
export interface ExtractedFileTheme {
|
|
20
|
+
sourceFormat: InferSourceFormat;
|
|
21
|
+
/** `a:theme@name` when present (e.g. "Office Theme", "Ion"). */
|
|
22
|
+
themeName?: string;
|
|
23
|
+
colors?: OoxmlColorScheme;
|
|
24
|
+
colorMap: { bg1: SchemeSlot; tx1: SchemeSlot };
|
|
25
|
+
fonts?: OoxmlFontScheme;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
package/src/ooxml/index.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type {
|
|
|
25
25
|
} from './types.js';
|
|
26
26
|
|
|
27
27
|
// Reader
|
|
28
|
+
export type { OoxmlOpenOptions } from './reader.js';
|
|
29
|
+
export { ZipSafetyError } from '../shared/zipSafety.js';
|
|
30
|
+
export type {
|
|
31
|
+
ZipSafetyLimits,
|
|
32
|
+
ZipSafetyErrorCode,
|
|
33
|
+
ZipSafetyErrorOptions,
|
|
34
|
+
} from '../shared/zipSafety.js';
|
|
28
35
|
export {
|
|
29
36
|
openPackage,
|
|
30
37
|
getPartRelationships,
|
|
@@ -33,6 +40,13 @@ export {
|
|
|
33
40
|
getCoreProperties,
|
|
34
41
|
} from './reader.js';
|
|
35
42
|
|
|
43
|
+
// Read utilities
|
|
44
|
+
export { attrNS, baseDirOf, resolveTarget, findRelByType } from './readUtils.js';
|
|
45
|
+
|
|
46
|
+
// Theme reader
|
|
47
|
+
export type { OoxmlColorScheme, OoxmlFontScheme, OoxmlTheme } from './themeReader.js';
|
|
48
|
+
export { parseThemeXml, readThemePart } from './themeReader.js';
|
|
49
|
+
|
|
36
50
|
// Writer
|
|
37
51
|
export type { OoxmlPackageBuilder } from './writer.js';
|
|
38
52
|
export { createPackage } from './writer.js';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DOM-read helpers for OOXML importers.
|
|
3
|
+
*
|
|
4
|
+
* Every importer walks parts with `getElementsByTagNameNS` and resolves
|
|
5
|
+
* relationship targets relative to the referencing part; these helpers hold
|
|
6
|
+
* the common pieces so new readers (theme, slide layouts) don't re-implement
|
|
7
|
+
* them per format.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Relationship } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read a namespaced attribute with a qualified-name fallback for documents
|
|
14
|
+
* whose producer omitted the namespace declaration (e.g. `r:id`).
|
|
15
|
+
*/
|
|
16
|
+
export function attrNS(el: Element, ns: string, local: string, qualified: string): string | null {
|
|
17
|
+
return el.getAttributeNS(ns, local) ?? el.getAttribute(qualified);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Directory portion of a part path, e.g. `ppt/slides/slide1.xml` → `ppt/slides`. */
|
|
21
|
+
export function baseDirOf(path: string): string {
|
|
22
|
+
const slash = path.lastIndexOf('/');
|
|
23
|
+
return slash === -1 ? '' : path.slice(0, slash);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a relationship target against the referencing part's directory.
|
|
28
|
+
* Absolute targets (leading `/`) are package-rooted; relative targets may
|
|
29
|
+
* climb with `..` segments (e.g. `../slideLayouts/slideLayout1.xml`).
|
|
30
|
+
*/
|
|
31
|
+
export function resolveTarget(baseDir: string, target: string): string {
|
|
32
|
+
if (target.startsWith('/')) return target.replace(/^\//, '');
|
|
33
|
+
const stack = baseDir ? baseDir.split('/') : [];
|
|
34
|
+
for (const seg of target.split('/')) {
|
|
35
|
+
if (seg === '..') stack.pop();
|
|
36
|
+
else if (seg !== '.') stack.push(seg);
|
|
37
|
+
}
|
|
38
|
+
return stack.join('/');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** First relationship of the given type, if any. */
|
|
42
|
+
export function findRelByType(rels: Relationship[], type: string): Relationship | undefined {
|
|
43
|
+
return rels.find((r) => r.type === type);
|
|
44
|
+
}
|
package/src/ooxml/reader.ts
CHANGED
|
@@ -5,12 +5,44 @@
|
|
|
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 an xmldom
|
|
9
|
+
* fallback in bare 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 MAX_CONTENT_TYPES_BYTES = 1024 * 1024;
|
|
30
|
+
const MAX_RELATIONSHIPS_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/**
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
function parseXml(source: string): Document {
|
|
37
|
+
const nativeParser = (globalThis as { DOMParser?: DomParserLike }).DOMParser;
|
|
38
|
+
const Parser = nativeParser ?? (XmldomDOMParser as unknown as DomParserLike);
|
|
39
|
+
const doc = new Parser().parseFromString(source, 'application/xml') as Document;
|
|
40
|
+
const parserErrors = doc.getElementsByTagName('parsererror');
|
|
41
|
+
if (parserErrors.length > 0) {
|
|
42
|
+
throw new Error(`Invalid OOXML XML part: ${parserErrors[0]?.textContent ?? 'parse error'}`);
|
|
43
|
+
}
|
|
44
|
+
return doc;
|
|
45
|
+
}
|
|
14
46
|
|
|
15
47
|
// ============================================
|
|
16
48
|
// Package Opening
|
|
@@ -22,14 +54,23 @@ import { NS_RELATIONSHIPS, NS_DC, NS_DCTERMS, NS_CORE_PROPERTIES } from './names
|
|
|
22
54
|
* Parses the ZIP archive, [Content_Types].xml, and root relationships.
|
|
23
55
|
*
|
|
24
56
|
* @param data - The raw .docx/.pptx/.xlsx file as ArrayBuffer or Blob
|
|
57
|
+
* @param limits - Optional archive count, size, and compression-ratio limits
|
|
25
58
|
* @returns A parsed OoxmlPackage
|
|
26
59
|
*/
|
|
27
|
-
export async function openPackage(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
60
|
+
export async function openPackage(
|
|
61
|
+
data: ArrayBuffer | Blob,
|
|
62
|
+
limits: OoxmlOpenOptions = {},
|
|
63
|
+
): Promise<OoxmlPackage> {
|
|
64
|
+
const archive = await openBoundedZipArchive(data, limits);
|
|
65
|
+
const contentTypes = await parseContentTypes(archive);
|
|
66
|
+
const rootRelationships = await parseRelationships(archive, '');
|
|
31
67
|
|
|
32
|
-
|
|
68
|
+
const pkg = {
|
|
69
|
+
contentTypes,
|
|
70
|
+
rootRelationships,
|
|
71
|
+
} as unknown as OoxmlPackage;
|
|
72
|
+
packageArchives.set(pkg, archive);
|
|
73
|
+
return pkg;
|
|
33
74
|
}
|
|
34
75
|
|
|
35
76
|
// ============================================
|
|
@@ -39,15 +80,13 @@ export async function openPackage(data: ArrayBuffer | Blob): Promise<OoxmlPackag
|
|
|
39
80
|
/**
|
|
40
81
|
* Parse [Content_Types].xml from the archive.
|
|
41
82
|
*/
|
|
42
|
-
async function parseContentTypes(
|
|
83
|
+
async function parseContentTypes(archive: BoundedZipArchive): Promise<ContentTypeMap> {
|
|
43
84
|
const overrides = new Map<string, string>();
|
|
44
85
|
const defaults = new Map<string, string>();
|
|
45
86
|
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
const text = await file.async('text');
|
|
50
|
-
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
|
87
|
+
const text = await archive.readText('[Content_Types].xml', MAX_CONTENT_TYPES_BYTES);
|
|
88
|
+
if (text === null) return { overrides, defaults };
|
|
89
|
+
const doc = parseXml(text);
|
|
51
90
|
|
|
52
91
|
// Parse <Default Extension="rels" ContentType="..." />
|
|
53
92
|
const defaultEls = doc.getElementsByTagName('Default');
|
|
@@ -89,7 +128,7 @@ export async function getPartRelationships(
|
|
|
89
128
|
pkg: OoxmlPackage,
|
|
90
129
|
partPath: string,
|
|
91
130
|
): Promise<Relationship[]> {
|
|
92
|
-
return parseRelationships(pkg
|
|
131
|
+
return parseRelationships(pkg, partPath);
|
|
93
132
|
}
|
|
94
133
|
|
|
95
134
|
/**
|
|
@@ -98,14 +137,16 @@ export async function getPartRelationships(
|
|
|
98
137
|
* For root relationships, relsPath = "_rels/.rels".
|
|
99
138
|
* For part relationships, relsPath = "<dir>/_rels/<filename>.rels".
|
|
100
139
|
*/
|
|
101
|
-
async function parseRelationships(
|
|
140
|
+
async function parseRelationships(
|
|
141
|
+
source: OoxmlPackage | BoundedZipArchive,
|
|
142
|
+
partPath: string,
|
|
143
|
+
): Promise<Relationship[]> {
|
|
102
144
|
const relsPath = partPath === '' ? '_rels/.rels' : buildRelsPath(partPath);
|
|
103
145
|
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
|
146
|
+
const bytes = await readArchivePart(source, relsPath, MAX_RELATIONSHIPS_BYTES);
|
|
147
|
+
if (!bytes) return [];
|
|
148
|
+
const text = new TextDecoder().decode(bytes);
|
|
149
|
+
const doc = parseXml(text);
|
|
109
150
|
const result: Relationship[] = [];
|
|
110
151
|
|
|
111
152
|
const els = doc.getElementsByTagNameNS(NS_RELATIONSHIPS, 'Relationship');
|
|
@@ -159,11 +200,8 @@ function buildRelsPath(partPath: string): string {
|
|
|
159
200
|
* @returns Parsed XML Document, or null if the part doesn't exist
|
|
160
201
|
*/
|
|
161
202
|
export async function getPartXml(pkg: OoxmlPackage, partPath: string): Promise<Document | null> {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const text = await file.async('text');
|
|
166
|
-
return new DOMParser().parseFromString(text, 'application/xml');
|
|
203
|
+
const bytes = await readPackagePart(pkg, partPath);
|
|
204
|
+
return bytes ? parseXml(new TextDecoder().decode(bytes)) : null;
|
|
167
205
|
}
|
|
168
206
|
|
|
169
207
|
/**
|
|
@@ -177,9 +215,30 @@ export async function getPartBinary(
|
|
|
177
215
|
pkg: OoxmlPackage,
|
|
178
216
|
partPath: string,
|
|
179
217
|
): Promise<ArrayBuffer | null> {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
218
|
+
const bytes = await readPackagePart(pkg, partPath);
|
|
219
|
+
return bytes ? bytes.slice().buffer : null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readArchivePart(
|
|
223
|
+
source: OoxmlPackage | BoundedZipArchive,
|
|
224
|
+
path: string,
|
|
225
|
+
maxBytes?: number,
|
|
226
|
+
): Promise<Uint8Array | null> {
|
|
227
|
+
return 'entries' in source
|
|
228
|
+
? source.read(path, maxBytes)
|
|
229
|
+
: readPackagePart(source, path, maxBytes);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function readPackagePart(
|
|
233
|
+
pkg: OoxmlPackage,
|
|
234
|
+
path: string,
|
|
235
|
+
maxBytes?: number,
|
|
236
|
+
): Promise<Uint8Array | null> {
|
|
237
|
+
const archive = packageArchives.get(pkg);
|
|
238
|
+
if (!archive) {
|
|
239
|
+
throw new TypeError('Invalid OoxmlPackage: create packages with openPackage().');
|
|
240
|
+
}
|
|
241
|
+
return archive.read(path, maxBytes);
|
|
183
242
|
}
|
|
184
243
|
|
|
185
244
|
// ============================================
|
|
@@ -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
|
/**
|