@erclx/canon 5.18.0 → 5.19.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.
@@ -0,0 +1,323 @@
1
+ import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+ import JSZip from 'jszip'
5
+ import type { Browser, Page } from 'playwright-core'
6
+ import PptxGenJS from 'pptxgenjs'
7
+ import { isBrowserMissing, isEngineMissing } from '@/browser/engine'
8
+ import { resolveFrameTokens } from '@/canvas/tokens'
9
+ import {
10
+ type DrawOp,
11
+ type Fallback,
12
+ planSlide,
13
+ type RefusedLink,
14
+ } from '@/slides/convert/shapes'
15
+ import {
16
+ ID_ATTRIBUTE,
17
+ type WalkedSlide,
18
+ walkSlide,
19
+ } from '@/slides/convert/walk'
20
+
21
+ /**
22
+ * Turns a folder of HTML slides into one editable deck. Chromium lays each
23
+ * slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
24
+ * placed, `planSlide` maps the records onto shapes, and an element whose CSS has
25
+ * no mapping lands as a screenshot of itself.
26
+ *
27
+ * Every browser reference this feature adds lives here, and the command reaches
28
+ * it through a dynamic import so no other command resolves the engine.
29
+ */
30
+
31
+ export const SLIDE_WIDTH = 1280
32
+ export const SLIDE_HEIGHT = 720
33
+
34
+ /** Device pixels per CSS pixel in every screenshot, so a picture stays sharp. */
35
+ const SCREENSHOT_SCALE = 2
36
+
37
+ /** English Metric Units per inch, the unit a slide's XML measures in. */
38
+ const EMU_PER_INCH = 914400
39
+
40
+ export interface SlideFallback extends Fallback {
41
+ readonly slide: number
42
+ }
43
+
44
+ export interface SlideRefusedLink extends RefusedLink {
45
+ readonly slide: number
46
+ }
47
+
48
+ export type ExportRefusal =
49
+ | 'empty-source'
50
+ | 'engine-missing'
51
+ | 'browser-missing'
52
+ | 'export-failed'
53
+
54
+ export type ExportResult =
55
+ | {
56
+ readonly status: 'written'
57
+ readonly pptxPath: string
58
+ readonly mirrorPath?: string
59
+ readonly slideCount: number
60
+ readonly fallbacks: readonly SlideFallback[]
61
+ readonly refusedLinks: readonly SlideRefusedLink[]
62
+ }
63
+ | {
64
+ readonly status: 'refused'
65
+ readonly reason: ExportRefusal
66
+ readonly message: string
67
+ }
68
+
69
+ export interface ExportOptions {
70
+ /** The project whose tokens the slides are laid out in. */
71
+ readonly root: string
72
+ readonly mirror?: string
73
+ }
74
+
75
+ function refused(reason: ExportRefusal, message: string): ExportResult {
76
+ return { status: 'refused', reason, message }
77
+ }
78
+
79
+ /** Every `.html` file directly in the folder, in filename order. */
80
+ export function slideFiles(sourceDir: string): string[] {
81
+ return readdirSync(sourceDir, { withFileTypes: true })
82
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
83
+ .map((entry) => entry.name)
84
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
85
+ .map((name) => join(sourceDir, name))
86
+ }
87
+
88
+ /** A picture whose bytes the post-write pass replaces or whose corners it rounds. */
89
+ interface Patch {
90
+ readonly slide: number
91
+ readonly name: string
92
+ readonly png?: Buffer
93
+ readonly radius?: number
94
+ }
95
+
96
+ export async function exportHtmlDeck(
97
+ sourceDir: string,
98
+ outDir: string,
99
+ options: ExportOptions,
100
+ ): Promise<ExportResult> {
101
+ const files = slideFiles(sourceDir)
102
+ if (files.length === 0) {
103
+ return refused('empty-source', `${sourceDir} holds no .html slides`)
104
+ }
105
+
106
+ let chromium: typeof import('playwright-core').chromium
107
+ try {
108
+ ;({ chromium } = await import('playwright-core'))
109
+ } catch (error) {
110
+ if (!isEngineMissing(error)) throw error
111
+ return refused('engine-missing', 'playwright-core is not installed')
112
+ }
113
+
114
+ let browser: Browser
115
+ try {
116
+ browser = await chromium.launch()
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error)
119
+ return refused(
120
+ isBrowserMissing(error) ? 'browser-missing' : 'export-failed',
121
+ message,
122
+ )
123
+ }
124
+
125
+ const tokens = resolveFrameTokens(options.root).css
126
+ const pptx = new PptxGenJS()
127
+ pptx.layout = 'LAYOUT_WIDE'
128
+ const fallbacks: SlideFallback[] = []
129
+ const refusedLinks: SlideRefusedLink[] = []
130
+ const patches: Patch[] = []
131
+
132
+ try {
133
+ const page = await browser.newPage({
134
+ viewport: { width: SLIDE_WIDTH, height: SLIDE_HEIGHT },
135
+ deviceScaleFactor: SCREENSHOT_SCALE,
136
+ })
137
+ for (const [index, file] of files.entries()) {
138
+ const slideNumber = index + 1
139
+ const walked = await layOut(page, file, tokens)
140
+ const plan = planSlide(walked.records, { slideCount: files.length })
141
+ fallbacks.push(
142
+ ...plan.fallbacks.map((each) => ({ ...each, slide: slideNumber })),
143
+ )
144
+ refusedLinks.push(
145
+ ...plan.refusedLinks.map((each) => ({ ...each, slide: slideNumber })),
146
+ )
147
+ const slide = pptx.addSlide()
148
+ slide.background = { color: walked.background.hex }
149
+ for (const [opIndex, op] of plan.ops.entries()) {
150
+ const name = `canon-${slideNumber}-${opIndex + 1}`
151
+ await draw(page, slide, { slide: slideNumber, name }, op, patches)
152
+ }
153
+ }
154
+ } catch (error) {
155
+ return refused(
156
+ 'export-failed',
157
+ error instanceof Error ? error.message : String(error),
158
+ )
159
+ } finally {
160
+ await browser.close().catch(() => undefined)
161
+ }
162
+
163
+ const written = await pptx.write({ outputType: 'nodebuffer' })
164
+ if (!(written instanceof Uint8Array)) {
165
+ return refused('export-failed', 'pptxgenjs returned no buffer')
166
+ }
167
+ const deck = await applyPatches(written, patches)
168
+
169
+ mkdirSync(outDir, { recursive: true })
170
+ const fileName = `${basename(sourceDir)}.pptx`
171
+ const pptxPath = join(outDir, fileName)
172
+ writeFileSync(pptxPath, deck)
173
+
174
+ let mirrorPath: string | undefined
175
+ if (options.mirror) {
176
+ mkdirSync(options.mirror, { recursive: true })
177
+ mirrorPath = join(options.mirror, fileName)
178
+ copyFileSync(pptxPath, mirrorPath)
179
+ }
180
+
181
+ return {
182
+ status: 'written',
183
+ pptxPath,
184
+ mirrorPath,
185
+ slideCount: files.length,
186
+ fallbacks,
187
+ refusedLinks,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * The tokens go in ahead of the slide's own styles, so a slide reads
193
+ * `var(--color-*)` from the project and can still override any of it.
194
+ */
195
+ async function layOut(
196
+ page: Page,
197
+ file: string,
198
+ tokens: string,
199
+ ): Promise<WalkedSlide> {
200
+ await page.goto(pathToFileURL(file).href, { waitUntil: 'load' })
201
+ if (tokens) {
202
+ await page.evaluate((css) => {
203
+ const style = document.createElement('style')
204
+ style.textContent = css
205
+ document.head.prepend(style)
206
+ }, tokens)
207
+ }
208
+ await page.evaluate(() => document.fonts.ready.then(() => undefined))
209
+ return page.evaluate(walkSlide, ID_ATTRIBUTE)
210
+ }
211
+
212
+ async function screenshot(page: Page, id: number): Promise<Buffer> {
213
+ return page
214
+ .locator(`[${ID_ATTRIBUTE}="${id}"]`)
215
+ .screenshot({ omitBackground: true, animations: 'disabled' })
216
+ }
217
+
218
+ function imageSource(src: string): Pick<PptxGenJS.ImageProps, 'path' | 'data'> {
219
+ if (src.startsWith('data:')) return { data: src.slice('data:'.length) }
220
+ if (src.startsWith('file:')) return { path: fileURLToPath(src) }
221
+ return { path: src }
222
+ }
223
+
224
+ const base64 = (bytes: Buffer | string): string =>
225
+ Buffer.from(bytes).toString('base64')
226
+
227
+ async function draw(
228
+ page: Page,
229
+ slide: PptxGenJS.Slide,
230
+ target: { readonly slide: number; readonly name: string },
231
+ op: DrawOp,
232
+ patches: Patch[],
233
+ ): Promise<void> {
234
+ if (op.kind === 'shape') {
235
+ slide.addShape(op.shape, op.options)
236
+ } else if (op.kind === 'text') {
237
+ slide.addText(op.runs, op.options)
238
+ } else if (op.kind === 'table') {
239
+ slide.addTable(op.rows, op.options)
240
+ } else if (op.kind === 'image') {
241
+ slide.addImage({
242
+ ...op.options,
243
+ ...imageSource(op.src),
244
+ objectName: target.name,
245
+ })
246
+ if (op.radius > 0) patches.push({ ...target, radius: op.radius })
247
+ if (op.frame) slide.addShape(op.radius > 0 ? 'roundRect' : 'rect', op.frame)
248
+ } else if (op.kind === 'svg') {
249
+ slide.addImage({
250
+ ...op.options,
251
+ data: `image/svg+xml;base64,${base64(op.markup)}`,
252
+ objectName: target.name,
253
+ })
254
+ patches.push({ ...target, png: await screenshot(page, op.id) })
255
+ } else {
256
+ const png = await screenshot(page, op.id)
257
+ slide.addImage({ ...op.options, data: `image/png;base64,${base64(png)}` })
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Two things pptxgenjs cannot write, fixed in the package it wrote. An SVG's
263
+ * PNG fallback comes out as the SVG's own bytes under a `.png` name in Node, so
264
+ * a viewer that cannot draw SVG gets a broken image, and a picture takes no
265
+ * corner radius. Each patched picture is found by the object name `draw` gave
266
+ * it, which is the only handle the written XML keeps.
267
+ */
268
+ async function applyPatches(
269
+ deck: Uint8Array,
270
+ patches: readonly Patch[],
271
+ ): Promise<Buffer> {
272
+ const zip = await JSZip.loadAsync(deck)
273
+ const bySlide = Map.groupBy(patches, (patch) => patch.slide)
274
+ for (const [slideNumber, slidePatches] of bySlide) {
275
+ const slidePath = `ppt/slides/slide${slideNumber}.xml`
276
+ const relsPath = `ppt/slides/_rels/slide${slideNumber}.xml.rels`
277
+ let xml = (await zip.file(slidePath)?.async('string')) ?? ''
278
+ const rels = (await zip.file(relsPath)?.async('string')) ?? ''
279
+ for (const patch of slidePatches) {
280
+ const pic = pictureXml(xml, patch.name)
281
+ if (!pic) continue
282
+ if (patch.png) {
283
+ const rId = /<a:blip r:embed="(rId\d+)"/.exec(pic)?.[1]
284
+ const target = rId
285
+ ? new RegExp(`Id="${rId}"[^>]*Target="\\.\\./([^"]+)"`).exec(
286
+ rels,
287
+ )?.[1]
288
+ : undefined
289
+ if (target) zip.file(`ppt/${target}`, patch.png)
290
+ }
291
+ if (patch.radius) {
292
+ xml = xml.replace(pic, roundCorners(pic, patch.radius))
293
+ }
294
+ }
295
+ zip.file(slidePath, xml)
296
+ }
297
+ return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
298
+ }
299
+
300
+ function pictureXml(xml: string, name: string): string | undefined {
301
+ const start = xml.indexOf(`name="${name}"`)
302
+ if (start < 0) return undefined
303
+ const open = xml.lastIndexOf('<p:pic>', start)
304
+ const close = xml.indexOf('</p:pic>', start)
305
+ return open < 0 || close < 0
306
+ ? undefined
307
+ : xml.slice(open, close + '</p:pic>'.length)
308
+ }
309
+
310
+ /** The adjust value is the radius over the shorter side, out of 100000. */
311
+ function roundCorners(pic: string, radius: number): string {
312
+ const extent = /<a:ext cx="(\d+)" cy="(\d+)"\/>/.exec(pic)
313
+ const shorter = extent ? Math.min(Number(extent[1]), Number(extent[2])) : 0
314
+ if (shorter === 0) return pic
315
+ const adjust = Math.min(
316
+ 50000,
317
+ Math.round((radius * EMU_PER_INCH * 100000) / shorter),
318
+ )
319
+ return pic.replace(
320
+ /<a:prstGeom prst="rect"><a:avLst\/><\/a:prstGeom>/,
321
+ `<a:prstGeom prst="roundRect"><a:avLst><a:gd name="adj" fmla="val ${adjust}"/></a:avLst></a:prstGeom>`,
322
+ )
323
+ }