@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @bendyline/squisq-formats Infer Module
|
|
3
|
+
*
|
|
4
|
+
* "Infer theme and layouts from a file import": read the OOXML theme
|
|
5
|
+
* (colors + fonts) out of a DOCX/PPTX/XLSX file and compile it into a
|
|
6
|
+
* Squisq `Theme`; for PPTX, optionally derive custom layout templates
|
|
7
|
+
* from the deck's slide layouts/masters.
|
|
8
|
+
*
|
|
9
|
+
* Consumers:
|
|
10
|
+
* - the editor's theme customizer surfaces (upload / drag-drop a file)
|
|
11
|
+
* - the PPTX importer (theme + layouts ride along as frontmatter)
|
|
12
|
+
*
|
|
13
|
+
* PDF is deliberately unsupported: PDF files carry no theme tables, so
|
|
14
|
+
* inference would produce a mostly-default theme. `inferThemeFromFile`
|
|
15
|
+
* rejects PDF bytes with a clear `ConversionError`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { CustomTemplateDefinition, Theme } from '@bendyline/squisq/schemas';
|
|
19
|
+
import { openPackage, throwIfOoxmlAborted } from '../ooxml/reader.js';
|
|
20
|
+
import type { OoxmlOpenOptions } from '../ooxml/reader.js';
|
|
21
|
+
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
22
|
+
import { ConversionError } from '../registry/errors.js';
|
|
23
|
+
import type { ExtractedFileTheme, InferSourceFormat } from './types.js';
|
|
24
|
+
import { extractDocxTheme, extractPptxTheme, extractXlsxTheme } from './extract.js';
|
|
25
|
+
import { colorHintsFromExtraction, compileExtractedTheme } from './mapTheme.js';
|
|
26
|
+
|
|
27
|
+
export type { ExtractedFileTheme, InferSourceFormat, SchemeSlot } from './types.js';
|
|
28
|
+
export { extractDocxTheme, extractPptxTheme, extractXlsxTheme } from './extract.js';
|
|
29
|
+
export type { CompileExtractedOptions, MappedThemePartial } from './mapTheme.js';
|
|
30
|
+
export {
|
|
31
|
+
colorHintsFromExtraction,
|
|
32
|
+
compileExtractedTheme,
|
|
33
|
+
extractedThemeToPartial,
|
|
34
|
+
} from './mapTheme.js';
|
|
35
|
+
|
|
36
|
+
export interface InferThemeOptions extends OoxmlOpenOptions {
|
|
37
|
+
/** Skip sniffing when the caller already knows the format (e.g. from the extension). */
|
|
38
|
+
format?: InferSourceFormat;
|
|
39
|
+
/** PPTX only: also derive custom layout templates from slide layouts/masters. */
|
|
40
|
+
inferLayouts?: boolean;
|
|
41
|
+
/** Preferred display name for the compiled theme (e.g. the file's basename). */
|
|
42
|
+
nameHint?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InferredFileTheme {
|
|
46
|
+
/** Compiled, validated Squisq theme (`custom-<slug>` id, seeds recorded). */
|
|
47
|
+
theme: Theme;
|
|
48
|
+
/** Raw extraction, for callers mapping into their own editing model. */
|
|
49
|
+
extraction: ExtractedFileTheme;
|
|
50
|
+
/** Present only for PPTX with `inferLayouts` and ≥1 non-redundant layout. */
|
|
51
|
+
layouts?: CustomTemplateDefinition[];
|
|
52
|
+
/** Non-fatal notes: sysClr fallbacks, dropped accents, skipped layouts, … */
|
|
53
|
+
warnings: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function looksLikePdf(data: ArrayBuffer | Blob, signal?: AbortSignal): Promise<boolean> {
|
|
57
|
+
throwIfOoxmlAborted(signal);
|
|
58
|
+
// Duck-typed (not instanceof): buffers routinely cross realms in test
|
|
59
|
+
// environments and web workers. Blobs carry `size`; ArrayBuffers carry
|
|
60
|
+
// `byteLength`.
|
|
61
|
+
let head: Uint8Array | null = null;
|
|
62
|
+
const blob = data as Blob;
|
|
63
|
+
if (
|
|
64
|
+
typeof blob.size === 'number' &&
|
|
65
|
+
typeof blob.slice === 'function' &&
|
|
66
|
+
typeof blob.arrayBuffer === 'function'
|
|
67
|
+
) {
|
|
68
|
+
head = new Uint8Array(await waitForAbortable(blob.slice(0, 5).arrayBuffer(), signal));
|
|
69
|
+
} else if (typeof (data as ArrayBuffer).byteLength === 'number') {
|
|
70
|
+
const buf = data as ArrayBuffer;
|
|
71
|
+
head = new Uint8Array(buf, 0, Math.min(5, buf.byteLength));
|
|
72
|
+
}
|
|
73
|
+
throwIfOoxmlAborted(signal);
|
|
74
|
+
if (!head || head.length < 4) return false;
|
|
75
|
+
// '%PDF'
|
|
76
|
+
return head[0] === 0x25 && head[1] === 0x50 && head[2] === 0x44 && head[3] === 0x46;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function waitForAbortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
80
|
+
throwIfOoxmlAborted(signal);
|
|
81
|
+
if (!signal) return work;
|
|
82
|
+
return new Promise<T>((resolve, reject) => {
|
|
83
|
+
let settled = false;
|
|
84
|
+
const finish = (complete: () => void): void => {
|
|
85
|
+
if (settled) return;
|
|
86
|
+
settled = true;
|
|
87
|
+
signal.removeEventListener('abort', handleAbort);
|
|
88
|
+
complete();
|
|
89
|
+
};
|
|
90
|
+
const handleAbort = (): void =>
|
|
91
|
+
finish(() => reject(signal.reason ?? new Error('OOXML operation was cancelled')));
|
|
92
|
+
signal.addEventListener('abort', handleAbort, { once: true });
|
|
93
|
+
if (signal.aborted) handleAbort();
|
|
94
|
+
work.then(
|
|
95
|
+
(value) => finish(() => resolve(value)),
|
|
96
|
+
(error: unknown) => finish(() => reject(error)),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Classify an opened package from its already-parsed content types.
|
|
103
|
+
* (Deliberately not the registry's byte sniffer — that re-parses the zip
|
|
104
|
+
* and carries `dbk` semantics this path doesn't want.)
|
|
105
|
+
*/
|
|
106
|
+
function sniffOoxmlFormat(pkg: OoxmlPackage): InferSourceFormat | undefined {
|
|
107
|
+
const values = [...pkg.contentTypes.overrides.values(), ...pkg.contentTypes.defaults.values()];
|
|
108
|
+
for (const ct of values) {
|
|
109
|
+
if (ct.includes('wordprocessingml')) return 'docx';
|
|
110
|
+
if (ct.includes('presentationml')) return 'pptx';
|
|
111
|
+
if (ct.includes('spreadsheetml')) return 'xlsx';
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const EXTRACTORS: Record<
|
|
117
|
+
InferSourceFormat,
|
|
118
|
+
(pkg: OoxmlPackage) => Promise<ExtractedFileTheme | null>
|
|
119
|
+
> = {
|
|
120
|
+
docx: extractDocxTheme,
|
|
121
|
+
pptx: extractPptxTheme,
|
|
122
|
+
xlsx: extractXlsxTheme,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Infer a Squisq theme (and, for PPTX, optionally custom layout templates)
|
|
127
|
+
* from an office file's bytes.
|
|
128
|
+
*
|
|
129
|
+
* Throws `ConversionError`:
|
|
130
|
+
* - `unsupported-input` for PDF bytes (no theme tables to read)
|
|
131
|
+
* - `invalid-input` for non-OOXML data or files with no theme part
|
|
132
|
+
*/
|
|
133
|
+
export async function inferThemeFromFile(
|
|
134
|
+
data: ArrayBuffer | Blob,
|
|
135
|
+
options: InferThemeOptions = {},
|
|
136
|
+
): Promise<InferredFileTheme> {
|
|
137
|
+
throwIfOoxmlAborted(options.signal);
|
|
138
|
+
if (await looksLikePdf(data, options.signal)) {
|
|
139
|
+
throw new ConversionError(
|
|
140
|
+
'unsupported-input',
|
|
141
|
+
'PDF theme inference is not supported — PDF files carry no theme color/font tables.',
|
|
142
|
+
{ format: 'pdf' },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let pkg: OoxmlPackage;
|
|
147
|
+
try {
|
|
148
|
+
pkg = await openPackage(data, options);
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
if (options.signal?.aborted) throw options.signal.reason ?? err;
|
|
151
|
+
throw new ConversionError(
|
|
152
|
+
'invalid-input',
|
|
153
|
+
'Could not read this file as a Word, PowerPoint, or Excel document.',
|
|
154
|
+
{ cause: err },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const format = options.format ?? sniffOoxmlFormat(pkg);
|
|
159
|
+
throwIfOoxmlAborted(options.signal);
|
|
160
|
+
if (!format) {
|
|
161
|
+
throw new ConversionError(
|
|
162
|
+
'invalid-input',
|
|
163
|
+
'Not a Word, PowerPoint, or Excel document — no theme to infer.',
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const extraction = await EXTRACTORS[format](pkg);
|
|
168
|
+
throwIfOoxmlAborted(options.signal);
|
|
169
|
+
if (!extraction) {
|
|
170
|
+
throw new ConversionError('invalid-input', 'No theme part found in this file.', { format });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { theme, warnings: mapWarnings } = compileExtractedTheme(extraction, {
|
|
174
|
+
nameHint: options.nameHint,
|
|
175
|
+
});
|
|
176
|
+
throwIfOoxmlAborted(options.signal);
|
|
177
|
+
const warnings = [...extraction.warnings, ...mapWarnings];
|
|
178
|
+
|
|
179
|
+
let layouts: CustomTemplateDefinition[] | undefined;
|
|
180
|
+
if (options.inferLayouts) {
|
|
181
|
+
if (format !== 'pptx') {
|
|
182
|
+
warnings.push('Layout inference is only available for PowerPoint files; skipped.');
|
|
183
|
+
} else {
|
|
184
|
+
const { analyzePptxLayouts } = await import('../pptx/layouts.js');
|
|
185
|
+
const analysis = await analyzePptxLayouts(pkg, {
|
|
186
|
+
colors: colorHintsFromExtraction(extraction),
|
|
187
|
+
signal: options.signal,
|
|
188
|
+
});
|
|
189
|
+
warnings.push(...analysis.warnings);
|
|
190
|
+
const defs = analysis.layouts
|
|
191
|
+
.map((l) => (l.verdict.kind === 'custom' ? l.verdict.def : null))
|
|
192
|
+
.filter((d): d is CustomTemplateDefinition => d !== null);
|
|
193
|
+
if (defs.length > 0) layouts = defs;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throwIfOoxmlAborted(options.signal);
|
|
198
|
+
return { theme, extraction, ...(layouts ? { layouts } : {}), warnings };
|
|
199
|
+
}
|
|
@@ -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
|
+
}
|