@erclx/canon 5.18.0 → 5.20.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.
- package/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/record-screencast/SKILL.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -3
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/task-board/REQUIREMENT.md +1 -0
- package/claude/skills/task-board/SKILL.md +6 -0
- package/claude/skills/task-board/references/decline.md +2 -0
- package/claude/skills/video-craft/REQUIREMENT.md +48 -0
- package/claude/skills/video-craft/SKILL.md +80 -0
- package/claude/skills/video-craft/references/adopted.md +66 -0
- package/docs/agents/canvas.md +4 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/demo.md +1 -1
- package/docs/agents/install-and-sync.md +17 -3
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/sandbox.md +8 -7
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/operating-model.md +9 -9
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/renames.toml +24 -0
- package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
- package/governance/rules/standards/context.md +15 -0
- package/governance/rules/standards/design.md +13 -0
- package/governance/rules/standards/diagrams.md +17 -0
- package/governance/rules/standards/groundwork.md +13 -0
- package/governance/rules/standards/intake.md +12 -0
- package/governance/rules/standards/markdown.md +15 -0
- package/governance/rules/standards/memory.md +12 -0
- package/governance/rules/standards/mermaid.md +15 -0
- package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
- package/governance/rules/standards/readme.md +12 -0
- package/governance/rules/standards/ready.md +13 -0
- package/governance/rules/standards/requirements.md +13 -0
- package/governance/rules/standards/rule.md +13 -0
- package/governance/rules/standards/session.md +13 -0
- package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
- package/governance/rules/standards/standard.md +13 -0
- package/governance/rules/standards/tasks.md +15 -0
- package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
- package/governance/rules/standards/wiki.md +13 -0
- package/governance/rules/standards/wireframes.md +14 -0
- package/governance/rules/writing/800-prose.md +1 -1
- package/governance/stacks/base.toml +4 -2
- package/package.json +2 -1
- package/src/canvas/capture.ts +228 -15
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/routing.ts +1 -1
- package/src/commands/canvas.ts +76 -2
- package/src/commands/gov.ts +19 -4
- package/src/commands/sandbox.ts +7 -0
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/gate/stages.ts +7 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/standard-rules.ts +179 -0
- package/src/sandbox/equivalence.ts +43 -14
- package/src/sandbox/stub-remote.ts +140 -0
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +5 -0
- package/standards/context.md +6 -0
- package/standards/design.md +3 -0
- package/standards/diagrams.md +8 -0
- package/standards/groundwork-spikes.md +66 -0
- package/standards/groundwork.md +5 -25
- package/standards/index.md +6 -2
- package/standards/intake.md +2 -0
- package/standards/markdown.md +6 -0
- package/standards/memory.md +2 -0
- package/standards/mermaid.md +6 -0
- package/standards/plan-lifecycle.md +36 -0
- package/standards/plan.md +13 -11
- package/standards/readme.md +2 -0
- package/standards/ready.md +4 -0
- package/standards/requirements.md +3 -0
- package/standards/rule.md +6 -1
- package/standards/session.md +4 -0
- package/standards/skill-paths.md +50 -0
- package/standards/skill.md +15 -23
- package/standards/standard.md +7 -1
- package/standards/tasks-decline.md +38 -0
- package/standards/tasks.md +8 -13
- package/standards/teach.md +5 -0
- package/standards/wiki.md +4 -0
- package/standards/wireframes.md +5 -0
- package/governance/rules/canon/610-context.md +0 -17
- package/governance/rules/canon/620-wireframes.md +0 -13
- package/governance/rules/canon/630-requirements.md +0 -12
- package/governance/rules/canon/650-design.md +0 -12
- package/governance/rules/canon/655-tasks.md +0 -13
- package/governance/rules/canon/656-groundwork.md +0 -11
- package/governance/rules/canon/657-intake.md +0 -11
- package/governance/rules/canon/659-memory.md +0 -11
- package/governance/rules/canon/660-diagrams.md +0 -19
- package/governance/rules/canon/662-session.md +0 -15
- package/governance/rules/canon/663-ready.md +0 -15
- package/governance/rules/claude/590-rule-authoring.md +0 -12
- package/governance/rules/writing/801-markdown.md +0 -14
- package/governance/rules/writing/802-mermaid.md +0 -14
- package/governance/rules/writing/880-readme.md +0 -11
- package/governance/rules/writing/891-standard-authoring.md +0 -12
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from 'node:fs'
|
|
2
|
+
import { SERVE_HOST } from '@/serve/static'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long a burst of watch events for one path waits before it is sent. One
|
|
6
|
+
* editor save raises several, and each would otherwise reach a reader again.
|
|
7
|
+
*/
|
|
8
|
+
const SETTLE_MS = 40
|
|
9
|
+
|
|
10
|
+
export interface LiveOptions<T> {
|
|
11
|
+
/**
|
|
12
|
+
* Turns a changed path, relative to the watched directory, into what a change
|
|
13
|
+
* carries, or into undefined to drop it, such as a write's staging file.
|
|
14
|
+
*/
|
|
15
|
+
readonly map: (path: string) => T | undefined
|
|
16
|
+
/**
|
|
17
|
+
* Takes each settled change in place of the clients. A caller that has work
|
|
18
|
+
* to do before a reader may look, such as regenerating pages, sends its own
|
|
19
|
+
* payload through `send` once that work lands.
|
|
20
|
+
*/
|
|
21
|
+
readonly onChange?: (payload: T) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether a request names this server by a loopback name and its own port.
|
|
26
|
+
* The bind keeps other machines out, and this keeps out a page the operator
|
|
27
|
+
* visits whose domain was rebound to 127.0.0.1, which reaches the socket with
|
|
28
|
+
* its own name in the Host header.
|
|
29
|
+
*/
|
|
30
|
+
export function isLoopbackHost(host: string | null, port: number): boolean {
|
|
31
|
+
return host === `${SERVE_HOST}:${port}` || host === `localhost:${port}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Watches a directory and fans each settled change out as a server-sent event
|
|
36
|
+
* to every open client, or to `onChange` when the caller takes changes itself.
|
|
37
|
+
*/
|
|
38
|
+
export class LiveStream<T> {
|
|
39
|
+
private readonly clients = new Set<ReadableStreamDefaultController<string>>()
|
|
40
|
+
private readonly pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
41
|
+
private readonly watcher: FSWatcher
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
dir: string,
|
|
45
|
+
private readonly options: LiveOptions<T>,
|
|
46
|
+
) {
|
|
47
|
+
this.watcher = watch(dir, { recursive: true }, (_event, filename) => {
|
|
48
|
+
if (filename) this.queue(filename.toString())
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private queue(path: string): void {
|
|
53
|
+
const payload = this.options.map(path)
|
|
54
|
+
if (payload === undefined) return
|
|
55
|
+
clearTimeout(this.pending.get(path))
|
|
56
|
+
this.pending.set(
|
|
57
|
+
path,
|
|
58
|
+
setTimeout(() => {
|
|
59
|
+
this.pending.delete(path)
|
|
60
|
+
if (this.options.onChange) this.options.onChange(payload)
|
|
61
|
+
else this.send(payload)
|
|
62
|
+
}, SETTLE_MS),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
send(payload: unknown): void {
|
|
67
|
+
const frame = `data: ${JSON.stringify(payload)}\n\n`
|
|
68
|
+
for (const client of this.clients) client.enqueue(frame)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
open(): Response {
|
|
72
|
+
let self: ReadableStreamDefaultController<string> | undefined
|
|
73
|
+
const stream = new ReadableStream<string>({
|
|
74
|
+
start: (controller) => {
|
|
75
|
+
self = controller
|
|
76
|
+
this.clients.add(controller)
|
|
77
|
+
controller.enqueue(': open\n\n')
|
|
78
|
+
},
|
|
79
|
+
cancel: () => {
|
|
80
|
+
if (self) this.clients.delete(self)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
return new Response(stream, {
|
|
84
|
+
headers: {
|
|
85
|
+
'content-type': 'text/event-stream',
|
|
86
|
+
'cache-control': 'no-store',
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Opens a stream for a request naming this server, and refuses any other. */
|
|
92
|
+
answer(request: Request, port: number): Response {
|
|
93
|
+
if (isLoopbackHost(request.headers.get('host'), port)) return this.open()
|
|
94
|
+
return new Response('Forbidden\n', {
|
|
95
|
+
status: 403,
|
|
96
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
close(): void {
|
|
101
|
+
this.watcher.close()
|
|
102
|
+
for (const timer of this.pending.values()) clearTimeout(timer)
|
|
103
|
+
this.pending.clear()
|
|
104
|
+
for (const client of this.clients) {
|
|
105
|
+
try {
|
|
106
|
+
client.close()
|
|
107
|
+
} catch {
|
|
108
|
+
/* Already closed by the browser going away. */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.clients.clear()
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/serve/static.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
2
2
|
import { join, resolve, sep } from 'node:path'
|
|
3
|
+
import type { LiveStream } from '@/serve/live'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The loopback interface, never a wildcard bind. A preview serves whatever
|
|
@@ -78,11 +79,25 @@ export interface ServeStarted {
|
|
|
78
79
|
|
|
79
80
|
export type ServeOutcome = ServeStarted | ServeRefused
|
|
80
81
|
|
|
82
|
+
/** The route a live page's script opens its event stream on. */
|
|
83
|
+
export const LIVE_EVENTS_PATH = '/__live'
|
|
84
|
+
|
|
85
|
+
export interface LiveServe {
|
|
86
|
+
readonly stream: Pick<LiveStream<unknown>, 'answer'>
|
|
87
|
+
/**
|
|
88
|
+
* The `<script>` element spliced into every page served, built by the caller
|
|
89
|
+
* so this module stays free of what a reload means to any one surface.
|
|
90
|
+
*/
|
|
91
|
+
readonly script: string
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export interface ServeOptions {
|
|
82
95
|
readonly port?: number
|
|
83
96
|
readonly entry?: string
|
|
84
97
|
/** Answers a directory request with no index page by listing it. Off by default. */
|
|
85
98
|
readonly index?: boolean
|
|
99
|
+
/** Answers the events route and splices a script into each page. Off by default. */
|
|
100
|
+
readonly live?: LiveServe
|
|
86
101
|
}
|
|
87
102
|
|
|
88
103
|
function escapeHtml(text: string): string {
|
|
@@ -253,16 +268,59 @@ export function bindFirstFree(
|
|
|
253
268
|
return undefined
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Splices the script in before the last closing body tag of a page. A page
|
|
273
|
+
* carrying none goes out as the bytes on disk, since guessing a place for it
|
|
274
|
+
* risks breaking markup the author wrote. The splice happens per response and
|
|
275
|
+
* never on disk, so a page the reader saves or commits carries no hook.
|
|
276
|
+
*/
|
|
277
|
+
export async function injectLive(
|
|
278
|
+
response: Response,
|
|
279
|
+
script: string,
|
|
280
|
+
): Promise<Response> {
|
|
281
|
+
const type = response.headers.get('content-type') ?? ''
|
|
282
|
+
if (response.status !== 200 || !type.startsWith('text/html')) return response
|
|
283
|
+
|
|
284
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
285
|
+
const html = new TextDecoder().decode(bytes)
|
|
286
|
+
/*
|
|
287
|
+
* Searched in the original rather than a lowercased copy, since lowercasing
|
|
288
|
+
* changes the length of some characters, such as İ, and an index read off
|
|
289
|
+
* the copy would cut the original inside the tag.
|
|
290
|
+
*/
|
|
291
|
+
const at = [...html.matchAll(/<\/body>/gi)].at(-1)?.index
|
|
292
|
+
const init = { status: response.status, headers: response.headers }
|
|
293
|
+
if (at === undefined) return new Response(bytes, init)
|
|
294
|
+
return new Response(`${html.slice(0, at)}${script}${html.slice(at)}`, init)
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
function listen(
|
|
257
298
|
root: string,
|
|
258
299
|
first: number,
|
|
259
300
|
index: boolean,
|
|
301
|
+
live: LiveServe | undefined,
|
|
260
302
|
): { server: BoundServer; port: number } | undefined {
|
|
303
|
+
if (!live) {
|
|
304
|
+
return bindFirstFree(first, (port) =>
|
|
305
|
+
Bun.serve({
|
|
306
|
+
hostname: SERVE_HOST,
|
|
307
|
+
port,
|
|
308
|
+
fetch: (request) => respond(root, request, index),
|
|
309
|
+
}),
|
|
310
|
+
)
|
|
311
|
+
}
|
|
261
312
|
return bindFirstFree(first, (port) =>
|
|
262
313
|
Bun.serve({
|
|
263
314
|
hostname: SERVE_HOST,
|
|
264
315
|
port,
|
|
265
|
-
|
|
316
|
+
/* The event stream stays open for as long as the page does. */
|
|
317
|
+
idleTimeout: 0,
|
|
318
|
+
fetch: async (request, server) => {
|
|
319
|
+
if (new URL(request.url).pathname === LIVE_EVENTS_PATH) {
|
|
320
|
+
return live.stream.answer(request, server.port ?? 0)
|
|
321
|
+
}
|
|
322
|
+
return injectLive(await respond(root, request, index), live.script)
|
|
323
|
+
},
|
|
266
324
|
}),
|
|
267
325
|
)
|
|
268
326
|
}
|
|
@@ -378,7 +436,7 @@ export function startServer(
|
|
|
378
436
|
*/
|
|
379
437
|
let bound: ReturnType<typeof listen>
|
|
380
438
|
try {
|
|
381
|
-
bound = listen(root, first, options.index ?? false)
|
|
439
|
+
bound = listen(root, first, options.index ?? false, options.live)
|
|
382
440
|
} catch (error) {
|
|
383
441
|
const code = (error as NodeJS.ErrnoException).code ?? 'unknown'
|
|
384
442
|
return refuse(
|
|
@@ -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
|
+
}
|