@erclx/canon 5.19.0 → 5.21.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -0
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- 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/index.md +1 -0
- package/docs/agents/install-and-sync.md +17 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/sandbox.md +8 -7
- package/docs/agents/slides.md +120 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +9 -9
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +12 -13
- package/governance/renames.toml +24 -0
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- 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 +1 -1
- package/src/canvas/capture.ts +228 -15
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/workflow.ts +4 -20
- 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 +13 -3
- package/src/commands/tasks.ts +46 -2
- package/src/gate/stages.ts +7 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/standard-rules.ts +179 -0
- package/src/migrate/skill-names.ts +0 -7
- package/src/sandbox/equivalence.ts +43 -14
- package/src/sandbox/stub-remote.ts +140 -0
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +164 -0
- package/src/slides/convert/export.ts +217 -7
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +1 -1
- package/src/slides/convert/walk.ts +187 -2
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -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 +9 -14
- package/standards/teach.md +5 -0
- package/standards/wiki.md +4 -0
- package/standards/wireframes.md +5 -0
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
- 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
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
2
8
|
import { basename, join } from 'node:path'
|
|
3
9
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
10
|
import JSZip from 'jszip'
|
|
@@ -6,6 +12,22 @@ import type { Browser, Page } from 'playwright-core'
|
|
|
6
12
|
import PptxGenJS from 'pptxgenjs'
|
|
7
13
|
import { isBrowserMissing, isEngineMissing } from '@/browser/engine'
|
|
8
14
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
15
|
+
import {
|
|
16
|
+
type ChartTheme,
|
|
17
|
+
planChart,
|
|
18
|
+
seriesColors,
|
|
19
|
+
} from '@/slides/convert/chart'
|
|
20
|
+
import { type DeckConfig, readDeck } from '@/slides/convert/deck'
|
|
21
|
+
import {
|
|
22
|
+
bandTexts,
|
|
23
|
+
buildMaster,
|
|
24
|
+
type DeckTheme,
|
|
25
|
+
deckTheme,
|
|
26
|
+
type MarkSize,
|
|
27
|
+
type MasterBands,
|
|
28
|
+
masterName,
|
|
29
|
+
slideNumberProps,
|
|
30
|
+
} from '@/slides/convert/master'
|
|
9
31
|
import {
|
|
10
32
|
type DrawOp,
|
|
11
33
|
type Fallback,
|
|
@@ -13,7 +35,10 @@ import {
|
|
|
13
35
|
type RefusedLink,
|
|
14
36
|
} from '@/slides/convert/shapes'
|
|
15
37
|
import {
|
|
38
|
+
type BandOverride,
|
|
16
39
|
ID_ATTRIBUTE,
|
|
40
|
+
readTheme,
|
|
41
|
+
type SlideMeta,
|
|
17
42
|
type WalkedSlide,
|
|
18
43
|
walkSlide,
|
|
19
44
|
} from '@/slides/convert/walk'
|
|
@@ -22,7 +47,8 @@ import {
|
|
|
22
47
|
* Turns a folder of HTML slides into one editable deck. Chromium lays each
|
|
23
48
|
* slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
|
|
24
49
|
* placed, `planSlide` maps the records onto shapes, and an element whose CSS has
|
|
25
|
-
* no mapping lands as a screenshot of itself.
|
|
50
|
+
* no mapping lands as a screenshot of itself. The first slide's computed tokens
|
|
51
|
+
* build the masters, and `deck.json` sets the bands they carry.
|
|
26
52
|
*
|
|
27
53
|
* Every browser reference this feature adds lives here, and the command reaches
|
|
28
54
|
* it through a dynamic import so no other command resolves the engine.
|
|
@@ -45,8 +71,16 @@ export interface SlideRefusedLink extends RefusedLink {
|
|
|
45
71
|
readonly slide: number
|
|
46
72
|
}
|
|
47
73
|
|
|
74
|
+
/** A chart left out of the deck, with the reason the report prints. */
|
|
75
|
+
export interface RefusedChart {
|
|
76
|
+
readonly slide: number
|
|
77
|
+
readonly selector: string
|
|
78
|
+
readonly message: string
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
export type ExportRefusal =
|
|
49
82
|
| 'empty-source'
|
|
83
|
+
| 'deck-invalid'
|
|
50
84
|
| 'engine-missing'
|
|
51
85
|
| 'browser-missing'
|
|
52
86
|
| 'export-failed'
|
|
@@ -59,6 +93,9 @@ export type ExportResult =
|
|
|
59
93
|
readonly slideCount: number
|
|
60
94
|
readonly fallbacks: readonly SlideFallback[]
|
|
61
95
|
readonly refusedLinks: readonly SlideRefusedLink[]
|
|
96
|
+
readonly refusedCharts: readonly RefusedChart[]
|
|
97
|
+
/** What the deck master could not take from the project's tokens. */
|
|
98
|
+
readonly notices: readonly string[]
|
|
62
99
|
}
|
|
63
100
|
| {
|
|
64
101
|
readonly status: 'refused'
|
|
@@ -102,6 +139,11 @@ export async function exportHtmlDeck(
|
|
|
102
139
|
if (files.length === 0) {
|
|
103
140
|
return refused('empty-source', `${sourceDir} holds no .html slides`)
|
|
104
141
|
}
|
|
142
|
+
const deckRead = readDeck(sourceDir)
|
|
143
|
+
if (deckRead.status === 'refused') {
|
|
144
|
+
return refused('deck-invalid', deckRead.message)
|
|
145
|
+
}
|
|
146
|
+
const deck = deckRead.deck
|
|
105
147
|
|
|
106
148
|
let chromium: typeof import('playwright-core').chromium
|
|
107
149
|
try {
|
|
@@ -125,15 +167,35 @@ export async function exportHtmlDeck(
|
|
|
125
167
|
const tokens = resolveFrameTokens(options.root).css
|
|
126
168
|
const pptx = new PptxGenJS()
|
|
127
169
|
pptx.layout = 'LAYOUT_WIDE'
|
|
170
|
+
pptx.title = deck.title
|
|
128
171
|
const fallbacks: SlideFallback[] = []
|
|
129
172
|
const refusedLinks: SlideRefusedLink[] = []
|
|
173
|
+
const refusedCharts: RefusedChart[] = []
|
|
130
174
|
const patches: Patch[] = []
|
|
175
|
+
const notices: string[] = []
|
|
131
176
|
|
|
132
177
|
try {
|
|
133
178
|
const page = await browser.newPage({
|
|
134
179
|
viewport: { width: SLIDE_WIDTH, height: SLIDE_HEIGHT },
|
|
135
180
|
deviceScaleFactor: SCREENSHOT_SCALE,
|
|
136
181
|
})
|
|
182
|
+
await prepare(page, files[0] ?? '', tokens)
|
|
183
|
+
const reading = await page.evaluate(readTheme)
|
|
184
|
+
const read = deckTheme(reading)
|
|
185
|
+
const theme = read.theme
|
|
186
|
+
notices.push(...read.notices)
|
|
187
|
+
const drifted: number[] = []
|
|
188
|
+
const chartTheme: ChartTheme = {
|
|
189
|
+
colors: seriesColors(theme.accent, reading.roles),
|
|
190
|
+
ink: theme.ink,
|
|
191
|
+
muted: theme.muted,
|
|
192
|
+
face: theme.face,
|
|
193
|
+
}
|
|
194
|
+
pptx.theme = { headFontFace: theme.face, bodyFontFace: theme.face }
|
|
195
|
+
const markSize = deck.mark ? await measure(page, deck.mark) : undefined
|
|
196
|
+
const masters = new Set<string>()
|
|
197
|
+
const sections = new Sections(deck.title, declaresSections(files))
|
|
198
|
+
|
|
137
199
|
for (const [index, file] of files.entries()) {
|
|
138
200
|
const slideNumber = index + 1
|
|
139
201
|
const walked = await layOut(page, file, tokens)
|
|
@@ -144,12 +206,48 @@ export async function exportHtmlDeck(
|
|
|
144
206
|
refusedLinks.push(
|
|
145
207
|
...plan.refusedLinks.map((each) => ({ ...each, slide: slideNumber })),
|
|
146
208
|
)
|
|
147
|
-
const
|
|
148
|
-
|
|
209
|
+
const { textToken } = walked.meta
|
|
210
|
+
if (textToken !== undefined && textToken !== theme.ink) {
|
|
211
|
+
drifted.push(slideNumber)
|
|
212
|
+
}
|
|
213
|
+
// pptxgenjs binds a slide to its master at creation, so the bands this
|
|
214
|
+
// slide keeps decide the master before any shape is added.
|
|
215
|
+
const bands = masterBands(deck, walked.meta)
|
|
216
|
+
const master = masterName(bands)
|
|
217
|
+
if (!masters.has(master)) {
|
|
218
|
+
pptx.defineSlideMaster(buildMaster(deck, theme, bands, markSize))
|
|
219
|
+
masters.add(master)
|
|
220
|
+
}
|
|
221
|
+
const sectionTitle = sections.titleFor(walked.meta, pptx)
|
|
222
|
+
const slide = pptx.addSlide({
|
|
223
|
+
masterName: master,
|
|
224
|
+
...(sectionTitle ? { sectionTitle } : {}),
|
|
225
|
+
})
|
|
226
|
+
if (walked.background.hex !== theme.background) {
|
|
227
|
+
slide.background = { color: walked.background.hex }
|
|
228
|
+
}
|
|
229
|
+
if (walked.meta.isHidden) slide.hidden = true
|
|
149
230
|
for (const [opIndex, op] of plan.ops.entries()) {
|
|
150
231
|
const name = `canon-${slideNumber}-${opIndex + 1}`
|
|
151
232
|
await draw(page, slide, { slide: slideNumber, name }, op, patches)
|
|
152
233
|
}
|
|
234
|
+
for (const record of walked.charts) {
|
|
235
|
+
const chart = planChart(record, chartTheme)
|
|
236
|
+
if (chart.status === 'refused') {
|
|
237
|
+
refusedCharts.push({ ...chart, slide: slideNumber })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
slide.addChart(chart.type, [...chart.data], chart.options)
|
|
241
|
+
}
|
|
242
|
+
drawOverrides(slide, deck, theme, walked.meta)
|
|
243
|
+
if (walked.meta.notes) slide.addNotes(walked.meta.notes)
|
|
244
|
+
}
|
|
245
|
+
if (drifted.length > 0) {
|
|
246
|
+
const noun = drifted.length === 1 ? 'slide' : 'slides'
|
|
247
|
+
const verb = drifted.length === 1 ? 'sets' : 'set'
|
|
248
|
+
notices.push(
|
|
249
|
+
`${noun} ${drifted.join(', ')} ${verb} a --color-text apart from the master, so ${drifted.length === 1 ? 'its' : 'their'} bands keep the master colors`,
|
|
250
|
+
)
|
|
153
251
|
}
|
|
154
252
|
} catch (error) {
|
|
155
253
|
return refused(
|
|
@@ -164,12 +262,12 @@ export async function exportHtmlDeck(
|
|
|
164
262
|
if (!(written instanceof Uint8Array)) {
|
|
165
263
|
return refused('export-failed', 'pptxgenjs returned no buffer')
|
|
166
264
|
}
|
|
167
|
-
const
|
|
265
|
+
const patched = await applyPatches(written, patches)
|
|
168
266
|
|
|
169
267
|
mkdirSync(outDir, { recursive: true })
|
|
170
268
|
const fileName = `${basename(sourceDir)}.pptx`
|
|
171
269
|
const pptxPath = join(outDir, fileName)
|
|
172
|
-
writeFileSync(pptxPath,
|
|
270
|
+
writeFileSync(pptxPath, patched)
|
|
173
271
|
|
|
174
272
|
let mirrorPath: string | undefined
|
|
175
273
|
if (options.mirror) {
|
|
@@ -185,9 +283,113 @@ export async function exportHtmlDeck(
|
|
|
185
283
|
slideCount: files.length,
|
|
186
284
|
fallbacks,
|
|
187
285
|
refusedLinks,
|
|
286
|
+
refusedCharts,
|
|
287
|
+
notices,
|
|
188
288
|
}
|
|
189
289
|
}
|
|
190
290
|
|
|
291
|
+
/**
|
|
292
|
+
* A band stays on the master unless the deck has it off anyway, so a slide
|
|
293
|
+
* hiding only the header keeps the master's footer and numbers.
|
|
294
|
+
*/
|
|
295
|
+
function masterBands(deck: DeckConfig, meta: SlideMeta): MasterBands {
|
|
296
|
+
return {
|
|
297
|
+
header: deck.header.show && meta.header.kind === 'master',
|
|
298
|
+
footer: deck.footer.show && meta.footer.kind === 'master',
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type Override = Extract<BandOverride, { readonly kind: 'override' }>
|
|
303
|
+
|
|
304
|
+
/** The slide's slots over the deck's, so overriding the center keeps the title. */
|
|
305
|
+
function mergeBand(override: Override, base: DeckConfig['header']) {
|
|
306
|
+
return {
|
|
307
|
+
left: override.left ?? base.left,
|
|
308
|
+
center: override.center ?? base.center,
|
|
309
|
+
right: override.right ?? base.right,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function drawOverrides(
|
|
314
|
+
slide: PptxGenJS.Slide,
|
|
315
|
+
deck: DeckConfig,
|
|
316
|
+
theme: DeckTheme,
|
|
317
|
+
meta: SlideMeta,
|
|
318
|
+
): void {
|
|
319
|
+
if (meta.header.kind === 'override') {
|
|
320
|
+
const band = mergeBand(meta.header, deck.header)
|
|
321
|
+
const hasMark = deck.mark !== undefined
|
|
322
|
+
for (const text of bandTexts(band, 'header', theme, { hasMark })) {
|
|
323
|
+
slide.addText(text.text, text.options)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (meta.footer.kind === 'override') {
|
|
327
|
+
const band = mergeBand(meta.footer, deck.footer)
|
|
328
|
+
for (const text of bandTexts(band, 'footer', theme)) {
|
|
329
|
+
slide.addText(text.text, text.options)
|
|
330
|
+
}
|
|
331
|
+
if (deck.slideNumbers && band.right === undefined) {
|
|
332
|
+
slide.slideNumber = slideNumberProps(theme)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A slide's `data-section` opens a section the slides after it stay in.
|
|
339
|
+
* PowerPoint wants every slide in a section once any exists, so slides ahead of
|
|
340
|
+
* the first declared one open a section named after the deck.
|
|
341
|
+
*
|
|
342
|
+
* pptxgenjs files a slide under the first section carrying its title, so a
|
|
343
|
+
* title the deck returns to takes a numbered suffix rather than sending the
|
|
344
|
+
* slide back into the earlier section, which PowerPoint reads as a broken list.
|
|
345
|
+
*/
|
|
346
|
+
export class Sections {
|
|
347
|
+
/** The title as declared, and as written once a repeat is numbered. */
|
|
348
|
+
private declared: string | undefined
|
|
349
|
+
private current: string | undefined
|
|
350
|
+
private readonly opened = new Map<string, number>()
|
|
351
|
+
|
|
352
|
+
constructor(
|
|
353
|
+
private readonly deckTitle: string,
|
|
354
|
+
private readonly isUsed: boolean,
|
|
355
|
+
) {}
|
|
356
|
+
|
|
357
|
+
titleFor(meta: SlideMeta, pptx: PptxGenJS): string | undefined {
|
|
358
|
+
if (!this.isUsed) return undefined
|
|
359
|
+
const isContinuing =
|
|
360
|
+
meta.section === undefined || meta.section === this.declared
|
|
361
|
+
if (isContinuing && this.current !== undefined) return this.current
|
|
362
|
+
const title = meta.section ?? this.deckTitle
|
|
363
|
+
const count = (this.opened.get(title) ?? 0) + 1
|
|
364
|
+
this.opened.set(title, count)
|
|
365
|
+
this.declared = title
|
|
366
|
+
this.current = count === 1 ? title : `${title} (${count})`
|
|
367
|
+
pptx.addSection({ title: this.current })
|
|
368
|
+
return this.current
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether any slide declares a section, read from the source ahead of layout,
|
|
374
|
+
* since the first slide is placed before the last is laid out.
|
|
375
|
+
*/
|
|
376
|
+
function declaresSections(files: readonly string[]): boolean {
|
|
377
|
+
return files.some((file) =>
|
|
378
|
+
/<body\b[^>]*\sdata-section\s*=/i.test(readFileSync(file, 'utf8')),
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The mark's intrinsic size as Chromium decodes it, square where unknown. */
|
|
383
|
+
async function measure(page: Page, path: string): Promise<MarkSize> {
|
|
384
|
+
const size = await page.evaluate(async (src) => {
|
|
385
|
+
const image = new Image()
|
|
386
|
+
image.src = src
|
|
387
|
+
await image.decode().catch(() => undefined)
|
|
388
|
+
return { width: image.naturalWidth, height: image.naturalHeight }
|
|
389
|
+
}, pathToFileURL(path).href)
|
|
390
|
+
return size.width > 0 && size.height > 0 ? size : { width: 1, height: 1 }
|
|
391
|
+
}
|
|
392
|
+
|
|
191
393
|
/**
|
|
192
394
|
* The tokens go in ahead of the slide's own styles, so a slide reads
|
|
193
395
|
* `var(--color-*)` from the project and can still override any of it.
|
|
@@ -197,6 +399,15 @@ async function layOut(
|
|
|
197
399
|
file: string,
|
|
198
400
|
tokens: string,
|
|
199
401
|
): Promise<WalkedSlide> {
|
|
402
|
+
await prepare(page, file, tokens)
|
|
403
|
+
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function prepare(
|
|
407
|
+
page: Page,
|
|
408
|
+
file: string,
|
|
409
|
+
tokens: string,
|
|
410
|
+
): Promise<void> {
|
|
200
411
|
await page.goto(pathToFileURL(file).href, { waitUntil: 'load' })
|
|
201
412
|
if (tokens) {
|
|
202
413
|
await page.evaluate((css) => {
|
|
@@ -206,7 +417,6 @@ async function layOut(
|
|
|
206
417
|
}, tokens)
|
|
207
418
|
}
|
|
208
419
|
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
209
|
-
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
210
420
|
}
|
|
211
421
|
|
|
212
422
|
async function screenshot(page: Page, id: number): Promise<Buffer> {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type PptxGenJS from 'pptxgenjs'
|
|
2
|
+
import {
|
|
3
|
+
BAND_SLOTS,
|
|
4
|
+
type Band,
|
|
5
|
+
type BandSlot,
|
|
6
|
+
type DeckConfig,
|
|
7
|
+
} from '@/slides/convert/deck'
|
|
8
|
+
import { fontFace } from '@/slides/convert/shapes'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The deck's master, built from the project's own tokens as the page computed
|
|
12
|
+
* them rather than from the toolkit's design module. A slide that hides or
|
|
13
|
+
* overrides a band takes a master without that band, so editing the footer
|
|
14
|
+
* once in PowerPoint reaches every slide still on the master's own.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface DeckTheme {
|
|
18
|
+
readonly background: string
|
|
19
|
+
readonly ink: string
|
|
20
|
+
readonly muted: string
|
|
21
|
+
readonly accent: string
|
|
22
|
+
readonly face: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What the first slide computed, read in the page by `readTheme`. */
|
|
26
|
+
export interface ThemeReading {
|
|
27
|
+
/** Each role's `--color-*` value as hex, absent where none is declared. */
|
|
28
|
+
readonly tokens: {
|
|
29
|
+
readonly background?: string
|
|
30
|
+
readonly text?: string
|
|
31
|
+
readonly muted?: string
|
|
32
|
+
readonly accent?: string
|
|
33
|
+
}
|
|
34
|
+
readonly body: { readonly background: string; readonly color: string }
|
|
35
|
+
readonly fontFamily: string
|
|
36
|
+
/** Every declared `--color-*` property in sheet order, for chart series. */
|
|
37
|
+
readonly roles: readonly { readonly name: string; readonly hex: string }[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MasterBands {
|
|
41
|
+
readonly header: boolean
|
|
42
|
+
readonly footer: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Intrinsic pixel size, which sets the mark's aspect inside its box. */
|
|
46
|
+
export interface MarkSize {
|
|
47
|
+
readonly width: number
|
|
48
|
+
readonly height: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SLIDE_W = 13.333
|
|
52
|
+
const MARGIN = 0.5
|
|
53
|
+
const BAND_H = 0.4
|
|
54
|
+
const HEADER_Y = 0.2
|
|
55
|
+
const FOOTER_Y = 7.5 - 0.2 - BAND_H
|
|
56
|
+
const SLOT_W = (SLIDE_W - 2 * MARGIN) / 3
|
|
57
|
+
const NUMBER_W = 1
|
|
58
|
+
const MARK_BOX = { w: 1.2, h: BAND_H }
|
|
59
|
+
const BAND_PT = 10
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A missing role falls back to what the slide's body computed, never to the
|
|
63
|
+
* toolkit's palette, which would put Canon's colors on another project's deck.
|
|
64
|
+
*/
|
|
65
|
+
export function deckTheme(reading: ThemeReading): {
|
|
66
|
+
readonly theme: DeckTheme
|
|
67
|
+
readonly notices: readonly string[]
|
|
68
|
+
} {
|
|
69
|
+
const { tokens, body } = reading
|
|
70
|
+
const missing = (
|
|
71
|
+
[
|
|
72
|
+
['background', tokens.background],
|
|
73
|
+
['text', tokens.text],
|
|
74
|
+
['muted', tokens.muted],
|
|
75
|
+
['accent', tokens.accent],
|
|
76
|
+
] as const
|
|
77
|
+
)
|
|
78
|
+
.filter(([, value]) => value === undefined)
|
|
79
|
+
.map(([role]) => `--color-${role}`)
|
|
80
|
+
return {
|
|
81
|
+
theme: {
|
|
82
|
+
background: tokens.background ?? body.background,
|
|
83
|
+
ink: tokens.text ?? body.color,
|
|
84
|
+
muted: tokens.muted ?? body.color,
|
|
85
|
+
accent: tokens.accent ?? body.color,
|
|
86
|
+
face: fontFace(reading.fontFamily),
|
|
87
|
+
},
|
|
88
|
+
notices:
|
|
89
|
+
missing.length === 0
|
|
90
|
+
? []
|
|
91
|
+
: [
|
|
92
|
+
`no ${missing.join(' or ')} token, so the deck master takes the slide body ${missing.length === 1 ? 'color for it' : 'color for them'}`,
|
|
93
|
+
],
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function masterName(bands: MasterBands): string {
|
|
98
|
+
const parts = [bands.header && 'header', bands.footer && 'footer'].filter(
|
|
99
|
+
Boolean,
|
|
100
|
+
)
|
|
101
|
+
return parts.length === 0 ? 'canon-bare' : `canon-${parts.join('-')}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SLOT_ALIGN: Record<BandSlot, PptxGenJS.HAlign> = {
|
|
105
|
+
left: 'left',
|
|
106
|
+
center: 'center',
|
|
107
|
+
right: 'right',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One text object per filled slot. The master draws these for a band it
|
|
112
|
+
* carries, and a slide overriding a band draws its own through the same call.
|
|
113
|
+
*/
|
|
114
|
+
export function bandTexts(
|
|
115
|
+
band: Omit<Band, 'show'>,
|
|
116
|
+
kind: 'header' | 'footer',
|
|
117
|
+
theme: DeckTheme,
|
|
118
|
+
options: { readonly hasMark?: boolean } = {},
|
|
119
|
+
): { readonly text: string; readonly options: PptxGenJS.TextPropsOptions }[] {
|
|
120
|
+
return BAND_SLOTS.flatMap((slot, index) => {
|
|
121
|
+
const text = band[slot]
|
|
122
|
+
if (!text) return []
|
|
123
|
+
const isMarkSlot = kind === 'header' && slot === 'right' && options.hasMark
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
text,
|
|
127
|
+
options: {
|
|
128
|
+
x: MARGIN + index * SLOT_W,
|
|
129
|
+
y: kind === 'header' ? HEADER_Y : FOOTER_Y,
|
|
130
|
+
w: SLOT_W - (isMarkSlot ? MARK_BOX.w + 0.1 : 0),
|
|
131
|
+
h: BAND_H,
|
|
132
|
+
margin: 0,
|
|
133
|
+
align: SLOT_ALIGN[slot],
|
|
134
|
+
valign: 'middle',
|
|
135
|
+
fontFace: theme.face,
|
|
136
|
+
fontSize: BAND_PT,
|
|
137
|
+
color: kind === 'header' ? theme.ink : theme.muted,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
]
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The right of the footer, which `deck.json` keeps free while numbers show. */
|
|
145
|
+
export function slideNumberProps(theme: DeckTheme): PptxGenJS.SlideNumberProps {
|
|
146
|
+
return {
|
|
147
|
+
x: SLIDE_W - MARGIN - NUMBER_W,
|
|
148
|
+
y: FOOTER_Y,
|
|
149
|
+
w: NUMBER_W,
|
|
150
|
+
h: BAND_H,
|
|
151
|
+
margin: 0,
|
|
152
|
+
align: 'right',
|
|
153
|
+
valign: 'middle',
|
|
154
|
+
fontFace: theme.face,
|
|
155
|
+
fontSize: BAND_PT,
|
|
156
|
+
color: theme.accent,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function markImage(path: string, size: MarkSize): PptxGenJS.ImageProps {
|
|
161
|
+
const scale = Math.min(MARK_BOX.w / size.width, MARK_BOX.h / size.height)
|
|
162
|
+
const w = Math.round(size.width * scale * 1000) / 1000
|
|
163
|
+
const h = Math.round(size.height * scale * 1000) / 1000
|
|
164
|
+
return { path, x: SLIDE_W - MARGIN - w, y: HEADER_Y, w, h }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function buildMaster(
|
|
168
|
+
deck: DeckConfig,
|
|
169
|
+
theme: DeckTheme,
|
|
170
|
+
bands: MasterBands,
|
|
171
|
+
markSize: MarkSize = { width: 1, height: 1 },
|
|
172
|
+
): PptxGenJS.SlideMasterProps {
|
|
173
|
+
const hasHeader = bands.header && deck.header.show
|
|
174
|
+
const hasFooter = bands.footer && deck.footer.show
|
|
175
|
+
const hasMark = deck.mark !== undefined
|
|
176
|
+
const objects: NonNullable<PptxGenJS.SlideMasterProps['objects']> = [
|
|
177
|
+
...(deck.mark ? [{ image: markImage(deck.mark, markSize) }] : []),
|
|
178
|
+
...(hasHeader
|
|
179
|
+
? bandTexts(deck.header, 'header', theme, { hasMark }).map((text) => ({
|
|
180
|
+
text,
|
|
181
|
+
}))
|
|
182
|
+
: []),
|
|
183
|
+
...(hasFooter
|
|
184
|
+
? bandTexts(deck.footer, 'footer', theme).map((text) => ({ text }))
|
|
185
|
+
: []),
|
|
186
|
+
]
|
|
187
|
+
return {
|
|
188
|
+
title: masterName(bands),
|
|
189
|
+
background: { color: theme.background },
|
|
190
|
+
objects,
|
|
191
|
+
...(hasFooter && deck.slideNumbers
|
|
192
|
+
? { slideNumber: slideNumberProps(theme) }
|
|
193
|
+
: {}),
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -150,7 +150,7 @@ const PT_PER_PX = 0.75
|
|
|
150
150
|
const inches = (px: number): number => px / PX_PER_INCH
|
|
151
151
|
const points = (px: number): number => px * PT_PER_PX
|
|
152
152
|
|
|
153
|
-
function position(box: Rect): PptxGenJS.PositionProps {
|
|
153
|
+
export function position(box: Rect): PptxGenJS.PositionProps {
|
|
154
154
|
return {
|
|
155
155
|
x: inches(box.x),
|
|
156
156
|
y: inches(box.y),
|