@bendyline/squisq-formats 1.4.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +48 -2
  2. package/dist/chunk-2KPARF2P.js +99 -0
  3. package/dist/chunk-2KPARF2P.js.map +1 -0
  4. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  5. package/dist/chunk-4VUWTSGM.js.map +1 -0
  6. package/dist/{chunk-YESS7VY3.js → chunk-ABVI556T.js} +154 -39
  7. package/dist/chunk-ABVI556T.js.map +1 -0
  8. package/dist/{chunk-WWBIKRNP.js → chunk-CRAVSMPZ.js} +13 -11
  9. package/dist/chunk-CRAVSMPZ.js.map +1 -0
  10. package/dist/chunk-FE6OJV6O.js +560 -0
  11. package/dist/chunk-FE6OJV6O.js.map +1 -0
  12. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  13. package/dist/chunk-GVS2XXV6.js.map +1 -0
  14. package/dist/{chunk-RTS5XBZ7.js → chunk-HTW2M27H.js} +341 -100
  15. package/dist/chunk-HTW2M27H.js.map +1 -0
  16. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  17. package/dist/chunk-LXYLOOST.js.map +1 -0
  18. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  19. package/dist/chunk-MLX2BOJC.js.map +1 -0
  20. package/dist/chunk-NKAJPJ4G.js +106 -0
  21. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  22. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  23. package/dist/chunk-ODL3SSPT.js +18 -0
  24. package/dist/chunk-ODL3SSPT.js.map +1 -0
  25. package/dist/chunk-QRVN6A6E.js +127 -0
  26. package/dist/chunk-QRVN6A6E.js.map +1 -0
  27. package/dist/chunk-U32AG3G3.js +195 -0
  28. package/dist/chunk-U32AG3G3.js.map +1 -0
  29. package/dist/{chunk-EHLGMSTE.js → chunk-VJJM2SSH.js} +10 -8
  30. package/dist/chunk-VJJM2SSH.js.map +1 -0
  31. package/dist/chunk-VSYHZECT.js +778 -0
  32. package/dist/chunk-VSYHZECT.js.map +1 -0
  33. package/dist/{chunk-SHLPSADL.js → chunk-WC7WULGV.js} +81 -189
  34. package/dist/chunk-WC7WULGV.js.map +1 -0
  35. package/dist/chunk-XKUMNGBW.js +131 -0
  36. package/dist/chunk-XKUMNGBW.js.map +1 -0
  37. package/dist/chunk-YRT7GQ5Y.js +28 -0
  38. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  39. package/dist/container/index.d.ts +13 -3
  40. package/dist/container/index.js +23 -20
  41. package/dist/container/index.js.map +1 -1
  42. package/dist/docx/index.d.ts +6 -2
  43. package/dist/docx/index.js +5 -3
  44. package/dist/epub/index.d.ts +3 -1
  45. package/dist/epub/index.js +3 -3
  46. package/dist/export-D2NkylDT.d.ts +69 -0
  47. package/dist/extract-H6RXJMHP.js +15 -0
  48. package/dist/extract-H6RXJMHP.js.map +1 -0
  49. package/dist/html/index.d.ts +15 -85
  50. package/dist/html/index.js +3 -3
  51. package/dist/images-7FBWPKE3.js +7 -0
  52. package/dist/images-7FBWPKE3.js.map +1 -0
  53. package/dist/import-DTkDxHmZ.d.ts +84 -0
  54. package/dist/import-K8mfc0fz.d.ts +136 -0
  55. package/dist/index.d.ts +14 -4
  56. package/dist/index.js +33 -12
  57. package/dist/infer/index.d.ts +138 -0
  58. package/dist/infer/index.js +28 -0
  59. package/dist/infer/index.js.map +1 -0
  60. package/dist/layouts-BHrgZ5FS.d.ts +150 -0
  61. package/dist/layouts-QVPK3ZCU.js +12 -0
  62. package/dist/layouts-QVPK3ZCU.js.map +1 -0
  63. package/dist/mapTheme-IR27S6IV.js +11 -0
  64. package/dist/mapTheme-IR27S6IV.js.map +1 -0
  65. package/dist/ooxml/index.d.ts +21 -127
  66. package/dist/ooxml/index.js +31 -9
  67. package/dist/pdf/index.d.ts +3 -1
  68. package/dist/pdf/index.js +1 -1
  69. package/dist/pptx/index.d.ts +8 -108
  70. package/dist/pptx/index.js +13 -4
  71. package/dist/reader-B9L8Ucbj.d.ts +140 -0
  72. package/dist/registry/index.d.ts +60 -8
  73. package/dist/registry/index.js +9 -4
  74. package/dist/themeReader-DJKErl_j.d.ts +56 -0
  75. package/dist/xlsx/index.d.ts +6 -66
  76. package/dist/xlsx/index.js +4 -2
  77. package/dist/zipLimits-BOKCB7qk.d.ts +31 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +310 -1
  81. package/src/__tests__/epub.test.ts +102 -1
  82. package/src/__tests__/html.test.ts +62 -0
  83. package/src/__tests__/inferTheme.test.ts +135 -0
  84. package/src/__tests__/lossyWarnings.test.ts +41 -0
  85. package/src/__tests__/ooxml.test.ts +76 -2
  86. package/src/__tests__/ooxmlCancellation.test.ts +113 -0
  87. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  88. package/src/__tests__/plainHtml.test.ts +15 -1
  89. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  90. package/src/__tests__/pptxImport.test.ts +30 -0
  91. package/src/__tests__/pptxInferFixtures.ts +314 -0
  92. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  93. package/src/__tests__/zipSafety.test.ts +317 -0
  94. package/src/container/index.ts +37 -32
  95. package/src/docx/export.ts +4 -2
  96. package/src/docx/import.ts +4 -3
  97. package/src/epub/export.ts +94 -25
  98. package/src/html/docsHtmlBundle.ts +6 -2
  99. package/src/html/htmlTemplate.ts +31 -6
  100. package/src/html/imageUtils.ts +14 -5
  101. package/src/html/index.ts +12 -1
  102. package/src/html/plainHtml.ts +7 -3
  103. package/src/html/plainHtmlBundle.ts +11 -9
  104. package/src/index.ts +23 -0
  105. package/src/infer/extract.ts +127 -0
  106. package/src/infer/index.ts +199 -0
  107. package/src/infer/mapTheme.ts +176 -0
  108. package/src/infer/types.ts +27 -0
  109. package/src/ooxml/index.ts +14 -0
  110. package/src/ooxml/readUtils.ts +44 -0
  111. package/src/ooxml/reader.ts +129 -29
  112. package/src/ooxml/themeReader.ts +197 -0
  113. package/src/ooxml/types.ts +8 -9
  114. package/src/ooxml/writer.ts +28 -10
  115. package/src/pdf/export.ts +4 -2
  116. package/src/pdf/import.ts +40 -34
  117. package/src/pptx/export.ts +4 -2
  118. package/src/pptx/import.ts +214 -32
  119. package/src/pptx/index.ts +14 -0
  120. package/src/pptx/layouts.ts +1222 -0
  121. package/src/registry/convert.ts +168 -53
  122. package/src/registry/defaultFormats.ts +135 -45
  123. package/src/registry/index.ts +6 -1
  124. package/src/registry/types.ts +69 -6
  125. package/src/shared/boundedZipArchive.ts +383 -0
  126. package/src/shared/fidelity.ts +130 -0
  127. package/src/shared/zipEntryCount.ts +151 -0
  128. package/src/shared/zipLimits.ts +296 -0
  129. package/src/shared/zipSafety.ts +19 -0
  130. package/src/xlsx/import.ts +3 -2
  131. package/dist/chunk-46FMDOWZ.js.map +0 -1
  132. package/dist/chunk-5LSSWZPU.js.map +0 -1
  133. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  134. package/dist/chunk-EHLGMSTE.js.map +0 -1
  135. package/dist/chunk-MJGRI6XR.js.map +0 -1
  136. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  137. package/dist/chunk-SHLPSADL.js.map +0 -1
  138. package/dist/chunk-WWBIKRNP.js.map +0 -1
  139. package/dist/chunk-YESS7VY3.js.map +0 -1
  140. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -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 = crypto.randomUUID();
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
- let filename = extractFilename(src);
110
- // Deduplicate: if two paths share a basename (e.g. a/hero.png and b/hero.png)
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(options.themeId ?? readFrontmatterThemeId(doc.frontmatter), doc);
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 filename = extractFilename(seg.src);
193
- const finalName = filename.includes('.') ? filename : `${filename}.mp3`;
194
- zip.file(`OEBPS/audio/${finalName}`, data);
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(node.url)}"${titleAttr}>${inlinesToXhtml(node.children, images)}</a>`;
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 src = resolved ? `../images/${resolved.filename}` : escapeXml(node.url);
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(themeId: string | undefined, doc?: MarkdownDocument): string {
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
  }
@@ -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
- // Apply theme to doc if specified (the SquisqPlayer respects doc.themeId at render time)
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
  */
@@ -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
- let binary = '';
32
- for (let i = 0; i < bytes.length; i++) {
33
- binary += String.fromCharCode(bytes[i]);
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
- const base64 = btoa(binary);
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 { playerScript, images, audio, mode = 'slideshow', title, autoPlay } = options;
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
 
@@ -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 = options.theme ?? (resolveId ? resolveThemeForDoc(doc, resolveId) : undefined);
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 `getLayers` for the same templates.
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'`). Resolved via
40
- * `resolveTheme` and applied to every page. Convenient for callers
41
- * that track themes by id (like the host export dialog) without
42
- * having to resolve to a `Theme` object first. When both `theme` and
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: resolvedTheme,
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
+ }