@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/epub/export.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import JSZip from 'jszip';
|
|
25
|
-
import type { Doc, AudioSegment } from '@bendyline/squisq/schemas';
|
|
25
|
+
import type { Doc, AudioSegment, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
26
26
|
import { resolveFontFamily } from '@bendyline/squisq/schemas';
|
|
27
27
|
import { resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
28
28
|
import type {
|
|
@@ -36,7 +36,7 @@ import type {
|
|
|
36
36
|
MarkdownTableRow,
|
|
37
37
|
MarkdownTableCell,
|
|
38
38
|
} from '@bendyline/squisq/markdown';
|
|
39
|
-
import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
|
|
39
|
+
import { readFrontmatterThemeId, sanitizeUrl } from '@bendyline/squisq/markdown';
|
|
40
40
|
import { escapeXml } from '../ooxml/xmlUtils.js';
|
|
41
41
|
import { inferMimeType, extractFilename } from '../html/imageUtils.js';
|
|
42
42
|
import { extractPlainText } from '../shared/text.js';
|
|
@@ -56,6 +56,8 @@ export interface EpubExportOptions {
|
|
|
56
56
|
publisher?: string;
|
|
57
57
|
/** Squisq theme ID for CSS styling */
|
|
58
58
|
themeId?: string;
|
|
59
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
60
|
+
themeRegistry?: ThemeRegistry;
|
|
59
61
|
/** Pre-resolved image data keyed by relative path as it appears in the markdown */
|
|
60
62
|
images?: Map<string, ArrayBuffer>;
|
|
61
63
|
/** Cover image data (JPEG or PNG) */
|
|
@@ -93,7 +95,7 @@ export async function markdownDocToEpub(
|
|
|
93
95
|
const language = options.language ?? 'en';
|
|
94
96
|
const description = options.description ?? '';
|
|
95
97
|
const publisher = options.publisher ?? '';
|
|
96
|
-
const uuid =
|
|
98
|
+
const uuid = createEpubUuid();
|
|
97
99
|
|
|
98
100
|
// Split document into chapters
|
|
99
101
|
const chapters = splitIntoChapters(doc.children);
|
|
@@ -101,22 +103,13 @@ export async function markdownDocToEpub(
|
|
|
101
103
|
// Collect images referenced in the document, deduplicating filenames
|
|
102
104
|
const imageEntries = collectDocImages(doc.children);
|
|
103
105
|
const resolvedImages = new Map<string, { data: ArrayBuffer; mime: string; filename: string }>();
|
|
106
|
+
const usedImageNames = new Set<string>();
|
|
104
107
|
if (options.images) {
|
|
105
|
-
const usedNames = new Set<string>();
|
|
106
108
|
for (const src of imageEntries) {
|
|
107
109
|
const data = options.images.get(src);
|
|
108
110
|
if (data) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (usedNames.has(filename)) {
|
|
112
|
-
const dot = filename.lastIndexOf('.');
|
|
113
|
-
const base = dot > 0 ? filename.slice(0, dot) : filename;
|
|
114
|
-
const ext = dot > 0 ? filename.slice(dot) : '';
|
|
115
|
-
let counter = 2;
|
|
116
|
-
while (usedNames.has(`${base}-${counter}${ext}`)) counter++;
|
|
117
|
-
filename = `${base}-${counter}${ext}`;
|
|
118
|
-
}
|
|
119
|
-
usedNames.add(filename);
|
|
111
|
+
const filename = uniqueFilename(safeArchiveBasename(src, 'image'), usedImageNames);
|
|
112
|
+
usedImageNames.add(filename);
|
|
120
113
|
resolvedImages.set(src, { data, mime: inferMimeType(filename), filename });
|
|
121
114
|
}
|
|
122
115
|
}
|
|
@@ -124,7 +117,11 @@ export async function markdownDocToEpub(
|
|
|
124
117
|
|
|
125
118
|
// Generate theme CSS — honor an explicit themeId, else the doc's frontmatter
|
|
126
119
|
// theme (`squisq-theme` / legacy), mirroring the other export formats.
|
|
127
|
-
const css = generateStylesheet(
|
|
120
|
+
const css = generateStylesheet(
|
|
121
|
+
options.themeId ?? readFrontmatterThemeId(doc.frontmatter),
|
|
122
|
+
doc,
|
|
123
|
+
options.themeRegistry,
|
|
124
|
+
);
|
|
128
125
|
|
|
129
126
|
// Build the ZIP
|
|
130
127
|
const zip = new JSZip();
|
|
@@ -155,7 +152,8 @@ export async function markdownDocToEpub(
|
|
|
155
152
|
bytes[1] === 0x50 &&
|
|
156
153
|
bytes[2] === 0x4e &&
|
|
157
154
|
bytes[3] === 0x47;
|
|
158
|
-
coverFilename = isPng ? 'cover.png' : 'cover.jpg';
|
|
155
|
+
coverFilename = uniqueFilename(isPng ? 'cover.png' : 'cover.jpg', usedImageNames);
|
|
156
|
+
usedImageNames.add(coverFilename);
|
|
159
157
|
zip.file(`OEBPS/images/${coverFilename}`, options.coverImage);
|
|
160
158
|
|
|
161
159
|
// Generate cover XHTML page — full-bleed image, no margins
|
|
@@ -184,17 +182,28 @@ export async function markdownDocToEpub(
|
|
|
184
182
|
// Per-segment audio file info, indexed by segment index (null if data missing)
|
|
185
183
|
const segmentAudioFiles: ({ filename: string; mime: string } | null)[] = [];
|
|
186
184
|
const allAudioFiles: { filename: string; mime: string }[] = [];
|
|
185
|
+
const audioFilenameBySource = new Map<string, string>();
|
|
186
|
+
const usedAudioFilenames = new Set<string>();
|
|
187
187
|
|
|
188
188
|
if (hasAudio) {
|
|
189
189
|
for (const seg of audioSegments) {
|
|
190
190
|
const data = audioMap.get(seg.src) ?? audioMap.get(seg.name);
|
|
191
191
|
if (data) {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
const sourceKey = seg.src || seg.name;
|
|
193
|
+
let finalName = audioFilenameBySource.get(sourceKey);
|
|
194
|
+
let isNewFile = false;
|
|
195
|
+
if (!finalName) {
|
|
196
|
+
const basename = safeArchiveBasename(sourceKey || 'narration', 'narration');
|
|
197
|
+
const withExtension = basename.includes('.') ? basename : `${basename}.mp3`;
|
|
198
|
+
finalName = uniqueFilename(withExtension, usedAudioFilenames);
|
|
199
|
+
audioFilenameBySource.set(sourceKey, finalName);
|
|
200
|
+
usedAudioFilenames.add(finalName);
|
|
201
|
+
zip.file(`OEBPS/audio/${finalName}`, data);
|
|
202
|
+
isNewFile = true;
|
|
203
|
+
}
|
|
195
204
|
const info = { filename: finalName, mime: inferMimeType(finalName) };
|
|
196
205
|
segmentAudioFiles.push(info);
|
|
197
|
-
allAudioFiles.push(info);
|
|
206
|
+
if (isNewFile) allAudioFiles.push(info);
|
|
198
207
|
} else {
|
|
199
208
|
segmentAudioFiles.push(null);
|
|
200
209
|
}
|
|
@@ -535,14 +544,18 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
|
|
|
535
544
|
return `<code>${escapeXml(node.value)}</code>`;
|
|
536
545
|
|
|
537
546
|
case 'link': {
|
|
547
|
+
const href = sanitizeUrl(node.url, 'link');
|
|
548
|
+
if (!href) return inlinesToXhtml(node.children, images);
|
|
538
549
|
const titleAttr = node.title ? ` title="${escapeXml(node.title)}"` : '';
|
|
539
|
-
return `<a href="${escapeXml(
|
|
550
|
+
return `<a href="${escapeXml(href)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
|
|
540
551
|
}
|
|
541
552
|
|
|
542
553
|
case 'image': {
|
|
543
554
|
const alt = escapeXml(node.alt ?? '');
|
|
544
555
|
const resolved = images.get(node.url);
|
|
545
|
-
const
|
|
556
|
+
const safeSrc = resolved ? `../images/${resolved.filename}` : sanitizeUrl(node.url, 'media');
|
|
557
|
+
if (!safeSrc) return alt;
|
|
558
|
+
const src = escapeXml(safeSrc);
|
|
546
559
|
return `<img src="${src}" alt="${alt}"/>`;
|
|
547
560
|
}
|
|
548
561
|
|
|
@@ -561,6 +574,58 @@ function inlineToXhtml(node: MarkdownInlineNode, images: ImageMap): string {
|
|
|
561
574
|
}
|
|
562
575
|
}
|
|
563
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Produce an EPUB identifier without assuming `crypto.randomUUID()` exists.
|
|
579
|
+
* `randomUUID` is unavailable in older/non-secure browser contexts; use
|
|
580
|
+
* `getRandomValues` when possible and retain a non-cryptographic last resort
|
|
581
|
+
* because this UUID is an identifier, not a security token.
|
|
582
|
+
*/
|
|
583
|
+
function createEpubUuid(): string {
|
|
584
|
+
const webCrypto = globalThis.crypto;
|
|
585
|
+
if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID();
|
|
586
|
+
|
|
587
|
+
const bytes = new Uint8Array(16);
|
|
588
|
+
if (typeof webCrypto?.getRandomValues === 'function') {
|
|
589
|
+
webCrypto.getRandomValues(bytes);
|
|
590
|
+
} else {
|
|
591
|
+
let seed = Date.now() ^ Math.floor(Math.random() * 0x7fffffff);
|
|
592
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
593
|
+
seed = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
594
|
+
bytes[i] = seed & 0xff;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// RFC 4122 version 4 / variant 1 bits.
|
|
598
|
+
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
|
599
|
+
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
|
600
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
601
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Flatten an external path to a safe archive basename. */
|
|
605
|
+
function safeArchiveBasename(path: string, fallback: string): string {
|
|
606
|
+
const filename = extractFilename(path.replace(/\\/g, '/'));
|
|
607
|
+
const safe = Array.from(filename, (character) => {
|
|
608
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
609
|
+
return character === '\\' || character === '/' || codePoint < 0x20 || codePoint === 0x7f
|
|
610
|
+
? '_'
|
|
611
|
+
: character;
|
|
612
|
+
})
|
|
613
|
+
.join('')
|
|
614
|
+
.trim();
|
|
615
|
+
return !safe || safe === '.' || safe === '..' ? fallback : safe;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Return a basename that does not collide with a prior archive member. */
|
|
619
|
+
function uniqueFilename(filename: string, used: ReadonlySet<string>): string {
|
|
620
|
+
if (!used.has(filename)) return filename;
|
|
621
|
+
const dot = filename.lastIndexOf('.');
|
|
622
|
+
const base = dot > 0 ? filename.slice(0, dot) : filename;
|
|
623
|
+
const ext = dot > 0 ? filename.slice(dot) : '';
|
|
624
|
+
let suffix = 2;
|
|
625
|
+
while (used.has(`${base}-${suffix}${ext}`)) suffix++;
|
|
626
|
+
return `${base}-${suffix}${ext}`;
|
|
627
|
+
}
|
|
628
|
+
|
|
564
629
|
// ── SMIL Media Overlays ───────────────────────────────────────────
|
|
565
630
|
|
|
566
631
|
/**
|
|
@@ -778,10 +843,14 @@ ${navItems}
|
|
|
778
843
|
|
|
779
844
|
// ── Stylesheet ────────────────────────────────────────────────────
|
|
780
845
|
|
|
781
|
-
function generateStylesheet(
|
|
846
|
+
function generateStylesheet(
|
|
847
|
+
themeId: string | undefined,
|
|
848
|
+
doc?: MarkdownDocument,
|
|
849
|
+
registry?: ThemeRegistry,
|
|
850
|
+
): string {
|
|
782
851
|
let themeVars = '';
|
|
783
852
|
if (themeId) {
|
|
784
|
-
const theme = resolveThemeForDoc(doc, themeId);
|
|
853
|
+
const theme = resolveThemeForDoc(doc, themeId, registry);
|
|
785
854
|
themeVars = `
|
|
786
855
|
--epub-bg: ${theme.colors.background};
|
|
787
856
|
--epub-text: ${theme.colors.text};
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
HtmlNode,
|
|
26
26
|
} from '@bendyline/squisq/markdown';
|
|
27
27
|
import { markdownToDoc } from '@bendyline/squisq/doc';
|
|
28
|
-
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
28
|
+
import type { Block, Doc, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
29
29
|
import { generateExternalHtml } from './htmlTemplate.js';
|
|
30
30
|
import {
|
|
31
31
|
collectLinkRefs,
|
|
@@ -51,6 +51,8 @@ export interface HtmlBundleOptions {
|
|
|
51
51
|
title?: string;
|
|
52
52
|
/** Theme id applied uniformly to every page. */
|
|
53
53
|
themeId?: string;
|
|
54
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
55
|
+
themeRegistry?: ThemeRegistry;
|
|
54
56
|
/** Rendering mode for every page (default: 'static' — scrollable, link-friendly). */
|
|
55
57
|
mode?: 'slideshow' | 'static';
|
|
56
58
|
/** Maximum recursion depth (default: unlimited; cycles always handled). */
|
|
@@ -83,6 +85,7 @@ export async function markdownDocsToHtmlBundle(options: HtmlBundleOptions): Prom
|
|
|
83
85
|
playerScript,
|
|
84
86
|
title,
|
|
85
87
|
themeId,
|
|
88
|
+
themeRegistry,
|
|
86
89
|
mode = 'static',
|
|
87
90
|
maxDepth = Infinity,
|
|
88
91
|
entryAsIndex = false,
|
|
@@ -161,7 +164,6 @@ export async function markdownDocsToHtmlBundle(options: HtmlBundleOptions): Prom
|
|
|
161
164
|
// link rewrite must walk that tree on every Doc before we serialize
|
|
162
165
|
// it into the rendered HTML.
|
|
163
166
|
let doc = markdownToDoc(mdDoc);
|
|
164
|
-
if (themeId) doc = { ...doc, themeId };
|
|
165
167
|
if (linkMap.size > 0) {
|
|
166
168
|
doc = rewriteDocLinks(doc, linkMap);
|
|
167
169
|
}
|
|
@@ -179,6 +181,8 @@ export async function markdownDocsToHtmlBundle(options: HtmlBundleOptions): Prom
|
|
|
179
181
|
imageRewriteMap.size > 0 ? Object.fromEntries(imageRewriteMap.entries()) : undefined,
|
|
180
182
|
mode,
|
|
181
183
|
title: pageTitle,
|
|
184
|
+
themeId,
|
|
185
|
+
themeRegistry,
|
|
182
186
|
});
|
|
183
187
|
zip.file(htmlPath, html);
|
|
184
188
|
}
|
package/src/html/htmlTemplate.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* 2. **External** — JS is referenced via `<script src>`, images via relative paths.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { Doc, Layer, Block } from '@bendyline/squisq/schemas';
|
|
13
|
+
import type { Doc, Layer, Block, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
14
|
+
import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
|
|
14
15
|
import { arrayBufferToBase64DataUrl, inferMimeType } from './imageUtils.js';
|
|
15
16
|
|
|
16
17
|
// ── Types ──────────────────────────────────────────────────────────
|
|
@@ -48,6 +49,8 @@ export interface HtmlExportOptions {
|
|
|
48
49
|
* so the SquisqPlayer renders with that theme's colors and typography.
|
|
49
50
|
*/
|
|
50
51
|
themeId?: string;
|
|
52
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
53
|
+
themeRegistry?: ThemeRegistry;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
// ── Image Path Collection ──────────────────────────────────────────
|
|
@@ -230,12 +233,10 @@ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string
|
|
|
230
233
|
title = 'Squisq Document',
|
|
231
234
|
autoPlay = false,
|
|
232
235
|
themeId,
|
|
236
|
+
themeRegistry,
|
|
233
237
|
} = options;
|
|
234
238
|
|
|
235
|
-
|
|
236
|
-
if (themeId) {
|
|
237
|
-
doc = { ...doc, themeId };
|
|
238
|
-
}
|
|
239
|
+
doc = applyThemeSelection(doc, themeId, themeRegistry);
|
|
239
240
|
|
|
240
241
|
// Build base64 image map
|
|
241
242
|
const imageMap: Record<string, string> = {};
|
|
@@ -291,7 +292,7 @@ ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;backg
|
|
|
291
292
|
*/
|
|
292
293
|
export function generateExternalHtml(
|
|
293
294
|
doc: Doc,
|
|
294
|
-
options: Pick<HtmlExportOptions, 'mode' | 'title' | 'autoPlay'> & {
|
|
295
|
+
options: Pick<HtmlExportOptions, 'mode' | 'title' | 'autoPlay' | 'themeId' | 'themeRegistry'> & {
|
|
295
296
|
/** Relative path to the player JS file (e.g., 'squisq-player.js') */
|
|
296
297
|
playerScriptPath: string;
|
|
297
298
|
/** Map of original image paths to their rewritten relative paths in the ZIP */
|
|
@@ -307,8 +308,12 @@ export function generateExternalHtml(
|
|
|
307
308
|
mode = 'slideshow',
|
|
308
309
|
title = 'Squisq Document',
|
|
309
310
|
autoPlay = false,
|
|
311
|
+
themeId,
|
|
312
|
+
themeRegistry,
|
|
310
313
|
} = options;
|
|
311
314
|
|
|
315
|
+
doc = applyThemeSelection(doc, themeId, themeRegistry);
|
|
316
|
+
|
|
312
317
|
const docJson = escapeForScript(JSON.stringify(doc));
|
|
313
318
|
const imageMapJson = imagePathMap ? escapeForScript(JSON.stringify(imagePathMap)) : '{}';
|
|
314
319
|
const audioMapJson = audioPathMap ? escapeForScript(JSON.stringify(audioPathMap)) : 'null';
|
|
@@ -347,6 +352,26 @@ ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;backg
|
|
|
347
352
|
</html>`;
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
function applyThemeSelection(
|
|
356
|
+
doc: Doc,
|
|
357
|
+
themeId: string | undefined,
|
|
358
|
+
registry: ThemeRegistry | undefined,
|
|
359
|
+
): Doc {
|
|
360
|
+
const selectedId = themeId ?? doc.themeId ?? readFrontmatterThemeId(doc.frontmatter);
|
|
361
|
+
if (!selectedId) return doc;
|
|
362
|
+
const selectedDocTheme = doc.customThemes?.some((theme) => theme.id === selectedId);
|
|
363
|
+
if (selectedDocTheme || !registry) {
|
|
364
|
+
return selectedId === doc.themeId ? doc : { ...doc, themeId: selectedId };
|
|
365
|
+
}
|
|
366
|
+
const registered = registry.get(selectedId);
|
|
367
|
+
if (!registered) return selectedId === doc.themeId ? doc : { ...doc, themeId: selectedId };
|
|
368
|
+
return {
|
|
369
|
+
...doc,
|
|
370
|
+
themeId: selectedId,
|
|
371
|
+
customThemes: [...(doc.customThemes ?? []), registered],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
350
375
|
/**
|
|
351
376
|
* Escape HTML special characters to prevent injection.
|
|
352
377
|
*/
|
package/src/html/imageUtils.ts
CHANGED
|
@@ -28,12 +28,21 @@ export function inferMimeType(filename: string): string {
|
|
|
28
28
|
*/
|
|
29
29
|
export function arrayBufferToBase64DataUrl(buffer: ArrayBuffer, mimeType: string): string {
|
|
30
30
|
const bytes = new Uint8Array(buffer);
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// Keep chunks divisible by three so concatenating their independently
|
|
32
|
+
// encoded results cannot introduce padding in the middle of the stream.
|
|
33
|
+
// This avoids both quadratic string concatenation and one image-sized
|
|
34
|
+
// intermediate binary string for large embedded assets.
|
|
35
|
+
const chunkSize = 3 * 8192;
|
|
36
|
+
const chunks: string[] = [];
|
|
37
|
+
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
|
38
|
+
const end = Math.min(offset + chunkSize, bytes.length);
|
|
39
|
+
let binary = '';
|
|
40
|
+
for (let i = offset; i < end; i++) {
|
|
41
|
+
binary += String.fromCharCode(bytes[i]);
|
|
42
|
+
}
|
|
43
|
+
chunks.push(btoa(binary));
|
|
34
44
|
}
|
|
35
|
-
|
|
36
|
-
return `data:${mimeType};base64,${base64}`;
|
|
45
|
+
return `data:${mimeType};base64,${chunks.join('')}`;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
/**
|
package/src/html/index.ts
CHANGED
|
@@ -127,7 +127,16 @@ export function docToHtml(doc: Doc, options: HtmlExportOptions): string {
|
|
|
127
127
|
* ```
|
|
128
128
|
*/
|
|
129
129
|
export async function docToHtmlZip(doc: Doc, options: HtmlZipExportOptions): Promise<Blob> {
|
|
130
|
-
const {
|
|
130
|
+
const {
|
|
131
|
+
playerScript,
|
|
132
|
+
images,
|
|
133
|
+
audio,
|
|
134
|
+
mode = 'slideshow',
|
|
135
|
+
title,
|
|
136
|
+
autoPlay,
|
|
137
|
+
themeId,
|
|
138
|
+
themeRegistry,
|
|
139
|
+
} = options;
|
|
131
140
|
|
|
132
141
|
const zip = new JSZip();
|
|
133
142
|
|
|
@@ -174,6 +183,8 @@ export async function docToHtmlZip(doc: Doc, options: HtmlZipExportOptions): Pro
|
|
|
174
183
|
mode,
|
|
175
184
|
title,
|
|
176
185
|
autoPlay,
|
|
186
|
+
themeId,
|
|
187
|
+
themeRegistry,
|
|
177
188
|
});
|
|
178
189
|
zip.file('index.html', html);
|
|
179
190
|
|
package/src/html/plainHtml.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
type HtmlNode,
|
|
24
24
|
readFrontmatterThemeId,
|
|
25
25
|
} from '@bendyline/squisq/markdown';
|
|
26
|
-
import type { Theme } from '@bendyline/squisq/schemas';
|
|
26
|
+
import type { Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
27
27
|
import { resolveFontFamily, buildGoogleFontsUrl } from '@bendyline/squisq/schemas';
|
|
28
28
|
import { resolveThemeForDoc } from '@bendyline/squisq/doc';
|
|
29
29
|
|
|
@@ -68,6 +68,8 @@ export interface PlainHtmlExportOptions {
|
|
|
68
68
|
* When both `theme` and `themeId` are provided, `theme` wins.
|
|
69
69
|
*/
|
|
70
70
|
themeId?: string;
|
|
71
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
72
|
+
themeRegistry?: ThemeRegistry;
|
|
71
73
|
/**
|
|
72
74
|
* Optional FontAwesome CSS text to inline into the rendered page,
|
|
73
75
|
* replacing the default cross-origin `<link>` to cdnjs. Required for
|
|
@@ -124,7 +126,9 @@ export function markdownDocToPlainHtml(
|
|
|
124
126
|
// `squisq-theme` key. Stays undefined when nothing selects a theme so
|
|
125
127
|
// un-themed exports render unstyled (unchanged behavior).
|
|
126
128
|
const resolveId = themeId ?? readFrontmatterThemeId(doc.frontmatter);
|
|
127
|
-
const theme =
|
|
129
|
+
const theme =
|
|
130
|
+
options.theme ??
|
|
131
|
+
(resolveId ? resolveThemeForDoc(doc, resolveId, options.themeRegistry) : undefined);
|
|
128
132
|
const ctx: RenderCtx = { images, links, htmlPolicy };
|
|
129
133
|
const body = renderTopLevel(doc.children, ctx);
|
|
130
134
|
const fontsLink = theme ? renderFontsLink(theme) : '';
|
|
@@ -187,7 +191,7 @@ function docUsesIcons(doc: MarkdownDocument): boolean {
|
|
|
187
191
|
* blocks that follow them (up to the next sibling-or-higher heading).
|
|
188
192
|
* Each group renders as a single `<section class="squisq-feature ...">`
|
|
189
193
|
* with a media column and a text column — the plain-HTML analogue of
|
|
190
|
-
* the SVG layer layout produced by `
|
|
194
|
+
* the SVG layer layout produced by `materializeBlockLayers` for the same templates.
|
|
191
195
|
*/
|
|
192
196
|
function renderTopLevel(children: MarkdownNode[], ctx: RenderCtx | undefined): string {
|
|
193
197
|
const out: string[] = [];
|
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
import JSZip from 'jszip';
|
|
19
19
|
import { parseMarkdown, inferDocumentTitle } from '@bendyline/squisq/markdown';
|
|
20
20
|
import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
|
|
21
|
-
import type { Theme } from '@bendyline/squisq/schemas';
|
|
22
|
-
import { resolveTheme } from '@bendyline/squisq/schemas';
|
|
21
|
+
import type { Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
|
|
23
22
|
import { markdownDocToPlainHtml } from './plainHtml.js';
|
|
24
23
|
|
|
25
24
|
// ── Public Types ───────────────────────────────────────────────────
|
|
@@ -36,13 +35,14 @@ export interface PlainHtmlBundleOptions {
|
|
|
36
35
|
/** Optional theme applied uniformly to every page. Overrides {@link themeId}. */
|
|
37
36
|
theme?: Theme;
|
|
38
37
|
/**
|
|
39
|
-
* Optional theme id (e.g. `'warm-earth'`, `'gezellig'`)
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `themeId` are supplied, `theme` wins.
|
|
38
|
+
* Optional theme id (e.g. `'warm-earth'`, `'gezellig'`) applied to every
|
|
39
|
+
* page. Each document resolves its own inline definition before consulting
|
|
40
|
+
* {@link themeRegistry}. When both `theme` and `themeId` are supplied,
|
|
41
|
+
* `theme` wins.
|
|
44
42
|
*/
|
|
45
43
|
themeId?: string;
|
|
44
|
+
/** Explicit caller-owned registry for non-document custom themes. */
|
|
45
|
+
themeRegistry?: ThemeRegistry;
|
|
46
46
|
/** Maximum recursion depth (default: unlimited; cycles always handled). */
|
|
47
47
|
maxDepth?: number;
|
|
48
48
|
/**
|
|
@@ -74,10 +74,10 @@ export async function markdownDocsToPlainHtmlBundle(
|
|
|
74
74
|
title,
|
|
75
75
|
theme,
|
|
76
76
|
themeId,
|
|
77
|
+
themeRegistry,
|
|
77
78
|
maxDepth = Infinity,
|
|
78
79
|
entryAsIndex = false,
|
|
79
80
|
} = options;
|
|
80
|
-
const resolvedTheme = theme ?? (themeId ? resolveTheme(themeId) : undefined);
|
|
81
81
|
const entry = normalizePath(entryPath);
|
|
82
82
|
if (!entry) {
|
|
83
83
|
throw new Error('markdownDocsToPlainHtmlBundle: entryPath is required');
|
|
@@ -165,7 +165,9 @@ export async function markdownDocsToPlainHtmlBundle(
|
|
|
165
165
|
title: docTitle ?? titleForFilename(path, mdDoc),
|
|
166
166
|
images: imageRewriteMap,
|
|
167
167
|
links: linkMap,
|
|
168
|
-
theme
|
|
168
|
+
theme,
|
|
169
|
+
themeId,
|
|
170
|
+
themeRegistry,
|
|
169
171
|
});
|
|
170
172
|
|
|
171
173
|
const htmlPath = htmlPathFor(path);
|
package/src/index.ts
CHANGED
|
@@ -64,15 +64,33 @@ export type { HtmlImportOptions } from './html/index.js';
|
|
|
64
64
|
export { markdownDocToEpub, docToEpub } from './epub/index.js';
|
|
65
65
|
export type { EpubExportOptions } from './epub/index.js';
|
|
66
66
|
|
|
67
|
+
// Theme inference from file imports (DOCX/PPTX/XLSX theme1.xml → Squisq Theme)
|
|
68
|
+
export { inferThemeFromFile, compileExtractedTheme } from './infer/index.js';
|
|
69
|
+
export type {
|
|
70
|
+
InferThemeOptions,
|
|
71
|
+
InferredFileTheme,
|
|
72
|
+
ExtractedFileTheme,
|
|
73
|
+
InferSourceFormat,
|
|
74
|
+
} from './infer/index.js';
|
|
75
|
+
|
|
67
76
|
// Format registry + programmatic convert()
|
|
68
77
|
export {
|
|
69
78
|
convert,
|
|
79
|
+
prepareConversion,
|
|
70
80
|
createRegistry,
|
|
71
81
|
defaultRegistry,
|
|
72
82
|
defaultFormats,
|
|
73
83
|
ConversionError,
|
|
74
84
|
BUILTIN_FORMAT_IDS,
|
|
75
85
|
} from './registry/index.js';
|
|
86
|
+
|
|
87
|
+
// Shared bounded-decompression errors/options used by DBK and OOXML imports.
|
|
88
|
+
export { ZipSafetyError } from './shared/zipSafety.js';
|
|
89
|
+
export type {
|
|
90
|
+
ZipSafetyLimits,
|
|
91
|
+
ZipSafetyErrorCode,
|
|
92
|
+
ZipSafetyErrorOptions,
|
|
93
|
+
} from './shared/zipSafety.js';
|
|
76
94
|
export type {
|
|
77
95
|
FormatId,
|
|
78
96
|
ConversionResult,
|
|
@@ -83,4 +101,9 @@ export type {
|
|
|
83
101
|
ConvertSource,
|
|
84
102
|
ConversionErrorCode,
|
|
85
103
|
ConversionErrorOptions,
|
|
104
|
+
BuiltinFormatOptions,
|
|
105
|
+
MarkdownFormatOptions,
|
|
106
|
+
DbkFormatOptions,
|
|
107
|
+
PreparedConversion,
|
|
108
|
+
PreparedExportOptions,
|
|
86
109
|
} from './registry/index.js';
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-format theme extraction: locate the theme part of an already-opened
|
|
3
|
+
* OOXML package, parse it, and resolve the document's background/text color
|
|
4
|
+
* mapping. Returns null when the file has no theme part at all.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OoxmlPackage } from '../ooxml/types.js';
|
|
8
|
+
import { getPartRelationships, getPartXml } from '../ooxml/reader.js';
|
|
9
|
+
import { CONTENT_TYPE_PPTX_THEME, NS_PML, NS_WML, REL_SLIDE_MASTER } from '../ooxml/namespaces.js';
|
|
10
|
+
import { findRelByType, resolveTarget } from '../ooxml/readUtils.js';
|
|
11
|
+
import type { OoxmlTheme } from '../ooxml/themeReader.js';
|
|
12
|
+
import { parseThemeXml, readThemePart } from '../ooxml/themeReader.js';
|
|
13
|
+
import type { ExtractedFileTheme, SchemeSlot } from './types.js';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_COLOR_MAP: { bg1: SchemeSlot; tx1: SchemeSlot } = { bg1: 'lt1', tx1: 'dk1' };
|
|
16
|
+
|
|
17
|
+
const SCHEME_SLOTS: readonly SchemeSlot[] = ['dk1', 'lt1', 'dk2', 'lt2'];
|
|
18
|
+
|
|
19
|
+
function asSchemeSlot(value: string | null | undefined): SchemeSlot | undefined {
|
|
20
|
+
return SCHEME_SLOTS.includes(value as SchemeSlot) ? (value as SchemeSlot) : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Fallback theme lookup for packages whose theme relationship is missing or
|
|
25
|
+
* unconventional: scan `[Content_Types].xml` overrides for the theme content
|
|
26
|
+
* type (shared by all three formats) and parse the first match.
|
|
27
|
+
*/
|
|
28
|
+
async function readThemeByContentType(pkg: OoxmlPackage): Promise<OoxmlTheme | null> {
|
|
29
|
+
for (const [partPath, contentType] of pkg.contentTypes.overrides) {
|
|
30
|
+
if (contentType !== CONTENT_TYPE_PPTX_THEME) continue;
|
|
31
|
+
const doc = await getPartXml(pkg, partPath);
|
|
32
|
+
if (doc) return parseThemeXml(doc);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toExtracted(
|
|
38
|
+
sourceFormat: ExtractedFileTheme['sourceFormat'],
|
|
39
|
+
theme: OoxmlTheme,
|
|
40
|
+
colorMap: { bg1: SchemeSlot; tx1: SchemeSlot },
|
|
41
|
+
extraWarnings: string[] = [],
|
|
42
|
+
): ExtractedFileTheme {
|
|
43
|
+
return {
|
|
44
|
+
sourceFormat,
|
|
45
|
+
...(theme.name ? { themeName: theme.name } : {}),
|
|
46
|
+
...(theme.colors ? { colors: theme.colors } : {}),
|
|
47
|
+
colorMap,
|
|
48
|
+
...(theme.fonts ? { fonts: theme.fonts } : {}),
|
|
49
|
+
warnings: [...theme.warnings, ...extraWarnings],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* DOCX: theme hangs off `word/document.xml`; an optional
|
|
55
|
+
* `w:clrSchemeMapping` in `word/settings.xml` remaps bg1/t1 slots
|
|
56
|
+
* (values `light1`/`dark1`/`light2`/`dark2`).
|
|
57
|
+
*/
|
|
58
|
+
export async function extractDocxTheme(pkg: OoxmlPackage): Promise<ExtractedFileTheme | null> {
|
|
59
|
+
const theme =
|
|
60
|
+
(await readThemePart(pkg, 'word/document.xml')) ?? (await readThemeByContentType(pkg));
|
|
61
|
+
if (!theme) return null;
|
|
62
|
+
|
|
63
|
+
let colorMap = DEFAULT_COLOR_MAP;
|
|
64
|
+
const settings = await getPartXml(pkg, 'word/settings.xml');
|
|
65
|
+
const mappingEls = settings?.getElementsByTagNameNS(NS_WML, 'clrSchemeMapping');
|
|
66
|
+
const mapping = mappingEls && mappingEls.length > 0 ? mappingEls[0]! : null;
|
|
67
|
+
if (mapping) {
|
|
68
|
+
const wordSlotToScheme: Record<string, SchemeSlot> = {
|
|
69
|
+
light1: 'lt1',
|
|
70
|
+
dark1: 'dk1',
|
|
71
|
+
light2: 'lt2',
|
|
72
|
+
dark2: 'dk2',
|
|
73
|
+
};
|
|
74
|
+
const bg1 =
|
|
75
|
+
wordSlotToScheme[
|
|
76
|
+
mapping.getAttributeNS(NS_WML, 'bg1') ?? mapping.getAttribute('w:bg1') ?? ''
|
|
77
|
+
];
|
|
78
|
+
const tx1 =
|
|
79
|
+
wordSlotToScheme[mapping.getAttributeNS(NS_WML, 't1') ?? mapping.getAttribute('w:t1') ?? ''];
|
|
80
|
+
if (bg1 && tx1) colorMap = { bg1, tx1 };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return toExtracted('docx', theme, colorMap);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* PPTX: theme hangs off the first slide master, whose `<p:clrMap>` records
|
|
88
|
+
* which scheme slots back the deck's background/text (dark decks set
|
|
89
|
+
* `bg1="dk1" tx1="lt1"`).
|
|
90
|
+
*/
|
|
91
|
+
export async function extractPptxTheme(pkg: OoxmlPackage): Promise<ExtractedFileTheme | null> {
|
|
92
|
+
const presRels = await getPartRelationships(pkg, 'ppt/presentation.xml');
|
|
93
|
+
const masterRel = findRelByType(presRels, REL_SLIDE_MASTER);
|
|
94
|
+
const masterPath = masterRel ? resolveTarget('ppt', masterRel.target) : undefined;
|
|
95
|
+
|
|
96
|
+
const warnings: string[] = [];
|
|
97
|
+
let theme: OoxmlTheme | null = null;
|
|
98
|
+
let colorMap = DEFAULT_COLOR_MAP;
|
|
99
|
+
|
|
100
|
+
if (masterPath) {
|
|
101
|
+
theme = await readThemePart(pkg, masterPath);
|
|
102
|
+
const masterDoc = await getPartXml(pkg, masterPath);
|
|
103
|
+
const clrMaps = masterDoc?.getElementsByTagNameNS(NS_PML, 'clrMap');
|
|
104
|
+
const clrMap = clrMaps && clrMaps.length > 0 ? clrMaps[0]! : null;
|
|
105
|
+
if (clrMap) {
|
|
106
|
+
const bg1 = asSchemeSlot(clrMap.getAttribute('bg1'));
|
|
107
|
+
const tx1 = asSchemeSlot(clrMap.getAttribute('tx1'));
|
|
108
|
+
if (bg1 && tx1) {
|
|
109
|
+
colorMap = { bg1, tx1 };
|
|
110
|
+
} else if (clrMap.getAttribute('bg1') || clrMap.getAttribute('tx1')) {
|
|
111
|
+
warnings.push('theme: unsupported clrMap slot mapping; using default bg1/tx1');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!theme) theme = await readThemeByContentType(pkg);
|
|
116
|
+
if (!theme) return null;
|
|
117
|
+
|
|
118
|
+
return toExtracted('pptx', theme, colorMap, warnings);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** XLSX: theme hangs off `xl/workbook.xml`; no color remapping exists. */
|
|
122
|
+
export async function extractXlsxTheme(pkg: OoxmlPackage): Promise<ExtractedFileTheme | null> {
|
|
123
|
+
const theme =
|
|
124
|
+
(await readThemePart(pkg, 'xl/workbook.xml')) ?? (await readThemeByContentType(pkg));
|
|
125
|
+
if (!theme) return null;
|
|
126
|
+
return toExtracted('xlsx', theme, DEFAULT_COLOR_MAP);
|
|
127
|
+
}
|