@bendyline/squisq-formats 1.4.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/README.md +48 -2
  2. package/dist/{chunk-WWBIKRNP.js → chunk-2CSZJEAL.js} +13 -11
  3. package/dist/chunk-2CSZJEAL.js.map +1 -0
  4. package/dist/{chunk-RTS5XBZ7.js → chunk-2EFDWKC6.js} +262 -84
  5. package/dist/chunk-2EFDWKC6.js.map +1 -0
  6. package/dist/chunk-2PRVLAU3.js +740 -0
  7. package/dist/chunk-2PRVLAU3.js.map +1 -0
  8. package/dist/{chunk-SHLPSADL.js → chunk-4HJ6AK2Q.js} +54 -193
  9. package/dist/chunk-4HJ6AK2Q.js.map +1 -0
  10. package/dist/{chunk-5LSSWZPU.js → chunk-4VUWTSGM.js} +72 -25
  11. package/dist/chunk-4VUWTSGM.js.map +1 -0
  12. package/dist/{chunk-YESS7VY3.js → chunk-CVNVD7VH.js} +154 -39
  13. package/dist/chunk-CVNVD7VH.js.map +1 -0
  14. package/dist/chunk-CX3LH47J.js +99 -0
  15. package/dist/chunk-CX3LH47J.js.map +1 -0
  16. package/dist/{chunk-D7IFPWNZ.js → chunk-GVS2XXV6.js} +27 -26
  17. package/dist/chunk-GVS2XXV6.js.map +1 -0
  18. package/dist/chunk-H7T5DXUU.js +479 -0
  19. package/dist/chunk-H7T5DXUU.js.map +1 -0
  20. package/dist/{chunk-EHLGMSTE.js → chunk-HBSCRS5P.js} +10 -8
  21. package/dist/chunk-HBSCRS5P.js.map +1 -0
  22. package/dist/{chunk-46FMDOWZ.js → chunk-LXYLOOST.js} +49 -17
  23. package/dist/chunk-LXYLOOST.js.map +1 -0
  24. package/dist/{chunk-MJGRI6XR.js → chunk-MLX2BOJC.js} +12 -7
  25. package/dist/chunk-MLX2BOJC.js.map +1 -0
  26. package/dist/chunk-NKAJPJ4G.js +106 -0
  27. package/dist/chunk-NKAJPJ4G.js.map +1 -0
  28. package/dist/{chunk-DQAZR57U.js → chunk-O3GVVND4.js} +1 -1
  29. package/dist/chunk-ODL3SSPT.js +18 -0
  30. package/dist/chunk-ODL3SSPT.js.map +1 -0
  31. package/dist/chunk-X4USWOQ2.js +131 -0
  32. package/dist/chunk-X4USWOQ2.js.map +1 -0
  33. package/dist/chunk-XTLQXR4U.js +195 -0
  34. package/dist/chunk-XTLQXR4U.js.map +1 -0
  35. package/dist/chunk-YRT7GQ5Y.js +28 -0
  36. package/dist/chunk-YRT7GQ5Y.js.map +1 -0
  37. package/dist/chunk-ZZ7D4DN6.js +97 -0
  38. package/dist/chunk-ZZ7D4DN6.js.map +1 -0
  39. package/dist/container/index.d.ts +6 -2
  40. package/dist/container/index.js +15 -17
  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-Djev9BLU.d.ts +69 -0
  47. package/dist/extract-LNOA7DBK.js +15 -0
  48. package/dist/extract-LNOA7DBK.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-Yivc3kZM.d.ts +136 -0
  55. package/dist/index.d.ts +13 -4
  56. package/dist/index.js +30 -11
  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-7OUUVAGU.js +12 -0
  61. package/dist/layouts-7OUUVAGU.js.map +1 -0
  62. package/dist/layouts-CZ0bdsNL.d.ts +150 -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-Gof6OrJX.d.ts +140 -0
  72. package/dist/registry/index.d.ts +46 -8
  73. package/dist/registry/index.js +5 -2
  74. package/dist/themeReader-DPw4J5Ss.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-bGYMS9cI.d.ts +29 -0
  78. package/package.json +8 -2
  79. package/src/__tests__/container.test.ts +41 -0
  80. package/src/__tests__/convert.test.ts +167 -0
  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 +59 -2
  86. package/src/__tests__/ooxmlThemeReader.test.ts +92 -0
  87. package/src/__tests__/plainHtml.test.ts +15 -1
  88. package/src/__tests__/plainHtmlBundle.test.ts +18 -0
  89. package/src/__tests__/pptxImport.test.ts +30 -0
  90. package/src/__tests__/pptxInferFixtures.ts +314 -0
  91. package/src/__tests__/pptxLayoutInfer.test.ts +395 -0
  92. package/src/__tests__/zipSafety.test.ts +317 -0
  93. package/src/container/index.ts +20 -29
  94. package/src/docx/export.ts +4 -2
  95. package/src/docx/import.ts +4 -3
  96. package/src/epub/export.ts +94 -25
  97. package/src/html/docsHtmlBundle.ts +6 -2
  98. package/src/html/htmlTemplate.ts +31 -6
  99. package/src/html/imageUtils.ts +14 -5
  100. package/src/html/index.ts +12 -1
  101. package/src/html/plainHtml.ts +7 -3
  102. package/src/html/plainHtmlBundle.ts +11 -9
  103. package/src/index.ts +20 -0
  104. package/src/infer/extract.ts +127 -0
  105. package/src/infer/index.ts +168 -0
  106. package/src/infer/mapTheme.ts +176 -0
  107. package/src/infer/types.ts +27 -0
  108. package/src/ooxml/index.ts +14 -0
  109. package/src/ooxml/readUtils.ts +44 -0
  110. package/src/ooxml/reader.ts +87 -28
  111. package/src/ooxml/themeReader.ts +197 -0
  112. package/src/ooxml/types.ts +8 -9
  113. package/src/ooxml/writer.ts +28 -10
  114. package/src/pdf/export.ts +4 -2
  115. package/src/pdf/import.ts +40 -34
  116. package/src/pptx/export.ts +4 -2
  117. package/src/pptx/import.ts +214 -32
  118. package/src/pptx/index.ts +14 -0
  119. package/src/pptx/layouts.ts +1168 -0
  120. package/src/registry/convert.ts +54 -23
  121. package/src/registry/defaultFormats.ts +131 -44
  122. package/src/registry/index.ts +3 -0
  123. package/src/registry/types.ts +52 -6
  124. package/src/shared/boundedZipArchive.ts +342 -0
  125. package/src/shared/fidelity.ts +130 -0
  126. package/src/shared/zipEntryCount.ts +97 -0
  127. package/src/shared/zipLimits.ts +286 -0
  128. package/src/shared/zipSafety.ts +19 -0
  129. package/src/xlsx/import.ts +3 -2
  130. package/dist/chunk-46FMDOWZ.js.map +0 -1
  131. package/dist/chunk-5LSSWZPU.js.map +0 -1
  132. package/dist/chunk-D7IFPWNZ.js.map +0 -1
  133. package/dist/chunk-EHLGMSTE.js.map +0 -1
  134. package/dist/chunk-MJGRI6XR.js.map +0 -1
  135. package/dist/chunk-RTS5XBZ7.js.map +0 -1
  136. package/dist/chunk-SHLPSADL.js.map +0 -1
  137. package/dist/chunk-WWBIKRNP.js.map +0 -1
  138. package/dist/chunk-YESS7VY3.js.map +0 -1
  139. /package/dist/{chunk-DQAZR57U.js.map → chunk-O3GVVND4.js.map} +0 -0
@@ -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,6 +64,15 @@ 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,
@@ -73,6 +82,14 @@ export {
73
82
  ConversionError,
74
83
  BUILTIN_FORMAT_IDS,
75
84
  } from './registry/index.js';
85
+
86
+ // Shared bounded-decompression errors/options used by DBK and OOXML imports.
87
+ export { ZipSafetyError } from './shared/zipSafety.js';
88
+ export type {
89
+ ZipSafetyLimits,
90
+ ZipSafetyErrorCode,
91
+ ZipSafetyErrorOptions,
92
+ } from './shared/zipSafety.js';
76
93
  export type {
77
94
  FormatId,
78
95
  ConversionResult,
@@ -83,4 +100,7 @@ export type {
83
100
  ConvertSource,
84
101
  ConversionErrorCode,
85
102
  ConversionErrorOptions,
103
+ BuiltinFormatOptions,
104
+ MarkdownFormatOptions,
105
+ DbkFormatOptions,
86
106
  } 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
+ }
@@ -0,0 +1,168 @@
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 } 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): Promise<boolean> {
57
+ // Duck-typed (not instanceof): buffers routinely cross realms in test
58
+ // environments and web workers. Blobs carry `size`; ArrayBuffers carry
59
+ // `byteLength`.
60
+ let head: Uint8Array | null = null;
61
+ const blob = data as Blob;
62
+ if (
63
+ typeof blob.size === 'number' &&
64
+ typeof blob.slice === 'function' &&
65
+ typeof blob.arrayBuffer === 'function'
66
+ ) {
67
+ head = new Uint8Array(await blob.slice(0, 5).arrayBuffer());
68
+ } else if (typeof (data as ArrayBuffer).byteLength === 'number') {
69
+ const buf = data as ArrayBuffer;
70
+ head = new Uint8Array(buf, 0, Math.min(5, buf.byteLength));
71
+ }
72
+ if (!head || head.length < 4) return false;
73
+ // '%PDF'
74
+ return head[0] === 0x25 && head[1] === 0x50 && head[2] === 0x44 && head[3] === 0x46;
75
+ }
76
+
77
+ /**
78
+ * Classify an opened package from its already-parsed content types.
79
+ * (Deliberately not the registry's byte sniffer — that re-parses the zip
80
+ * and carries `dbk` semantics this path doesn't want.)
81
+ */
82
+ function sniffOoxmlFormat(pkg: OoxmlPackage): InferSourceFormat | undefined {
83
+ const values = [...pkg.contentTypes.overrides.values(), ...pkg.contentTypes.defaults.values()];
84
+ for (const ct of values) {
85
+ if (ct.includes('wordprocessingml')) return 'docx';
86
+ if (ct.includes('presentationml')) return 'pptx';
87
+ if (ct.includes('spreadsheetml')) return 'xlsx';
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ const EXTRACTORS: Record<
93
+ InferSourceFormat,
94
+ (pkg: OoxmlPackage) => Promise<ExtractedFileTheme | null>
95
+ > = {
96
+ docx: extractDocxTheme,
97
+ pptx: extractPptxTheme,
98
+ xlsx: extractXlsxTheme,
99
+ };
100
+
101
+ /**
102
+ * Infer a Squisq theme (and, for PPTX, optionally custom layout templates)
103
+ * from an office file's bytes.
104
+ *
105
+ * Throws `ConversionError`:
106
+ * - `unsupported-input` for PDF bytes (no theme tables to read)
107
+ * - `invalid-input` for non-OOXML data or files with no theme part
108
+ */
109
+ export async function inferThemeFromFile(
110
+ data: ArrayBuffer | Blob,
111
+ options: InferThemeOptions = {},
112
+ ): Promise<InferredFileTheme> {
113
+ if (await looksLikePdf(data)) {
114
+ throw new ConversionError(
115
+ 'unsupported-input',
116
+ 'PDF theme inference is not supported — PDF files carry no theme color/font tables.',
117
+ { format: 'pdf' },
118
+ );
119
+ }
120
+
121
+ let pkg: OoxmlPackage;
122
+ try {
123
+ pkg = await openPackage(data, options);
124
+ } catch (err: unknown) {
125
+ throw new ConversionError(
126
+ 'invalid-input',
127
+ 'Could not read this file as a Word, PowerPoint, or Excel document.',
128
+ { cause: err },
129
+ );
130
+ }
131
+
132
+ const format = options.format ?? sniffOoxmlFormat(pkg);
133
+ if (!format) {
134
+ throw new ConversionError(
135
+ 'invalid-input',
136
+ 'Not a Word, PowerPoint, or Excel document — no theme to infer.',
137
+ );
138
+ }
139
+
140
+ const extraction = await EXTRACTORS[format](pkg);
141
+ if (!extraction) {
142
+ throw new ConversionError('invalid-input', 'No theme part found in this file.', { format });
143
+ }
144
+
145
+ const { theme, warnings: mapWarnings } = compileExtractedTheme(extraction, {
146
+ nameHint: options.nameHint,
147
+ });
148
+ const warnings = [...extraction.warnings, ...mapWarnings];
149
+
150
+ let layouts: CustomTemplateDefinition[] | undefined;
151
+ if (options.inferLayouts) {
152
+ if (format !== 'pptx') {
153
+ warnings.push('Layout inference is only available for PowerPoint files; skipped.');
154
+ } else {
155
+ const { analyzePptxLayouts } = await import('../pptx/layouts.js');
156
+ const analysis = await analyzePptxLayouts(pkg, {
157
+ colors: colorHintsFromExtraction(extraction),
158
+ });
159
+ warnings.push(...analysis.warnings);
160
+ const defs = analysis.layouts
161
+ .map((l) => (l.verdict.kind === 'custom' ? l.verdict.def : null))
162
+ .filter((d): d is CustomTemplateDefinition => d !== null);
163
+ if (defs.length > 0) layouts = defs;
164
+ }
165
+ }
166
+
167
+ return { theme, extraction, ...(layouts ? { layouts } : {}), warnings };
168
+ }