@bendyline/squisq-formats 1.4.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -20,6 +20,7 @@ import {
20
20
  REL_CORE_PROPERTIES,
21
21
  } from './namespaces.js';
22
22
  import { xmlDeclaration, escapeXml } from './xmlUtils.js';
23
+ import { assertSafeZipPath } from '../shared/zipSafety.js';
23
24
 
24
25
  // ============================================
25
26
  // Package Builder
@@ -99,14 +100,17 @@ export function createPackage(): OoxmlPackageBuilder {
99
100
 
100
101
  return {
101
102
  addPart(path, content, contentType) {
103
+ assertSafeZipPath(path);
102
104
  parts.push({ path, content, contentType });
103
105
  },
104
106
 
105
107
  addBinaryPart(path, data, contentType) {
108
+ assertSafeZipPath(path);
106
109
  parts.push({ path, binaryContent: data, contentType });
107
110
  },
108
111
 
109
112
  addRelationship(sourcePart, rel) {
113
+ if (sourcePart) assertSafeZipPath(sourcePart);
110
114
  relationships.push({ sourcePart, relationship: rel });
111
115
  },
112
116
 
@@ -139,6 +143,10 @@ function assemble(
139
143
  coreProps?: CoreProperties,
140
144
  ): JSZip {
141
145
  const zip = new JSZip();
146
+ // Assembly must be idempotent: callers may request both Blob and
147
+ // ArrayBuffer representations from one builder. Never append generated
148
+ // relationships to the builder-owned array.
149
+ const assembledRelationships = relationships.slice();
142
150
 
143
151
  // Write content parts
144
152
  for (const part of parts) {
@@ -154,22 +162,32 @@ function assemble(
154
162
  const coreXml = buildCorePropertiesXml(coreProps);
155
163
  zip.file('docProps/core.xml', coreXml);
156
164
 
157
- // Add relationship to core properties
158
- relationships.push({
159
- sourcePart: '',
160
- relationship: {
161
- id: `rId${relationships.length + 100}`,
162
- type: REL_CORE_PROPERTIES,
163
- target: 'docProps/core.xml',
164
- },
165
- });
165
+ // Add the generated relationship unless the caller already supplied it.
166
+ if (
167
+ !assembledRelationships.some(
168
+ ({ sourcePart, relationship }) =>
169
+ sourcePart === '' && relationship.type === REL_CORE_PROPERTIES,
170
+ )
171
+ ) {
172
+ const ids = new Set(assembledRelationships.map(({ relationship }) => relationship.id));
173
+ let nextId = 100;
174
+ while (ids.has(`rId${nextId}`)) nextId++;
175
+ assembledRelationships.push({
176
+ sourcePart: '',
177
+ relationship: {
178
+ id: `rId${nextId}`,
179
+ type: REL_CORE_PROPERTIES,
180
+ target: 'docProps/core.xml',
181
+ },
182
+ });
183
+ }
166
184
  }
167
185
 
168
186
  // Build and write [Content_Types].xml
169
187
  zip.file('[Content_Types].xml', buildContentTypesXml(parts, coreProps));
170
188
 
171
189
  // Build and write _rels/*.rels files
172
- const relsBySource = groupRelationshipsBySource(relationships);
190
+ const relsBySource = groupRelationshipsBySource(assembledRelationships);
173
191
  for (const [sourcePart, rels] of relsBySource) {
174
192
  const relsPath = sourcePart === '' ? '_rels/.rels' : buildRelsPath(sourcePart);
175
193
  zip.file(relsPath, buildRelationshipsXml(rels));
package/src/pdf/export.ts CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { PDFDocument, StandardFonts, rgb, PDFFont, PDFPage } from 'pdf-lib';
23
23
 
24
- import type { Doc } from '@bendyline/squisq/schemas';
24
+ import type { Doc, ThemeRegistry } from '@bendyline/squisq/schemas';
25
25
  import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
26
26
  import type {
27
27
  MarkdownDocument,
@@ -110,6 +110,8 @@ export interface PdfExportOptions {
110
110
  * Font changes are not supported (pdf-lib uses standard 14 PDF fonts only).
111
111
  */
112
112
  themeId?: string;
113
+ /** Explicit caller-owned registry for non-document custom themes. */
114
+ themeRegistry?: ThemeRegistry;
113
115
  }
114
116
 
115
117
  /**
@@ -219,7 +221,7 @@ async function createExportContext(
219
221
  // including inline custom themes (resolved doc-scoped).
220
222
  const themeId = options.themeId ?? readFrontmatterThemeId(doc.frontmatter);
221
223
  if (themeId) {
222
- const theme = resolveThemeForDoc(doc, themeId);
224
+ const theme = resolveThemeForDoc(doc, themeId, options.themeRegistry);
223
225
  if (theme.colors) {
224
226
  colorText = hexToRgb(theme.colors.text) ?? COLOR_TEXT;
225
227
  colorHeading = hexToRgb(theme.colors.primary) ?? COLOR_HEADING;
package/src/pdf/import.ts CHANGED
@@ -107,16 +107,21 @@ export async function pdfToMarkdownDoc(
107
107
  ? new Uint8Array(data)
108
108
  : data;
109
109
 
110
- const textLines = await extractTextLines(bytes);
110
+ const loaded = await loadPdfDocument(bytes);
111
+ try {
112
+ const textLines = await extractTextLines(loaded.pdf);
111
113
 
112
- if (textLines.length === 0) {
113
- return { type: 'document', children: [] };
114
- }
114
+ if (textLines.length === 0) {
115
+ return { type: 'document', children: [] };
116
+ }
115
117
 
116
- const bodySize = options.bodyFontSize ?? detectBodyFontSize(textLines);
117
- const blocks = classifyLines(textLines, bodySize, options);
118
+ const bodySize = options.bodyFontSize ?? detectBodyFontSize(textLines);
119
+ const blocks = classifyLines(textLines, bodySize, options);
118
120
 
119
- return { type: 'document', children: blocks };
121
+ return { type: 'document', children: blocks };
122
+ } finally {
123
+ await loaded.pdf.destroy?.();
124
+ }
120
125
  }
121
126
 
122
127
  /**
@@ -157,8 +162,14 @@ export async function pdfToContainer(
157
162
  ? new Uint8Array(data)
158
163
  : data;
159
164
 
160
- const textLines = await extractTextLines(bytes);
161
- const images = await extractImages(bytes);
165
+ const loaded = await loadPdfDocument(bytes);
166
+ let textLines: TextLine[];
167
+ let images: ExtractedImage[];
168
+ try {
169
+ [textLines, images] = await Promise.all([extractTextLines(loaded.pdf), extractImages(loaded)]);
170
+ } finally {
171
+ await loaded.pdf.destroy?.();
172
+ }
162
173
 
163
174
  // Under Node there is no DOM/canvas, so extractImages() returns [] via its
164
175
  // environment guard and embedded images can't be decoded. The registry's
@@ -208,32 +219,14 @@ export interface ExtractedImage {
208
219
  * Extract embedded images from a PDF using pdfjs-dist operator list API.
209
220
  * Requires browser canvas for PNG encoding.
210
221
  */
211
- async function extractImages(data: Uint8Array): Promise<ExtractedImage[]> {
222
+ async function extractImages(loaded: LoadedPdf): Promise<ExtractedImage[]> {
212
223
  // Canvas is required for PNG encoding — skip in non-browser environments
213
224
  if (typeof document === 'undefined') return [];
214
225
 
215
- let pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
216
- try {
217
- pdfjsLib = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as unknown as PdfjsLib & {
218
- OPS?: Record<string, number>;
219
- };
220
- } catch {
221
- pdfjsLib = (await import('pdfjs-dist')) as unknown as PdfjsLib & {
222
- OPS?: Record<string, number>;
223
- };
224
- }
225
-
226
- await applyWorkerConfig(pdfjsLib);
226
+ const { pdfjsLib, pdf } = loaded;
227
227
 
228
228
  const OPS_paintImageXObject = pdfjsLib.OPS?.paintImageXObject ?? 85;
229
229
 
230
- const loadingTask = pdfjsLib.getDocument({
231
- data,
232
- isEvalSupported: false,
233
- useSystemFonts: true,
234
- });
235
-
236
- const pdf = await loadingTask.promise;
237
230
  const images: ExtractedImage[] = [];
238
231
  let counter = 0;
239
232
 
@@ -501,6 +494,12 @@ interface PdfjsLib {
501
494
  interface PdfjsDocument {
502
495
  numPages: number;
503
496
  getPage(pageNum: number): Promise<PdfjsPage>;
497
+ destroy?(): Promise<void>;
498
+ }
499
+
500
+ interface LoadedPdf {
501
+ pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
502
+ pdf: PdfjsDocument;
504
503
  }
505
504
 
506
505
  interface PdfjsPage {
@@ -528,14 +527,18 @@ async function applyWorkerConfig(pdfjsLib: PdfjsLib): Promise<void> {
528
527
  // Node.js the caller must have called configurePdfWorker() first.
529
528
  }
530
529
 
531
- async function extractTextLines(data: Uint8Array): Promise<TextLine[]> {
530
+ async function loadPdfDocument(data: Uint8Array): Promise<LoadedPdf> {
532
531
  // Dynamic import — the legacy build bundles a fake-worker fallback
533
532
  // that avoids a real Web Worker in environments that don't support it.
534
- let pdfjsLib: PdfjsLib;
533
+ let pdfjsLib: PdfjsLib & { OPS?: Record<string, number> };
535
534
  try {
536
- pdfjsLib = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as unknown as PdfjsLib;
535
+ pdfjsLib = (await import('pdfjs-dist/legacy/build/pdf.mjs')) as unknown as PdfjsLib & {
536
+ OPS?: Record<string, number>;
537
+ };
537
538
  } catch {
538
- pdfjsLib = (await import('pdfjs-dist')) as unknown as PdfjsLib;
539
+ pdfjsLib = (await import('pdfjs-dist')) as unknown as PdfjsLib & {
540
+ OPS?: Record<string, number>;
541
+ };
539
542
  }
540
543
 
541
544
  await applyWorkerConfig(pdfjsLib);
@@ -546,7 +549,10 @@ async function extractTextLines(data: Uint8Array): Promise<TextLine[]> {
546
549
  useSystemFonts: true,
547
550
  });
548
551
 
549
- const pdf = await loadingTask.promise;
552
+ return { pdfjsLib, pdf: await loadingTask.promise };
553
+ }
554
+
555
+ async function extractTextLines(pdf: PdfjsDocument): Promise<TextLine[]> {
550
556
  const allLines: TextLine[] = [];
551
557
 
552
558
  for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
@@ -29,7 +29,7 @@
29
29
 
30
30
  import type { Doc, Transition, TransitionDirection } from '@bendyline/squisq/schemas';
31
31
  import { resolveFontFamily } from '@bendyline/squisq/schemas';
32
- import type { Theme } from '@bendyline/squisq/schemas';
32
+ import type { Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
33
33
  import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
34
34
  import type {
35
35
  MarkdownDocument,
@@ -132,6 +132,8 @@ export interface PptxExportOptions {
132
132
  * Falls back to the document's frontmatter `themeId` if not set here.
133
133
  */
134
134
  themeId?: string;
135
+ /** Explicit caller-owned registry for non-document custom themes. */
136
+ themeRegistry?: ThemeRegistry;
135
137
  /**
136
138
  * Image data keyed by the path/URL used in markdown.
137
139
  * When provided, images are embedded as picture shapes instead of
@@ -224,7 +226,7 @@ function resolveSlideStyle(
224
226
  };
225
227
  }
226
228
 
227
- const theme: Theme = resolveThemeForDoc(doc, themeId);
229
+ const theme: Theme = resolveThemeForDoc(doc, themeId, options.themeRegistry);
228
230
  const c = theme.colors;
229
231
 
230
232
  return {
@@ -7,9 +7,20 @@
7
7
  * (or "Slide N"), the remaining text as a bullet list, and any slide tables
8
8
  * (`<a:tbl>`) as markdown tables. Text lives in the DrawingML namespace
9
9
  * (`a:p` / `a:r` / `a:t`) inside PresentationML shapes (`p:sp`).
10
+ *
11
+ * Theme + layout inference (default ON): the deck's theme part is compiled
12
+ * into a Squisq custom theme and carried in the returned document's
13
+ * frontmatter (`squisq-custom-themes` + `squisq-theme`); slide layouts are
14
+ * classified against the built-in template set, distinctive ones become
15
+ * `squisq-custom-templates` definitions, and each slide's heading is
16
+ * annotated (`{[templateName …]}`) per its layout's verdict. Pass
17
+ * `inferTheme: false` / `inferLayouts: false` for legacy plain imports.
18
+ * Inference never fails an import — extraction errors degrade to plain
19
+ * output with a console warning.
10
20
  */
11
21
 
12
22
  import type {
23
+ HeadingTemplateAnnotation,
13
24
  MarkdownBlockNode,
14
25
  MarkdownDocument,
15
26
  MarkdownImage,
@@ -19,14 +30,19 @@ import type {
19
30
  MarkdownTableRow,
20
31
  } from '@bendyline/squisq/markdown';
21
32
  import { stringifyMarkdown } from '@bendyline/squisq/markdown';
33
+ import type { CustomTemplateDefinition, Theme } from '@bendyline/squisq/schemas';
22
34
  import { getPartBinary, getPartRelationships, getPartXml, openPackage } from '../ooxml/reader.js';
35
+ import type { OoxmlOpenOptions } from '../ooxml/reader.js';
23
36
  import type { OoxmlPackage } from '../ooxml/types.js';
24
37
  import { NS_DRAWINGML, NS_PML, NS_R } from '../ooxml/namespaces.js';
38
+ import { attrNS, baseDirOf, resolveTarget } from '../ooxml/readUtils.js';
25
39
  import type { ContentContainer } from '@bendyline/squisq/storage';
26
40
  import { buildContainer } from '../shared/container.js';
27
41
  import { extToMime } from '../shared/images.js';
42
+ import type { ExtractedFileTheme } from '../infer/types.js';
43
+ import type { AnalyzedLayout, PptxLayoutInference } from './layouts.js';
28
44
 
29
- export interface PptxImportOptions {
45
+ export interface PptxImportOptions extends OoxmlOpenOptions {
30
46
  /**
31
47
  * Whether to extract embedded slide images into the document as image nodes
32
48
  * (referencing `images/imageN.ext`). When false, pictures are ignored so the
@@ -34,6 +50,19 @@ export interface PptxImportOptions {
34
50
  * `pptxToContainer` forces this on. Default: false.
35
51
  */
36
52
  extractImages?: boolean;
53
+ /**
54
+ * Infer a Squisq theme from the deck's theme part (colors + fonts) and
55
+ * carry it in the returned document's frontmatter (`squisq-custom-themes`
56
+ * + `squisq-theme`). Default: true — pass false for a legacy import with
57
+ * no frontmatter.
58
+ */
59
+ inferTheme?: boolean;
60
+ /**
61
+ * Derive custom layout templates from the deck's slide layouts/masters
62
+ * (`squisq-custom-templates` frontmatter) and annotate slide headings
63
+ * with matching built-in or generated templates. Default: true.
64
+ */
65
+ inferLayouts?: boolean;
37
66
  }
38
67
 
39
68
  /**
@@ -46,20 +75,10 @@ interface ImportContext {
46
75
  /** Collected image files: `images/imageN.ext` → { data, mimeType } */
47
76
  extractedImages: Map<string, { data: ArrayBuffer; mimeType: string }>;
48
77
  imageCounter: number;
49
- }
50
-
51
- function attrNS(el: Element, ns: string, local: string, fallback: string): string | null {
52
- return el.getAttributeNS(ns, local) ?? el.getAttribute(fallback);
53
- }
54
-
55
- function resolveTarget(baseDir: string, target: string): string {
56
- if (target.startsWith('/')) return target.replace(/^\//, '');
57
- const stack = baseDir ? baseDir.split('/') : [];
58
- for (const seg of target.split('/')) {
59
- if (seg === '..') stack.pop();
60
- else if (seg !== '.') stack.push(seg);
61
- }
62
- return stack.join('/');
78
+ /** Layout analysis when `inferLayouts` is on. */
79
+ inference?: PptxLayoutInference;
80
+ /** Generated custom templates actually referenced by ≥1 slide annotation. */
81
+ usedCustomTemplates: Map<string, CustomTemplateDefinition>;
63
82
  }
64
83
 
65
84
  async function orderedSlidePaths(pkg: OoxmlPackage): Promise<string[]> {
@@ -114,12 +133,6 @@ function tableToMarkdown(tbl: Element): MarkdownTable {
114
133
  return { type: 'table', children: rows };
115
134
  }
116
135
 
117
- /** Directory portion of a part path, e.g. `ppt/slides/slide1.xml` → `ppt/slides`. */
118
- function baseDirOf(path: string): string {
119
- const slash = path.lastIndexOf('/');
120
- return slash === -1 ? '' : path.slice(0, slash);
121
- }
122
-
123
136
  /**
124
137
  * Extract every `<p:pic>` picture in a slide as an image node, reading the
125
138
  * `<a:blip r:embed>` relationship, resolving it to the media part, and copying
@@ -168,6 +181,93 @@ async function extractSlideImages(
168
181
  return images;
169
182
  }
170
183
 
184
+ // ── Layout-verdict annotation building ───────────────────────────────
185
+
186
+ /** One non-title text shape on a slide, with its placeholder identity. */
187
+ interface SlideTextEntry {
188
+ rawType: string;
189
+ idx: number;
190
+ texts: string[];
191
+ }
192
+
193
+ /** Annotation params live on a single heading line — flatten whitespace. */
194
+ function cleanParamText(text: string): string {
195
+ return text.replace(/\s+/g, ' ').trim();
196
+ }
197
+
198
+ /**
199
+ * Turn a slide's layout verdict into a heading annotation, downgrading to
200
+ * plain when the slide lacks the content the template needs (a bare
201
+ * annotation without its essential params renders an empty card in the
202
+ * player path, which never derives inputs for annotated blocks).
203
+ */
204
+ function buildSlideAnnotation(
205
+ analyzed: AnalyzedLayout | undefined,
206
+ entries: SlideTextEntry[],
207
+ images: MarkdownImage[],
208
+ ctx: ImportContext,
209
+ ): { annotation?: HeadingTemplateAnnotation; omitted: Set<SlideTextEntry> } {
210
+ const omitted = new Set<SlideTextEntry>();
211
+ if (!analyzed) return { omitted };
212
+ const verdict = analyzed.verdict;
213
+
214
+ if (verdict.kind === 'custom') {
215
+ ctx.usedCustomTemplates.set(verdict.def.name, verdict.def);
216
+ return { annotation: { template: verdict.def.name }, omitted };
217
+ }
218
+ if (verdict.kind !== 'builtin') return { omitted };
219
+
220
+ switch (verdict.paramSpec) {
221
+ case 'titleSubtitle': {
222
+ const sub = entries.find((e) => e.rawType === 'subTitle');
223
+ const subtitle = sub ? cleanParamText(sub.texts.join(' ')) : '';
224
+ // The subtitle moves into the card; keep it out of the bullets.
225
+ if (sub && subtitle) omitted.add(sub);
226
+ return {
227
+ annotation: {
228
+ template: verdict.template,
229
+ ...(subtitle ? { params: { subtitle } } : {}),
230
+ },
231
+ omitted,
232
+ };
233
+ }
234
+ case 'comparisonPairs': {
235
+ if (!verdict.columns) return { omitted };
236
+ const textAt = (idx: number | undefined): string[] =>
237
+ idx === undefined ? [] : (entries.find((e) => e.idx === idx)?.texts ?? []);
238
+ const side = (idxs: number[]): string => {
239
+ const header = cleanParamText(textAt(idxs[0])[0] ?? '');
240
+ const body = cleanParamText(textAt(idxs[1])[0] ?? '');
241
+ return header ? (body ? `${header}|${body}` : header) : '';
242
+ };
243
+ const left = side(verdict.columns.left);
244
+ const right = side(verdict.columns.right);
245
+ // twoColumn requires both labels — a bare annotation renders nothing.
246
+ if (!left || !right) return { omitted };
247
+ return { annotation: { template: 'twoColumn', params: { left, right } }, omitted };
248
+ }
249
+ case 'featureImage': {
250
+ const image = images[0];
251
+ if (!image) return { omitted };
252
+ const params: Record<string, string> = { imageSrc: image.url };
253
+ if (image.alt && image.alt !== 'Image') params.imageAlt = cleanParamText(image.alt);
254
+ return { annotation: { template: verdict.template, params }, omitted };
255
+ }
256
+ case 'photoGridGate': {
257
+ if (images.length < 2) return { omitted };
258
+ return {
259
+ annotation: {
260
+ template: 'photoGrid',
261
+ params: { images: images.map((i) => i.url).join(',') },
262
+ },
263
+ omitted,
264
+ };
265
+ }
266
+ default:
267
+ return { annotation: { template: verdict.template }, omitted };
268
+ }
269
+ }
270
+
171
271
  async function convertSlide(
172
272
  path: string,
173
273
  index: number,
@@ -179,7 +279,7 @@ async function convertSlide(
179
279
  const out: MarkdownBlockNode[] = [];
180
280
 
181
281
  let title = '';
182
- const bullets: string[] = [];
282
+ const entries: SlideTextEntry[] = [];
183
283
  const shapes = doc.getElementsByTagNameNS(NS_PML, 'sp');
184
284
  for (let s = 0; s < shapes.length; s++) {
185
285
  const sp = shapes[s]!;
@@ -194,17 +294,32 @@ async function convertSlide(
194
294
  if (texts.length === 0) continue;
195
295
  if (isTitleShape(sp) && !title) {
196
296
  title = texts.join(' ');
197
- } else {
198
- bullets.push(...texts);
297
+ continue;
199
298
  }
299
+ const phs = sp.getElementsByTagNameNS(NS_PML, 'ph');
300
+ const rawType = phs.length ? (phs[0]!.getAttribute('type') ?? '') : '';
301
+ const idxRaw = phs.length ? phs[0]!.getAttribute('idx') : null;
302
+ const idx = idxRaw ? parseInt(idxRaw, 10) || 0 : 0;
303
+ entries.push({ rawType, idx, texts });
200
304
  }
201
305
 
306
+ // Images are extracted before annotation building so feature/photoGrid
307
+ // params can reference their container paths; they still land after the
308
+ // bullet list in the output, unchanged.
309
+ const images = ctx.extractImages ? await extractSlideImages(doc, path, ctx) : [];
310
+
311
+ const layoutPath = ctx.inference?.layoutPathBySlide.get(path);
312
+ const analyzed = layoutPath ? ctx.inference?.byLayoutPath.get(layoutPath) : undefined;
313
+ const { annotation, omitted } = buildSlideAnnotation(analyzed, entries, images, ctx);
314
+
202
315
  out.push({
203
316
  type: 'heading',
204
317
  depth: 2,
205
318
  children: [{ type: 'text', value: title || `Slide ${index + 1}` }],
319
+ ...(annotation ? { templateAnnotation: annotation } : {}),
206
320
  });
207
321
 
322
+ const bullets = entries.filter((e) => !omitted.has(e)).flatMap((e) => e.texts);
208
323
  if (bullets.length > 0) {
209
324
  const items: MarkdownListItem[] = bullets.map((text) => ({
210
325
  type: 'listItem',
@@ -214,11 +329,8 @@ async function convertSlide(
214
329
  }
215
330
 
216
331
  // Embedded pictures land after the bullet list and before any tables.
217
- if (ctx.extractImages) {
218
- const images = await extractSlideImages(doc, path, ctx);
219
- for (const image of images) {
220
- out.push({ type: 'paragraph', children: [image] });
221
- }
332
+ for (const image of images) {
333
+ out.push({ type: 'paragraph', children: [image] });
222
334
  }
223
335
 
224
336
  const tbls = doc.getElementsByTagNameNS(NS_DRAWINGML, 'tbl');
@@ -227,6 +339,11 @@ async function convertSlide(
227
339
  return out;
228
340
  }
229
341
 
342
+ function warnInferenceFailure(step: string, err: unknown): void {
343
+ const message = err instanceof Error ? err.message : String(err);
344
+ console.warn(`pptx import: ${step} failed; importing without it — ${message}`);
345
+ }
346
+
230
347
  async function importDocument(
231
348
  pkg: OoxmlPackage,
232
349
  options: PptxImportOptions,
@@ -236,20 +353,85 @@ async function importDocument(
236
353
  extractImages: options.extractImages ?? false,
237
354
  extractedImages: new Map(),
238
355
  imageCounter: 0,
356
+ usedCustomTemplates: new Map(),
239
357
  };
358
+
359
+ const inferTheme = options.inferTheme !== false;
360
+ const inferLayouts = options.inferLayouts !== false;
361
+
362
+ // All inference modules load lazily so a plain import stays light, and
363
+ // every inference step degrades to a warning rather than failing the import.
364
+ let extraction: ExtractedFileTheme | null = null;
365
+ if (inferTheme || inferLayouts) {
366
+ try {
367
+ const { extractPptxTheme } = await import('../infer/extract.js');
368
+ extraction = await extractPptxTheme(pkg);
369
+ } catch (err: unknown) {
370
+ warnInferenceFailure('theme extraction', err);
371
+ }
372
+ }
373
+
374
+ let theme: Theme | undefined;
375
+ if (inferTheme && extraction) {
376
+ try {
377
+ const { compileExtractedTheme } = await import('../infer/mapTheme.js');
378
+ theme = compileExtractedTheme(extraction).theme;
379
+ } catch (err: unknown) {
380
+ warnInferenceFailure('theme compilation', err);
381
+ }
382
+ }
383
+
384
+ if (inferLayouts) {
385
+ try {
386
+ const { analyzePptxLayouts } = await import('./layouts.js');
387
+ const { colorHintsFromExtraction } = await import('../infer/mapTheme.js');
388
+ ctx.inference = await analyzePptxLayouts(pkg, {
389
+ colors: extraction ? colorHintsFromExtraction(extraction) : {},
390
+ });
391
+ } catch (err: unknown) {
392
+ warnInferenceFailure('layout inference', err);
393
+ }
394
+ }
395
+
240
396
  const paths = await orderedSlidePaths(pkg);
241
397
  const children: MarkdownBlockNode[] = [];
242
398
  for (let i = 0; i < paths.length; i++) {
243
399
  children.push(...(await convertSlide(paths[i]!, i, ctx)));
244
400
  }
245
- return { doc: { type: 'document', children }, ctx };
401
+ const doc: MarkdownDocument = { type: 'document', children };
402
+
403
+ const frontmatter: Record<string, unknown> = {};
404
+ if (theme || ctx.usedCustomTemplates.size > 0) {
405
+ const {
406
+ writeCustomThemesToFrontmatter,
407
+ writeCustomTemplatesToFrontmatter,
408
+ FRONTMATTER_CUSTOM_THEMES_KEY,
409
+ FRONTMATTER_CUSTOM_TEMPLATES_KEY,
410
+ } = await import('@bendyline/squisq/doc');
411
+ if (theme) {
412
+ const payload = writeCustomThemesToFrontmatter([theme]);
413
+ if (payload) {
414
+ frontmatter[FRONTMATTER_CUSTOM_THEMES_KEY] = payload;
415
+ // The doc-level selector `resolveThemeForDoc` reads — activates the
416
+ // inferred theme without any global registration.
417
+ frontmatter['squisq-theme'] = theme.id;
418
+ }
419
+ }
420
+ if (ctx.usedCustomTemplates.size > 0) {
421
+ const payload = writeCustomTemplatesToFrontmatter([...ctx.usedCustomTemplates.values()]);
422
+ if (payload) frontmatter[FRONTMATTER_CUSTOM_TEMPLATES_KEY] = payload;
423
+ }
424
+ }
425
+ if (Object.keys(frontmatter).length > 0) doc.frontmatter = frontmatter;
426
+
427
+ return { doc, ctx };
246
428
  }
247
429
 
248
430
  export async function pptxToMarkdownDoc(
249
431
  data: ArrayBuffer | Blob,
250
432
  options: PptxImportOptions = {},
251
433
  ): Promise<MarkdownDocument> {
252
- const pkg = await openPackage(data);
434
+ const pkg = await openPackage(data, options);
253
435
  const { doc } = await importDocument(pkg, options);
254
436
  return doc;
255
437
  }
@@ -266,7 +448,7 @@ export async function pptxToContainer(
266
448
  data: ArrayBuffer | Blob,
267
449
  options: PptxImportOptions = {},
268
450
  ): Promise<ContentContainer> {
269
- const pkg = await openPackage(data);
451
+ const pkg = await openPackage(data, options);
270
452
  const { doc, ctx } = await importDocument(pkg, { ...options, extractImages: true });
271
453
 
272
454
  return buildContainer(stringifyMarkdown(doc), ctx.extractedImages);
package/src/pptx/index.ts CHANGED
@@ -27,6 +27,20 @@ import { type PptxImportOptions, pptxToMarkdownDoc } from './import.js';
27
27
  export type { PptxImportOptions } from './import.js';
28
28
  export { pptxToMarkdownDoc, pptxToContainer } from './import.js';
29
29
 
30
+ // Layout inference (used by the PPTX importer and the theme dialog)
31
+ export { analyzePptxLayouts, inspectPptxLayouts } from './layouts.js';
32
+ export type {
33
+ AnalyzedLayout,
34
+ AnalyzePptxLayoutsOptions,
35
+ ExtractedPlaceholder,
36
+ ExtractedSlideLayout,
37
+ InspectPptxLayoutsOptions,
38
+ LayoutVerdict,
39
+ PptxColorHints,
40
+ PptxLayoutInference,
41
+ PptxLayoutSummary,
42
+ } from './layouts.js';
43
+
30
44
  /**
31
45
  * Convert a .pptx file to a squisq Doc (via the markdown model).
32
46
  */