@bendyline/squisq-formats 2.0.1 → 2.2.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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +1 -1
  4. package/dist/{chunk-CRAVSMPZ.js → chunk-26ISNJ7Y.js} +356 -114
  5. package/dist/{chunk-NKAJPJ4G.js → chunk-2JJ5RFDZ.js} +0 -1
  6. package/dist/{chunk-HTW2M27H.js → chunk-3NKXBZSR.js} +193 -42
  7. package/dist/{chunk-U32AG3G3.js → chunk-4V3KCHAP.js} +3 -4
  8. package/dist/{chunk-MLX2BOJC.js → chunk-6RQOV3B3.js} +1 -2
  9. package/dist/{chunk-QRVN6A6E.js → chunk-6S6GU3ZG.js} +5 -6
  10. package/dist/{chunk-FE6OJV6O.js → chunk-7AWFHP5U.js} +1 -1
  11. package/dist/{chunk-RFAPOKHJ.js → chunk-AD2WT564.js} +59 -9
  12. package/dist/{chunk-O3GVVND4.js → chunk-AONELFLA.js} +0 -1
  13. package/dist/{chunk-XKUMNGBW.js → chunk-EJTNGKEA.js} +5 -8
  14. package/dist/chunk-GX7RAUME.js +121 -0
  15. package/dist/{chunk-SSUPBUF5.js → chunk-IIQYS2YH.js} +0 -1
  16. package/dist/{chunk-ABVI556T.js → chunk-IPN56VLW.js} +83 -58
  17. package/dist/{chunk-LXYLOOST.js → chunk-JE6LSIHE.js} +83 -22
  18. package/dist/{chunk-U4MRIFKL.js → chunk-JU2RHXUB.js} +0 -1
  19. package/dist/{chunk-4VUWTSGM.js → chunk-K6XRMVPW.js} +64 -31
  20. package/dist/{chunk-ODL3SSPT.js → chunk-KXOZMWBS.js} +0 -1
  21. package/dist/chunk-OGS5VCGJ.js +446 -0
  22. package/dist/{chunk-GVS2XXV6.js → chunk-PJXJI2LY.js} +449 -57
  23. package/dist/{chunk-2KPARF2P.js → chunk-PU7REGWV.js} +5 -8
  24. package/dist/{chunk-PN52A5AA.js → chunk-SBUW7NHR.js} +0 -1
  25. package/dist/{chunk-VSYHZECT.js → chunk-TAAENIRB.js} +5 -8
  26. package/dist/{chunk-WC7WULGV.js → chunk-X2DEAXNK.js} +62 -2
  27. package/dist/container/index.js +1 -2
  28. package/dist/csv/index.d.ts +27 -2
  29. package/dist/csv/index.js +1 -2
  30. package/dist/docx/index.d.ts +5 -1
  31. package/dist/docx/index.js +9 -10
  32. package/dist/epub/index.d.ts +2 -0
  33. package/dist/epub/index.js +5 -6
  34. package/dist/{export-D2NkylDT.d.ts → export-D9msROJS.d.ts} +18 -6
  35. package/dist/extract-MN7LA3NL.js +13 -0
  36. package/dist/html/index.d.ts +11 -4
  37. package/dist/html/index.js +3 -4
  38. package/dist/images-ESPQKVTW.js +6 -0
  39. package/dist/{import-K8mfc0fz.d.ts → import-C3htUTss.d.ts} +5 -1
  40. package/dist/{import-DTkDxHmZ.d.ts → import-C8whCC7_.d.ts} +6 -0
  41. package/dist/index.d.ts +7 -7
  42. package/dist/index.js +28 -26
  43. package/dist/infer/index.d.ts +3 -3
  44. package/dist/infer/index.js +7 -9
  45. package/dist/{layouts-BHrgZ5FS.d.ts → layouts-CTdPlB-u.d.ts} +1 -1
  46. package/dist/layouts-DRWZGSPD.js +10 -0
  47. package/dist/{mapTheme-IR27S6IV.js → mapTheme-4TWH25FT.js} +1 -2
  48. package/dist/ooxml/index.d.ts +3 -3
  49. package/dist/ooxml/index.js +14 -13
  50. package/dist/pdf/index.d.ts +18 -0
  51. package/dist/pdf/index.js +2 -3
  52. package/dist/pptx/index.d.ts +4 -4
  53. package/dist/pptx/index.js +11 -13
  54. package/dist/{reader-B9L8Ucbj.d.ts → reader-B_m1aKZC.d.ts} +30 -1
  55. package/dist/registry/index.d.ts +21 -5
  56. package/dist/registry/index.js +9 -6
  57. package/dist/{themeReader-DJKErl_j.d.ts → themeReader-DCtwC83Q.d.ts} +1 -1
  58. package/dist/xlsx/index.d.ts +3 -3
  59. package/dist/xlsx/index.js +6 -7
  60. package/package.json +6 -3
  61. package/dist/chunk-2KPARF2P.js.map +0 -1
  62. package/dist/chunk-4VUWTSGM.js.map +0 -1
  63. package/dist/chunk-6M7Z25LA.js +0 -46
  64. package/dist/chunk-6M7Z25LA.js.map +0 -1
  65. package/dist/chunk-ABVI556T.js.map +0 -1
  66. package/dist/chunk-CRAVSMPZ.js.map +0 -1
  67. package/dist/chunk-FE6OJV6O.js.map +0 -1
  68. package/dist/chunk-GVS2XXV6.js.map +0 -1
  69. package/dist/chunk-HTW2M27H.js.map +0 -1
  70. package/dist/chunk-LXYLOOST.js.map +0 -1
  71. package/dist/chunk-MLX2BOJC.js.map +0 -1
  72. package/dist/chunk-NKAJPJ4G.js.map +0 -1
  73. package/dist/chunk-O3GVVND4.js.map +0 -1
  74. package/dist/chunk-ODL3SSPT.js.map +0 -1
  75. package/dist/chunk-PN52A5AA.js.map +0 -1
  76. package/dist/chunk-QRVN6A6E.js.map +0 -1
  77. package/dist/chunk-RFAPOKHJ.js.map +0 -1
  78. package/dist/chunk-SSUPBUF5.js.map +0 -1
  79. package/dist/chunk-U32AG3G3.js.map +0 -1
  80. package/dist/chunk-U4MRIFKL.js.map +0 -1
  81. package/dist/chunk-VJJM2SSH.js +0 -275
  82. package/dist/chunk-VJJM2SSH.js.map +0 -1
  83. package/dist/chunk-VSYHZECT.js.map +0 -1
  84. package/dist/chunk-WC7WULGV.js.map +0 -1
  85. package/dist/chunk-XKUMNGBW.js.map +0 -1
  86. package/dist/chunk-YRT7GQ5Y.js +0 -28
  87. package/dist/chunk-YRT7GQ5Y.js.map +0 -1
  88. package/dist/container/index.js.map +0 -1
  89. package/dist/csv/index.js.map +0 -1
  90. package/dist/docx/index.js.map +0 -1
  91. package/dist/epub/index.js.map +0 -1
  92. package/dist/extract-H6RXJMHP.js +0 -15
  93. package/dist/extract-H6RXJMHP.js.map +0 -1
  94. package/dist/html/index.js.map +0 -1
  95. package/dist/images-7FBWPKE3.js +0 -7
  96. package/dist/images-7FBWPKE3.js.map +0 -1
  97. package/dist/index.js.map +0 -1
  98. package/dist/infer/index.js.map +0 -1
  99. package/dist/layouts-QVPK3ZCU.js +0 -12
  100. package/dist/layouts-QVPK3ZCU.js.map +0 -1
  101. package/dist/mapTheme-IR27S6IV.js.map +0 -1
  102. package/dist/ooxml/index.js.map +0 -1
  103. package/dist/pdf/index.js.map +0 -1
  104. package/dist/pptx/index.js.map +0 -1
  105. package/dist/registry/index.js.map +0 -1
  106. package/dist/xlsx/index.js.map +0 -1
  107. package/src/__tests__/container.test.ts +0 -230
  108. package/src/__tests__/convert.test.ts +0 -495
  109. package/src/__tests__/csvImport.test.ts +0 -84
  110. package/src/__tests__/docxExport.test.ts +0 -457
  111. package/src/__tests__/docxImport.test.ts +0 -410
  112. package/src/__tests__/epub.test.ts +0 -649
  113. package/src/__tests__/exportThemeReconciliation.test.ts +0 -87
  114. package/src/__tests__/formatRegistry.test.ts +0 -174
  115. package/src/__tests__/html.test.ts +0 -435
  116. package/src/__tests__/htmlImport.test.ts +0 -57
  117. package/src/__tests__/inferTheme.test.ts +0 -135
  118. package/src/__tests__/lossyWarnings.test.ts +0 -146
  119. package/src/__tests__/ooxml.test.ts +0 -271
  120. package/src/__tests__/ooxmlCancellation.test.ts +0 -113
  121. package/src/__tests__/ooxmlThemeReader.test.ts +0 -92
  122. package/src/__tests__/pdfExport.test.ts +0 -322
  123. package/src/__tests__/pdfImport.test.ts +0 -384
  124. package/src/__tests__/plainHtml.test.ts +0 -417
  125. package/src/__tests__/plainHtmlBundle.test.ts +0 -253
  126. package/src/__tests__/pptxExport.test.ts +0 -138
  127. package/src/__tests__/pptxImport.test.ts +0 -145
  128. package/src/__tests__/pptxInferFixtures.ts +0 -314
  129. package/src/__tests__/pptxLayoutInfer.test.ts +0 -395
  130. package/src/__tests__/roundTrip.test.ts +0 -201
  131. package/src/__tests__/roundTripAssets.test.ts +0 -50
  132. package/src/__tests__/roundTripMatrix.fixtures.ts +0 -86
  133. package/src/__tests__/roundTripMatrix.helpers.ts +0 -154
  134. package/src/__tests__/roundTripMatrix.test.ts +0 -142
  135. package/src/__tests__/sharedContainer.test.ts +0 -41
  136. package/src/__tests__/sharedImages.test.ts +0 -61
  137. package/src/__tests__/xlsxExport.test.ts +0 -164
  138. package/src/__tests__/xlsxImport.test.ts +0 -80
  139. package/src/__tests__/zipSafety.test.ts +0 -317
  140. package/src/container/index.ts +0 -94
  141. package/src/csv/index.ts +0 -188
  142. package/src/docx/export.ts +0 -1267
  143. package/src/docx/import.ts +0 -995
  144. package/src/docx/index.ts +0 -26
  145. package/src/docx/styles.ts +0 -145
  146. package/src/epub/export.ts +0 -968
  147. package/src/epub/index.ts +0 -20
  148. package/src/html/docsHtmlBundle.ts +0 -373
  149. package/src/html/htmlTemplate.ts +0 -385
  150. package/src/html/imageUtils.ts +0 -61
  151. package/src/html/import.ts +0 -297
  152. package/src/html/index.ts +0 -212
  153. package/src/html/plainHtml.ts +0 -790
  154. package/src/html/plainHtmlBundle.ts +0 -421
  155. package/src/index.ts +0 -109
  156. package/src/infer/extract.ts +0 -127
  157. package/src/infer/index.ts +0 -199
  158. package/src/infer/mapTheme.ts +0 -176
  159. package/src/infer/types.ts +0 -27
  160. package/src/ooxml/index.ts +0 -111
  161. package/src/ooxml/namespaces.ts +0 -196
  162. package/src/ooxml/readUtils.ts +0 -44
  163. package/src/ooxml/reader.ts +0 -318
  164. package/src/ooxml/themeReader.ts +0 -197
  165. package/src/ooxml/types.ts +0 -103
  166. package/src/ooxml/writer.ts +0 -339
  167. package/src/ooxml/xmlUtils.ts +0 -123
  168. package/src/pdf/export.ts +0 -1084
  169. package/src/pdf/import.ts +0 -1164
  170. package/src/pdf/index.ts +0 -29
  171. package/src/pdf/styles.ts +0 -180
  172. package/src/pptx/export.ts +0 -1184
  173. package/src/pptx/import.ts +0 -455
  174. package/src/pptx/index.ts +0 -52
  175. package/src/pptx/layouts.ts +0 -1222
  176. package/src/pptx/styles.ts +0 -96
  177. package/src/pptx/templates.ts +0 -187
  178. package/src/registry/convert.ts +0 -433
  179. package/src/registry/defaultFormats.ts +0 -413
  180. package/src/registry/errors.ts +0 -46
  181. package/src/registry/index.ts +0 -43
  182. package/src/registry/registry.ts +0 -48
  183. package/src/registry/types.ts +0 -170
  184. package/src/shared/boundedZipArchive.ts +0 -383
  185. package/src/shared/container.ts +0 -28
  186. package/src/shared/fidelity.ts +0 -130
  187. package/src/shared/images.ts +0 -44
  188. package/src/shared/inlineRuns.ts +0 -99
  189. package/src/shared/text.ts +0 -41
  190. package/src/shared/zipEntryCount.ts +0 -151
  191. package/src/shared/zipLimits.ts +0 -296
  192. package/src/shared/zipSafety.ts +0 -19
  193. package/src/xlsx/export.ts +0 -253
  194. package/src/xlsx/import.ts +0 -160
  195. package/src/xlsx/index.ts +0 -35
@@ -1,1184 +0,0 @@
1
- /**
2
- * PPTX Export
3
- *
4
- * Converts a squisq MarkdownDocument (or Doc) into a .pptx file
5
- * by generating PresentationML + DrawingML XML and assembling the
6
- * OOXML package.
7
- *
8
- * Slide segmentation: each H1 or H2 heading starts a new slide.
9
- * The heading becomes the slide title; content below becomes the body.
10
- *
11
- * Supports:
12
- * - Theme-based styling (background, text colors, fonts from squisq themes)
13
- * - Image embedding (pass images as ArrayBuffers via options)
14
- * - Inline formatting (bold, italic, strikethrough, code, links)
15
- * - Lists, code blocks, blockquotes, tables, math
16
- *
17
- * No third-party pptx library — all XML is generated directly using
18
- * the shared ooxml/ infrastructure.
19
- *
20
- * @example
21
- * ```ts
22
- * import { parseMarkdown } from '@bendyline/squisq/markdown';
23
- * import { markdownDocToPptx } from '@bendyline/squisq-formats/pptx';
24
- *
25
- * const md = parseMarkdown('# Slide 1\n\nHello world\n\n## Slide 2\n\nMore content');
26
- * const blob = await markdownDocToPptx(md, { themeId: 'documentary' });
27
- * ```
28
- */
29
-
30
- import type { Doc, Transition, TransitionDirection } from '@bendyline/squisq/schemas';
31
- import { resolveFontFamily } from '@bendyline/squisq/schemas';
32
- import type { Theme, ThemeRegistry } from '@bendyline/squisq/schemas';
33
- import { docToMarkdown, resolveThemeForDoc } from '@bendyline/squisq/doc';
34
- import type {
35
- MarkdownDocument,
36
- MarkdownBlockNode,
37
- MarkdownInlineNode,
38
- MarkdownHeading,
39
- MarkdownParagraph,
40
- MarkdownBlockquote,
41
- MarkdownList,
42
- MarkdownListItem,
43
- MarkdownCodeBlock,
44
- MarkdownTable,
45
- MarkdownTableRow,
46
- MarkdownHtmlBlock,
47
- MarkdownMathBlock,
48
- MarkdownLink,
49
- MarkdownImage,
50
- } from '@bendyline/squisq/markdown';
51
- import { readFrontmatterThemeId } from '@bendyline/squisq/markdown';
52
-
53
- import { createPackage } from '../ooxml/writer.js';
54
- import { xmlDeclaration, escapeXml } from '../ooxml/xmlUtils.js';
55
- import { inferMimeType } from '../html/imageUtils.js';
56
- import { stripHtmlTags, extractPlainText } from '../shared/text.js';
57
- import {
58
- inlineNodesToRuns,
59
- inlineNodeToRuns,
60
- type InlineRunHandlers,
61
- } from '../shared/inlineRuns.js';
62
- import {
63
- NS_PML,
64
- NS_PML_2010,
65
- NS_DRAWINGML,
66
- NS_MC,
67
- NS_R,
68
- REL_OFFICE_DOCUMENT,
69
- REL_SLIDE,
70
- REL_SLIDE_LAYOUT,
71
- REL_SLIDE_MASTER,
72
- REL_THEME,
73
- REL_HYPERLINK,
74
- REL_IMAGE,
75
- CONTENT_TYPE_PPTX_PRESENTATION,
76
- CONTENT_TYPE_PPTX_SLIDE,
77
- CONTENT_TYPE_PPTX_SLIDE_LAYOUT,
78
- CONTENT_TYPE_PPTX_SLIDE_MASTER,
79
- CONTENT_TYPE_PPTX_THEME,
80
- } from '../ooxml/namespaces.js';
81
- import {
82
- DEFAULT_FONT,
83
- DEFAULT_TITLE_FONT,
84
- DEFAULT_CODE_FONT,
85
- DEFAULT_TITLE_SIZE,
86
- DEFAULT_CODE_SIZE,
87
- HYPERLINK_COLOR,
88
- TITLE_LEFT,
89
- TITLE_TOP,
90
- TITLE_WIDTH,
91
- TITLE_HEIGHT,
92
- BODY_LEFT,
93
- BODY_TOP,
94
- BODY_WIDTH,
95
- BODY_HEIGHT,
96
- } from './styles.js';
97
- import {
98
- buildPresentationXml,
99
- buildSlideMasterXml,
100
- buildSlideLayoutXml,
101
- buildThemeXml,
102
- } from './templates.js';
103
-
104
- // ============================================
105
- // Public API
106
- // ============================================
107
-
108
- /**
109
- * Options for PPTX export.
110
- */
111
- export interface PptxExportOptions {
112
- /** Presentation title (appears in core properties) */
113
- title?: string;
114
- /** Presentation author */
115
- author?: string;
116
- /** Presentation description */
117
- description?: string;
118
- /**
119
- * Which heading depth triggers a new slide.
120
- * - `'h1'` — only H1 headings start slides
121
- * - `'h2'` — H1 and H2 headings start slides (default)
122
- * - `'heading'` — any heading (H1–H6) starts a slide
123
- */
124
- slideBreak?: 'h1' | 'h2' | 'heading';
125
- /** Default body font family. Default: "Calibri" */
126
- defaultFont?: string;
127
- /** Default body font size in points. Default: 18 */
128
- defaultFontSize?: number;
129
- /**
130
- * Squisq theme ID to apply (e.g., 'documentary', 'cinematic', 'bold').
131
- * Controls slide background, text colors, and fonts.
132
- * Falls back to the document's frontmatter `themeId` if not set here.
133
- */
134
- themeId?: string;
135
- /** Explicit caller-owned registry for non-document custom themes. */
136
- themeRegistry?: ThemeRegistry;
137
- /**
138
- * Image data keyed by the path/URL used in markdown.
139
- * When provided, images are embedded as picture shapes instead of
140
- * showing `[Image: alt]` placeholders.
141
- */
142
- images?: Map<string, ArrayBuffer>;
143
- }
144
-
145
- /**
146
- * Convert a MarkdownDocument to a .pptx ArrayBuffer.
147
- */
148
- export async function markdownDocToPptx(
149
- doc: MarkdownDocument,
150
- options: PptxExportOptions = {},
151
- ): Promise<ArrayBuffer> {
152
- // Resolve theme from options or frontmatter. The frontmatter lookup
153
- // accepts the editor's canonical `squisq-theme` key as well as the
154
- // shorter legacy `themeId` / `theme` aliases — see
155
- // `readFrontmatterThemeId` for the precedence.
156
- const themeId = options.themeId ?? readFrontmatterThemeId(doc.frontmatter);
157
- const style = resolveSlideStyle(themeId, options, doc);
158
-
159
- const slides = segmentIntoSlides(doc.children, options.slideBreak ?? 'h2');
160
-
161
- // Ensure at least one slide
162
- if (slides.length === 0) {
163
- slides.push({ bodyNodes: [] });
164
- }
165
-
166
- const slideXmls: string[] = [];
167
- const slideContexts: SlideContext[] = [];
168
-
169
- for (let i = 0; i < slides.length; i++) {
170
- const ctx = new SlideContext(style, options.images, i);
171
- const xml = buildSlideXml(slides[i], ctx);
172
- slideXmls.push(xml);
173
- slideContexts.push(ctx);
174
- }
175
-
176
- return buildPptxPackage(slideXmls, slideContexts, options);
177
- }
178
-
179
- /**
180
- * Convert a squisq Doc to a .pptx ArrayBuffer.
181
- *
182
- * Convenience wrapper: Doc -> MarkdownDocument -> PPTX.
183
- */
184
- export async function docToPptx(doc: Doc, options: PptxExportOptions = {}): Promise<ArrayBuffer> {
185
- const markdownDoc = docToMarkdown(doc);
186
- // Carry themeId from Doc if not set in options
187
- if (!options.themeId && doc.themeId) {
188
- options = { ...options, themeId: doc.themeId };
189
- }
190
- return markdownDocToPptx(markdownDoc, options);
191
- }
192
-
193
- // ============================================
194
- // Resolved Style
195
- // ============================================
196
-
197
- /** Resolved visual style for slides, derived from a Theme or defaults. */
198
- interface SlideStyle {
199
- background: string; // hex without #
200
- text: string;
201
- titleColor: string;
202
- mutedColor: string;
203
- titleFont: string;
204
- bodyFont: string;
205
- codeFont: string;
206
- codeColor: string;
207
- hasTheme: boolean;
208
- }
209
-
210
- function resolveSlideStyle(
211
- themeId: string | undefined,
212
- options: PptxExportOptions,
213
- doc: MarkdownDocument,
214
- ): SlideStyle {
215
- if (!themeId) {
216
- return {
217
- background: 'FFFFFF',
218
- text: '333333',
219
- titleColor: '333333',
220
- mutedColor: '666666',
221
- titleFont: options.defaultFont ?? DEFAULT_TITLE_FONT,
222
- bodyFont: options.defaultFont ?? DEFAULT_FONT,
223
- codeFont: DEFAULT_CODE_FONT,
224
- codeColor: '333333',
225
- hasTheme: false,
226
- };
227
- }
228
-
229
- const theme: Theme = resolveThemeForDoc(doc, themeId, options.themeRegistry);
230
- const c = theme.colors;
231
-
232
- return {
233
- background: stripHash(c.background),
234
- text: stripHash(c.text),
235
- titleColor: stripHash(c.highlight || c.secondary || c.text),
236
- mutedColor: stripHash(c.textMuted || c.text),
237
- titleFont: resolveFontFamily(theme.typography?.titleFont, DEFAULT_TITLE_FONT),
238
- bodyFont: resolveFontFamily(theme.typography?.bodyFont, options.defaultFont || DEFAULT_FONT),
239
- codeFont: resolveFontFamily(theme.typography?.monoFont, DEFAULT_CODE_FONT),
240
- codeColor: stripHash(c.textMuted || c.text),
241
- hasTheme: true,
242
- };
243
- }
244
-
245
- function stripHash(color: string): string {
246
- return color.startsWith('#') ? color.slice(1) : color;
247
- }
248
-
249
- // ============================================
250
- // Slide Segmentation
251
- // ============================================
252
-
253
- interface SlideData {
254
- title?: string;
255
- titleDepth?: number;
256
- transition?: Transition;
257
- bodyNodes: MarkdownBlockNode[];
258
- }
259
-
260
- function segmentIntoSlides(
261
- children: MarkdownBlockNode[],
262
- slideBreak: 'h1' | 'h2' | 'heading',
263
- ): SlideData[] {
264
- const maxDepth = slideBreak === 'h1' ? 1 : slideBreak === 'h2' ? 2 : 6;
265
- const slides: SlideData[] = [];
266
- let current: SlideData | undefined;
267
-
268
- for (const node of children) {
269
- if (node.type === 'heading' && node.depth <= maxDepth) {
270
- if (current) slides.push(current);
271
- current = {
272
- title: extractPlainText(node.children),
273
- titleDepth: node.depth,
274
- transition: node.attributes?.blockMeta?.transition,
275
- bodyNodes: [],
276
- };
277
- } else {
278
- if (!current) {
279
- current = { bodyNodes: [] };
280
- }
281
- current.bodyNodes.push(node);
282
- }
283
- }
284
-
285
- if (current) slides.push(current);
286
- return slides;
287
- }
288
-
289
- // ============================================
290
- // Slide Context
291
- // ============================================
292
-
293
- interface EmbeddedImage {
294
- relId: string;
295
- mediaPath: string; // e.g. ppt/media/image_s0_1.png
296
- data: ArrayBuffer;
297
- contentType: string;
298
- alt: string;
299
- }
300
-
301
- class SlideContext {
302
- private nextRelId = 1;
303
-
304
- readonly relationships: Array<{
305
- id: string;
306
- type: string;
307
- target: string;
308
- targetMode?: 'External';
309
- }> = [];
310
-
311
- readonly style: SlideStyle;
312
- readonly images: Map<string, ArrayBuffer> | undefined;
313
- readonly slideIndex: number;
314
- readonly embeddedImages: EmbeddedImage[] = [];
315
- private nextShapeId = 4; // 1=group, 2=title, 3=body
316
-
317
- constructor(style: SlideStyle, images: Map<string, ArrayBuffer> | undefined, slideIndex: number) {
318
- this.style = style;
319
- this.images = images;
320
- this.slideIndex = slideIndex;
321
- }
322
-
323
- allocRelId(): string {
324
- return `rId${this.nextRelId++ + 1}`;
325
- }
326
-
327
- allocShapeId(): number {
328
- return this.nextShapeId++;
329
- }
330
-
331
- addHyperlink(url: string): string {
332
- const id = this.allocRelId();
333
- this.relationships.push({
334
- id,
335
- type: REL_HYPERLINK,
336
- target: url,
337
- targetMode: 'External',
338
- });
339
- return id;
340
- }
341
-
342
- /** Register an embedded image and return its relationship ID */
343
- addImage(src: string, data: ArrayBuffer, alt: string): string {
344
- const relId = this.allocRelId();
345
- const ext = inferExtension(src);
346
- const contentType = inferMimeType(ext);
347
- const imgIndex = this.embeddedImages.length + 1;
348
- const mediaPath = `ppt/media/image_s${this.slideIndex}_${imgIndex}.${ext}`;
349
-
350
- this.embeddedImages.push({ relId, mediaPath, data, contentType, alt });
351
- this.relationships.push({
352
- id: relId,
353
- type: REL_IMAGE,
354
- target: `../media/image_s${this.slideIndex}_${imgIndex}.${ext}`,
355
- });
356
-
357
- return relId;
358
- }
359
- }
360
-
361
- // ============================================
362
- // Slide XML Generation
363
- // ============================================
364
-
365
- function buildSlideXml(slide: SlideData, ctx: SlideContext): string {
366
- const shapes: string[] = [];
367
-
368
- shapes.push(
369
- `<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>` + `<p:grpSpPr/>`,
370
- );
371
-
372
- if (slide.title) {
373
- shapes.push(buildTitleShape(slide.title, ctx.style));
374
- }
375
-
376
- // Convert body blocks and collect any image-only paragraphs
377
- const bodyParas = convertBodyBlocks(slide.bodyNodes, ctx);
378
- const hasImages = ctx.embeddedImages.length > 0;
379
- const hasText = bodyParas.length > 0;
380
-
381
- if (hasText && hasImages) {
382
- // Split: text in top half of body area, images below
383
- const textHeight = Math.round(BODY_HEIGHT * 0.45);
384
- const imgTop = BODY_TOP + textHeight + 91440; // 0.1" gap
385
- const imgHeight = BODY_HEIGHT - textHeight - 91440;
386
- shapes.push(buildBodyShapeCustom(bodyParas, BODY_TOP, textHeight, ctx.style));
387
- shapes.push(
388
- buildImageShape(
389
- ctx.embeddedImages[0],
390
- ctx.allocShapeId(),
391
- BODY_LEFT,
392
- imgTop,
393
- BODY_WIDTH,
394
- imgHeight,
395
- ),
396
- );
397
- } else if (hasImages && !hasText) {
398
- // Full content area for image
399
- shapes.push(
400
- buildImageShape(
401
- ctx.embeddedImages[0],
402
- ctx.allocShapeId(),
403
- BODY_LEFT,
404
- BODY_TOP,
405
- BODY_WIDTH,
406
- BODY_HEIGHT,
407
- ),
408
- );
409
- } else {
410
- // Text only (or empty)
411
- shapes.push(
412
- buildBodyShapeCustom(
413
- bodyParas || `<a:p><a:endParaRPr lang="en-US"/></a:p>`,
414
- BODY_TOP,
415
- BODY_HEIGHT,
416
- ctx.style,
417
- ),
418
- );
419
- }
420
-
421
- // Additional images beyond the first get placed as extra shapes
422
- for (let i = 1; i < ctx.embeddedImages.length; i++) {
423
- shapes.push(
424
- buildImageShape(
425
- ctx.embeddedImages[i],
426
- ctx.allocShapeId(),
427
- BODY_LEFT,
428
- BODY_TOP,
429
- BODY_WIDTH,
430
- BODY_HEIGHT,
431
- ),
432
- );
433
- }
434
-
435
- // Build background
436
- const bgXml = ctx.style.hasTheme
437
- ? `<p:bg><p:bgPr><a:solidFill><a:srgbClr val="${ctx.style.background}"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>`
438
- : '';
439
- const transitionXml = buildTransitionXml(slide.transition);
440
- const extensionAttrs = transitionXml.includes('p14:')
441
- ? ` xmlns:mc="${NS_MC}" xmlns:p14="${NS_PML_2010}" mc:Ignorable="p14"`
442
- : '';
443
-
444
- return (
445
- xmlDeclaration() +
446
- `<p:sld xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}" xmlns:p="${NS_PML}"${extensionAttrs}>` +
447
- `<p:cSld>` +
448
- bgXml +
449
- `<p:spTree>` +
450
- shapes.join('') +
451
- `</p:spTree>` +
452
- `</p:cSld>` +
453
- transitionXml +
454
- `</p:sld>`
455
- );
456
- }
457
-
458
- interface TransitionXml {
459
- xml: string;
460
- }
461
-
462
- function buildTransitionXml(transition: Transition | undefined): string {
463
- if (!transition || transition.type === 'cut') return '';
464
-
465
- const child = buildTransitionChildXml(transition);
466
- const spd = pptxSpeedForDuration(transition.duration);
467
- return `<p:transition spd="${spd}">${child.xml}</p:transition>`;
468
- }
469
-
470
- function buildTransitionChildXml(transition: Transition): TransitionXml {
471
- const type = transition.type;
472
- switch (type) {
473
- case 'fade':
474
- case 'morph':
475
- return p('fade');
476
- case 'dissolve':
477
- return p('dissolve');
478
- case 'push':
479
- return p('push', ` dir="${sideDir(transition.direction, 'left')}"`);
480
- case 'slideLeft':
481
- return p('push', ` dir="l"`);
482
- case 'slideRight':
483
- return p('push', ` dir="r"`);
484
- case 'slideUp':
485
- return p('push', ` dir="u"`);
486
- case 'slideDown':
487
- return p('push', ` dir="d"`);
488
- case 'wipe':
489
- return p('wipe', ` dir="${sideDir(transition.direction, 'left')}"`);
490
- case 'split':
491
- return p('split', ` orient="${orientation(transition.direction)}" dir="out"`);
492
- case 'randomBars':
493
- case 'randomBar':
494
- return p('randomBar', ` dir="${orientation(transition.direction)}"`);
495
- case 'shape':
496
- case 'circle':
497
- return p('circle');
498
- case 'diamond':
499
- return p('diamond');
500
- case 'plus':
501
- return p('plus');
502
- case 'uncover':
503
- case 'pull':
504
- return p('pull', ` dir="${sideDir(transition.direction, 'left')}"`);
505
- case 'cover':
506
- return p('cover', ` dir="${sideDir(transition.direction, 'left')}"`);
507
- case 'checkerboard':
508
- case 'checker':
509
- return p('checker', ` dir="${orientation(transition.direction)}"`);
510
- case 'blinds':
511
- return p('blinds', ` dir="${orientation(transition.direction)}"`);
512
- case 'comb':
513
- return p('comb', ` dir="${orientation(transition.direction)}"`);
514
- case 'clock':
515
- case 'wheel':
516
- return p('wheel', ` spokes="4"`);
517
- case 'wheelReverse':
518
- return p14('wheelReverse');
519
- case 'newsflash':
520
- return p('newsflash');
521
- case 'random':
522
- return p('random');
523
- case 'strips':
524
- return p('strips', ` dir="${cornerDir(transition.direction)}"`);
525
- case 'wedge':
526
- return p('wedge');
527
- case 'zoom':
528
- case 'box':
529
- return p('zoom', ` dir="${inOutDir(transition.direction, 'in')}"`);
530
- case 'flash':
531
- return p14('flash');
532
- case 'vortex':
533
- case 'orbit':
534
- case 'rotate':
535
- return p14('vortex');
536
- case 'switch':
537
- case 'cube':
538
- return p14('switch');
539
- case 'flip':
540
- case 'fallOver':
541
- return p14('flip');
542
- case 'ripple':
543
- return p14('ripple');
544
- case 'glitter':
545
- case 'prestige':
546
- return p14('glitter');
547
- case 'honeycomb':
548
- return p14('honeycomb');
549
- case 'prism':
550
- return p14('prism');
551
- case 'doors':
552
- case 'drape':
553
- case 'curtains':
554
- return p14('doors');
555
- case 'window':
556
- return p14('window');
557
- case 'shred':
558
- case 'fracture':
559
- case 'crush':
560
- return p14('shred');
561
- case 'ferris':
562
- case 'ferrisWheel':
563
- return p14('ferris');
564
- case 'flythrough':
565
- case 'flyThrough':
566
- case 'airplane':
567
- case 'origami':
568
- return p14('flythrough');
569
- case 'warp':
570
- case 'pageCurl':
571
- case 'pageCurlDouble':
572
- case 'pageCurlSingle':
573
- case 'peelOff':
574
- return p14('warp');
575
- case 'gallery':
576
- return p14('gallery');
577
- case 'conveyor':
578
- return p14('conveyor');
579
- case 'pan':
580
- case 'wind':
581
- return p14('pan');
582
- case 'reveal':
583
- return p14('reveal');
584
- default:
585
- return p('fade');
586
- }
587
- }
588
-
589
- function p(tag: string, attrs: string = ''): TransitionXml {
590
- return { xml: `<p:${tag}${attrs}/>` };
591
- }
592
-
593
- function p14(tag: string, attrs: string = ''): TransitionXml {
594
- return { xml: `<p14:${tag}${attrs}/>` };
595
- }
596
-
597
- function pptxSpeedForDuration(duration: number | undefined): 'fast' | 'med' | 'slow' {
598
- if (duration != null && duration <= 0.5) return 'fast';
599
- if (duration != null && duration >= 1.2) return 'slow';
600
- return 'med';
601
- }
602
-
603
- function sideDir(
604
- direction: TransitionDirection | undefined,
605
- fallback: TransitionDirection,
606
- ): string {
607
- switch (direction ?? fallback) {
608
- case 'right':
609
- return 'r';
610
- case 'up':
611
- return 'u';
612
- case 'down':
613
- return 'd';
614
- case 'left':
615
- default:
616
- return 'l';
617
- }
618
- }
619
-
620
- function cornerDir(direction: TransitionDirection | undefined): string {
621
- switch (direction) {
622
- case 'right':
623
- case 'down':
624
- return 'rd';
625
- case 'up':
626
- return 'lu';
627
- case 'left':
628
- default:
629
- return 'ld';
630
- }
631
- }
632
-
633
- function orientation(direction: TransitionDirection | undefined): string {
634
- return direction === 'vertical' || direction === 'up' || direction === 'down' ? 'vert' : 'horz';
635
- }
636
-
637
- function inOutDir(direction: TransitionDirection | undefined, fallback: 'in' | 'out'): string {
638
- return direction === 'out' ? 'out' : direction === 'in' ? 'in' : fallback;
639
- }
640
-
641
- function buildTitleShape(title: string, style: SlideStyle): string {
642
- return (
643
- `<p:sp>` +
644
- `<p:nvSpPr>` +
645
- `<p:cNvPr id="2" name="Title"/>` +
646
- `<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>` +
647
- `<p:nvPr><p:ph type="title"/></p:nvPr>` +
648
- `</p:nvSpPr>` +
649
- `<p:spPr>` +
650
- `<a:xfrm><a:off x="${TITLE_LEFT}" y="${TITLE_TOP}"/>` +
651
- `<a:ext cx="${TITLE_WIDTH}" cy="${TITLE_HEIGHT}"/></a:xfrm>` +
652
- `</p:spPr>` +
653
- `<p:txBody>` +
654
- `<a:bodyPr/>` +
655
- `<a:lstStyle/>` +
656
- `<a:p>` +
657
- `<a:r>` +
658
- `<a:rPr lang="en-US" sz="${DEFAULT_TITLE_SIZE}" dirty="0">` +
659
- `<a:solidFill><a:srgbClr val="${style.titleColor}"/></a:solidFill>` +
660
- `<a:latin typeface="${escapeXml(style.titleFont)}"/>` +
661
- `</a:rPr>` +
662
- `<a:t>${escapeXml(title)}</a:t>` +
663
- `</a:r>` +
664
- `</a:p>` +
665
- `</p:txBody>` +
666
- `</p:sp>`
667
- );
668
- }
669
-
670
- function buildBodyShapeCustom(
671
- bodyContent: string,
672
- top: number,
673
- height: number,
674
- style: SlideStyle,
675
- ): string {
676
- // Set default text color for the body via defRPr
677
- const defRPr = style.hasTheme
678
- ? `<a:defRPr><a:solidFill><a:srgbClr val="${style.text}"/></a:solidFill><a:latin typeface="${escapeXml(style.bodyFont)}"/></a:defRPr>`
679
- : '';
680
-
681
- return (
682
- `<p:sp>` +
683
- `<p:nvSpPr>` +
684
- `<p:cNvPr id="3" name="Content"/>` +
685
- `<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr>` +
686
- `<p:nvPr><p:ph idx="1"/></p:nvPr>` +
687
- `</p:nvSpPr>` +
688
- `<p:spPr>` +
689
- `<a:xfrm><a:off x="${BODY_LEFT}" y="${top}"/>` +
690
- `<a:ext cx="${BODY_WIDTH}" cy="${height}"/></a:xfrm>` +
691
- `</p:spPr>` +
692
- `<p:txBody>` +
693
- `<a:bodyPr/>` +
694
- `<a:lstStyle>` +
695
- `<a:lvl1pPr>${defRPr}</a:lvl1pPr>` +
696
- `</a:lstStyle>` +
697
- bodyContent +
698
- `</p:txBody>` +
699
- `</p:sp>`
700
- );
701
- }
702
-
703
- function buildImageShape(
704
- img: EmbeddedImage,
705
- shapeId: number,
706
- left: number,
707
- top: number,
708
- maxWidth: number,
709
- maxHeight: number,
710
- ): string {
711
- return (
712
- `<p:pic>` +
713
- `<p:nvPicPr>` +
714
- `<p:cNvPr id="${shapeId}" name="${escapeXml(img.alt || 'Picture')}"/>` +
715
- `<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>` +
716
- `<p:nvPr/>` +
717
- `</p:nvPicPr>` +
718
- `<p:blipFill>` +
719
- `<a:blip r:embed="${img.relId}"/>` +
720
- `<a:stretch><a:fillRect/></a:stretch>` +
721
- `</p:blipFill>` +
722
- `<p:spPr>` +
723
- `<a:xfrm><a:off x="${left}" y="${top}"/>` +
724
- `<a:ext cx="${maxWidth}" cy="${maxHeight}"/></a:xfrm>` +
725
- `<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` +
726
- `</p:spPr>` +
727
- `</p:pic>`
728
- );
729
- }
730
-
731
- // ============================================
732
- // Block Conversion (Markdown -> DrawingML paragraphs)
733
- // ============================================
734
-
735
- function convertBodyBlocks(nodes: MarkdownBlockNode[], ctx: SlideContext): string {
736
- const parts: string[] = [];
737
- for (const node of nodes) {
738
- parts.push(convertBlock(node, ctx, 0));
739
- }
740
- return parts.join('');
741
- }
742
-
743
- function convertBlock(node: MarkdownBlockNode, ctx: SlideContext, listDepth: number): string {
744
- switch (node.type) {
745
- case 'heading':
746
- return convertHeading(node, ctx);
747
- case 'paragraph':
748
- return convertParagraph(node, ctx);
749
- case 'blockquote':
750
- return convertBlockquote(node, ctx);
751
- case 'list':
752
- return convertList(node, ctx, listDepth);
753
- case 'code':
754
- return convertCodeBlock(node, ctx);
755
- case 'table':
756
- return convertTable(node, ctx);
757
- case 'thematicBreak':
758
- return convertThematicBreak(ctx);
759
- case 'htmlBlock':
760
- return convertHtmlBlock(node, ctx);
761
- case 'math':
762
- return convertMathBlock(node);
763
- default:
764
- return '';
765
- }
766
- }
767
-
768
- function convertHeading(node: MarkdownHeading, ctx: SlideContext): string {
769
- const size = headingSizeForDepth(node.depth);
770
- const runs = convertInlines(node.children, ctx, { bold: true });
771
- return (
772
- `<a:p>` +
773
- `<a:pPr>` +
774
- `<a:spcBef><a:spcPts val="600"/></a:spcBef>` +
775
- `<a:defRPr sz="${size}" b="1">` +
776
- `<a:solidFill><a:srgbClr val="${ctx.style.titleColor}"/></a:solidFill>` +
777
- `<a:latin typeface="${escapeXml(ctx.style.titleFont)}"/>` +
778
- `</a:defRPr>` +
779
- `</a:pPr>` +
780
- runs +
781
- `</a:p>`
782
- );
783
- }
784
-
785
- function headingSizeForDepth(depth: number): number {
786
- const sizes: Record<number, number> = { 1: 3200, 2: 2800, 3: 2400, 4: 2000, 5: 1800, 6: 1800 };
787
- return sizes[depth] ?? 1800;
788
- }
789
-
790
- /**
791
- * Check if a paragraph contains only a single image (no surrounding text).
792
- */
793
- function isImageOnlyParagraph(node: MarkdownParagraph): MarkdownImage | null {
794
- if (node.children.length === 1 && node.children[0].type === 'image') {
795
- return node.children[0];
796
- }
797
- return null;
798
- }
799
-
800
- function convertParagraph(node: MarkdownParagraph, ctx: SlideContext): string {
801
- // If paragraph is just an image and we have the data, embed it as a shape
802
- const soloImage = isImageOnlyParagraph(node);
803
- if (soloImage && ctx.images) {
804
- const data = ctx.images.get(soloImage.url);
805
- if (data) {
806
- ctx.addImage(soloImage.url, data, soloImage.alt || '');
807
- return ''; // Image will be rendered as a p:pic shape, not inline text
808
- }
809
- }
810
-
811
- const runs = convertInlines(node.children, ctx);
812
- return `<a:p>${runs}</a:p>`;
813
- }
814
-
815
- function convertBlockquote(node: MarkdownBlockquote, ctx: SlideContext): string {
816
- const parts: string[] = [];
817
- for (const child of node.children) {
818
- if (child.type === 'paragraph') {
819
- const runs = convertInlines(child.children, ctx, { italic: true });
820
- parts.push(
821
- `<a:p>` +
822
- `<a:pPr marL="457200">` +
823
- `<a:spcBef><a:spcPts val="200"/></a:spcBef>` +
824
- `</a:pPr>` +
825
- runs +
826
- `</a:p>`,
827
- );
828
- } else {
829
- parts.push(convertBlock(child, ctx, 0));
830
- }
831
- }
832
- return parts.join('');
833
- }
834
-
835
- function convertList(node: MarkdownList, ctx: SlideContext, depth: number): string {
836
- const parts: string[] = [];
837
- for (const item of node.children) {
838
- parts.push(convertListItem(item, ctx, node.ordered ?? false, depth));
839
- }
840
- return parts.join('');
841
- }
842
-
843
- function convertListItem(
844
- item: MarkdownListItem,
845
- ctx: SlideContext,
846
- ordered: boolean,
847
- depth: number,
848
- ): string {
849
- const parts: string[] = [];
850
- for (const child of item.children) {
851
- if (child.type === 'paragraph') {
852
- const runs = convertInlines(child.children, ctx);
853
- const bullet = ordered ? `<a:buAutoNum type="arabicPeriod"/>` : `<a:buChar char="\u2022"/>`;
854
- const indent = 457200 * (depth + 1);
855
- parts.push(
856
- `<a:p>` +
857
- `<a:pPr lvl="${depth}" marL="${indent}" indent="-228600">` +
858
- bullet +
859
- `</a:pPr>` +
860
- runs +
861
- `</a:p>`,
862
- );
863
- } else if (child.type === 'list') {
864
- parts.push(convertList(child, ctx, depth + 1));
865
- } else {
866
- parts.push(convertBlock(child, ctx, depth));
867
- }
868
- }
869
- return parts.join('');
870
- }
871
-
872
- function convertCodeBlock(node: MarkdownCodeBlock, ctx: SlideContext): string {
873
- const lines = node.value.split('\n');
874
- const parts: string[] = [];
875
- for (const line of lines) {
876
- parts.push(
877
- `<a:p>` +
878
- `<a:r>` +
879
- `<a:rPr lang="en-US" sz="${DEFAULT_CODE_SIZE}" dirty="0">` +
880
- `<a:latin typeface="${escapeXml(ctx.style.codeFont)}"/>` +
881
- `<a:solidFill><a:srgbClr val="${ctx.style.codeColor}"/></a:solidFill>` +
882
- `</a:rPr>` +
883
- `<a:t>${escapeXml(line || ' ')}</a:t>` +
884
- `</a:r>` +
885
- `</a:p>`,
886
- );
887
- }
888
- return parts.join('');
889
- }
890
-
891
- function convertTable(node: MarkdownTable, ctx: SlideContext): string {
892
- const parts: string[] = [];
893
- for (let ri = 0; ri < node.children.length; ri++) {
894
- const row = node.children[ri];
895
- parts.push(convertTableRow(row, ctx, ri === 0));
896
- }
897
- return parts.join('');
898
- }
899
-
900
- function convertTableRow(row: MarkdownTableRow, ctx: SlideContext, isHeader: boolean): string {
901
- const cells: string[] = [];
902
- for (const cell of row.children) {
903
- cells.push(extractPlainText(cell.children));
904
- }
905
- const text = cells.join(' | ');
906
- const format: InlineFormat = isHeader ? { bold: true } : {};
907
- return `<a:p>${makeRun(text, format, ctx.style)}</a:p>`;
908
- }
909
-
910
- function convertThematicBreak(ctx: SlideContext): string {
911
- return `<a:p><a:pPr><a:spcAft><a:spcPts val="1200"/></a:spcAft></a:pPr><a:r><a:rPr lang="en-US" sz="400"><a:solidFill><a:srgbClr val="${ctx.style.mutedColor}"/></a:solidFill></a:rPr><a:t>———</a:t></a:r></a:p>`;
912
- }
913
-
914
- function convertHtmlBlock(node: MarkdownHtmlBlock, ctx: SlideContext): string {
915
- const text = stripHtmlTags(node.rawHtml).trim();
916
- if (!text) return '';
917
- return `<a:p>${makeRun(text, {}, ctx.style)}</a:p>`;
918
- }
919
-
920
- function convertMathBlock(node: MarkdownMathBlock): string {
921
- return (
922
- `<a:p>` +
923
- `<a:pPr algn="ctr"/>` +
924
- `<a:r>` +
925
- `<a:rPr lang="en-US" i="1">` +
926
- `<a:latin typeface="Cambria Math"/>` +
927
- `</a:rPr>` +
928
- `<a:t>${escapeXml(node.value)}</a:t>` +
929
- `</a:r>` +
930
- `</a:p>`
931
- );
932
- }
933
-
934
- // ============================================
935
- // Inline Conversion (DrawingML runs)
936
- // ============================================
937
-
938
- interface InlineFormat {
939
- bold?: boolean;
940
- italic?: boolean;
941
- strike?: boolean;
942
- code?: boolean;
943
- }
944
-
945
- /**
946
- * PPTX leaf handlers for the shared run-based inline walker. The traversal
947
- * (format threading) lives in `shared/inlineRuns.ts`; these emit DrawingML.
948
- */
949
- function pptxRunHandlers(ctx: SlideContext): InlineRunHandlers {
950
- return {
951
- run: (text, format) => makeRun(text, format, ctx.style),
952
- link: (node, format) => convertLink(node, ctx, format),
953
- image: (node, format) => convertImage(node, format, ctx),
954
- lineBreak: () => `<a:br/>`,
955
- };
956
- }
957
-
958
- function convertInlines(
959
- nodes: MarkdownInlineNode[],
960
- ctx: SlideContext,
961
- format: InlineFormat = {},
962
- ): string {
963
- return inlineNodesToRuns(nodes, pptxRunHandlers(ctx), format);
964
- }
965
-
966
- function convertInline(node: MarkdownInlineNode, ctx: SlideContext, format: InlineFormat): string {
967
- return inlineNodeToRuns(node, pptxRunHandlers(ctx), format);
968
- }
969
-
970
- function makeRun(text: string, format: InlineFormat, style: SlideStyle): string {
971
- if (!text) return '';
972
-
973
- const rPrParts: string[] = [`lang="en-US"`, `dirty="0"`];
974
-
975
- if (format.bold) rPrParts.push(`b="1"`);
976
- if (format.italic) rPrParts.push(`i="1"`);
977
- if (format.strike) rPrParts.push(`strike="sngStrike"`);
978
-
979
- let innerParts = '';
980
-
981
- if (format.code) {
982
- innerParts += `<a:latin typeface="${escapeXml(style.codeFont)}"/>`;
983
- innerParts += `<a:solidFill><a:srgbClr val="${style.codeColor}"/></a:solidFill>`;
984
- rPrParts.push(`sz="${DEFAULT_CODE_SIZE}"`);
985
- } else if (style.hasTheme) {
986
- innerParts += `<a:solidFill><a:srgbClr val="${style.text}"/></a:solidFill>`;
987
- }
988
-
989
- return (
990
- `<a:r>` +
991
- `<a:rPr ${rPrParts.join(' ')}>${innerParts}</a:rPr>` +
992
- `<a:t>${escapeXml(text)}</a:t>` +
993
- `</a:r>`
994
- );
995
- }
996
-
997
- function convertLink(node: MarkdownLink, ctx: SlideContext, format: InlineFormat): string {
998
- const rId = ctx.addHyperlink(node.url);
999
- const parts: string[] = [];
1000
-
1001
- for (const child of node.children) {
1002
- if (child.type === 'text') {
1003
- parts.push(makeHyperlinkRun(child.value, rId, format));
1004
- } else {
1005
- parts.push(convertInline(child, ctx, format));
1006
- }
1007
- }
1008
-
1009
- return parts.join('');
1010
- }
1011
-
1012
- function makeHyperlinkRun(text: string, rId: string, format: InlineFormat): string {
1013
- if (!text) return '';
1014
-
1015
- const rPrParts: string[] = [`lang="en-US"`, `dirty="0"`];
1016
- if (format.bold) rPrParts.push(`b="1"`);
1017
- if (format.italic) rPrParts.push(`i="1"`);
1018
-
1019
- return (
1020
- `<a:r>` +
1021
- `<a:rPr ${rPrParts.join(' ')}>` +
1022
- `<a:solidFill><a:srgbClr val="${HYPERLINK_COLOR}"/></a:solidFill>` +
1023
- `<a:hlinkClick r:id="${rId}"/>` +
1024
- `</a:rPr>` +
1025
- `<a:t>${escapeXml(text)}</a:t>` +
1026
- `</a:r>`
1027
- );
1028
- }
1029
-
1030
- function convertImage(node: MarkdownImage, format: InlineFormat, ctx: SlideContext): string {
1031
- // For inline images (not image-only paragraphs), try to embed
1032
- if (ctx.images) {
1033
- const data = ctx.images.get(node.url);
1034
- if (data) {
1035
- // Register for rendering as a shape; return empty text
1036
- ctx.addImage(node.url, data, node.alt || '');
1037
- return '';
1038
- }
1039
- }
1040
- // Fallback: placeholder text
1041
- const alt = node.alt || node.url;
1042
- return makeRun(`[Image: ${alt}]`, { ...format, italic: true }, ctx.style);
1043
- }
1044
-
1045
- // ============================================
1046
- // Package Assembly
1047
- // ============================================
1048
-
1049
- async function buildPptxPackage(
1050
- slideXmls: string[],
1051
- slideContexts: SlideContext[],
1052
- options: PptxExportOptions,
1053
- ): Promise<ArrayBuffer> {
1054
- const pkg = createPackage();
1055
- const slideCount = slideXmls.length;
1056
-
1057
- const slideMasterRelId = 'rId100';
1058
- const themeRelId = 'rId101';
1059
- const slideRelIds: string[] = [];
1060
-
1061
- for (let i = 0; i < slideCount; i++) {
1062
- slideRelIds.push(`rId${i + 1}`);
1063
- }
1064
-
1065
- // --- ppt/presentation.xml ---
1066
- const presentationXml = buildPresentationXml(
1067
- slideCount,
1068
- slideRelIds,
1069
- slideMasterRelId,
1070
- themeRelId,
1071
- );
1072
- pkg.addPart('ppt/presentation.xml', presentationXml, CONTENT_TYPE_PPTX_PRESENTATION);
1073
-
1074
- // --- Slides ---
1075
- for (let i = 0; i < slideCount; i++) {
1076
- const slidePath = `ppt/slides/slide${i + 1}.xml`;
1077
- pkg.addPart(slidePath, slideXmls[i], CONTENT_TYPE_PPTX_SLIDE);
1078
-
1079
- pkg.addRelationship(slidePath, {
1080
- id: 'rId1',
1081
- type: REL_SLIDE_LAYOUT,
1082
- target: '../slideLayouts/slideLayout1.xml',
1083
- });
1084
-
1085
- // Per-slide relationships (hyperlinks + images)
1086
- const ctx = slideContexts[i];
1087
- for (const rel of ctx.relationships) {
1088
- pkg.addRelationship(slidePath, {
1089
- id: rel.id,
1090
- type: rel.type,
1091
- target: rel.target,
1092
- ...(rel.targetMode ? { targetMode: rel.targetMode } : {}),
1093
- });
1094
- }
1095
-
1096
- // Embed image binary parts
1097
- for (const img of ctx.embeddedImages) {
1098
- pkg.addBinaryPart(img.mediaPath, img.data, img.contentType);
1099
- }
1100
- }
1101
-
1102
- // --- Slide layout ---
1103
- const layoutMasterRelId = 'rId1';
1104
- const slideLayoutXml = buildSlideLayoutXml(layoutMasterRelId);
1105
- pkg.addPart('ppt/slideLayouts/slideLayout1.xml', slideLayoutXml, CONTENT_TYPE_PPTX_SLIDE_LAYOUT);
1106
- pkg.addRelationship('ppt/slideLayouts/slideLayout1.xml', {
1107
- id: layoutMasterRelId,
1108
- type: REL_SLIDE_MASTER,
1109
- target: '../slideMasters/slideMaster1.xml',
1110
- });
1111
-
1112
- // --- Slide master ---
1113
- const masterLayoutRelId = 'rId1';
1114
- const masterThemeRelId = 'rId2';
1115
- const slideMasterXml = buildSlideMasterXml(masterLayoutRelId);
1116
- pkg.addPart('ppt/slideMasters/slideMaster1.xml', slideMasterXml, CONTENT_TYPE_PPTX_SLIDE_MASTER);
1117
- pkg.addRelationship('ppt/slideMasters/slideMaster1.xml', {
1118
- id: masterLayoutRelId,
1119
- type: REL_SLIDE_LAYOUT,
1120
- target: '../slideLayouts/slideLayout1.xml',
1121
- });
1122
- pkg.addRelationship('ppt/slideMasters/slideMaster1.xml', {
1123
- id: masterThemeRelId,
1124
- type: REL_THEME,
1125
- target: '../theme/theme1.xml',
1126
- });
1127
-
1128
- // --- Theme ---
1129
- const themeXml = buildThemeXml();
1130
- pkg.addPart('ppt/theme/theme1.xml', themeXml, CONTENT_TYPE_PPTX_THEME);
1131
-
1132
- // --- Root relationship ---
1133
- pkg.addRelationship('', {
1134
- id: 'rId1',
1135
- type: REL_OFFICE_DOCUMENT,
1136
- target: 'ppt/presentation.xml',
1137
- });
1138
-
1139
- // --- Presentation relationships ---
1140
- for (let i = 0; i < slideCount; i++) {
1141
- pkg.addRelationship('ppt/presentation.xml', {
1142
- id: slideRelIds[i],
1143
- type: REL_SLIDE,
1144
- target: `slides/slide${i + 1}.xml`,
1145
- });
1146
- }
1147
- pkg.addRelationship('ppt/presentation.xml', {
1148
- id: slideMasterRelId,
1149
- type: REL_SLIDE_MASTER,
1150
- target: 'slideMasters/slideMaster1.xml',
1151
- });
1152
- pkg.addRelationship('ppt/presentation.xml', {
1153
- id: themeRelId,
1154
- type: REL_THEME,
1155
- target: 'theme/theme1.xml',
1156
- });
1157
-
1158
- // --- Core properties ---
1159
- if (options.title || options.author || options.description) {
1160
- pkg.setCoreProperties({
1161
- title: options.title,
1162
- creator: options.author,
1163
- description: options.description,
1164
- created: new Date().toISOString(),
1165
- modified: new Date().toISOString(),
1166
- });
1167
- }
1168
-
1169
- return pkg.toArrayBuffer();
1170
- }
1171
-
1172
- // ============================================
1173
- // Helpers
1174
- // ============================================
1175
-
1176
- function inferExtension(path: string): string {
1177
- const dot = path.lastIndexOf('.');
1178
- if (dot === -1) return 'png';
1179
- const ext = path
1180
- .substring(dot + 1)
1181
- .toLowerCase()
1182
- .split('?')[0];
1183
- return ext || 'png';
1184
- }