@erclx/canon 5.20.0 → 5.22.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/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +2 -1
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +164 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/target/sync.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +25 -31
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +23 -3
- package/src/commands/sync.ts +10 -0
- package/src/commands/tasks.ts +46 -2
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +244 -0
- package/src/slides/convert/export.ts +281 -23
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +44 -4
- package/src/slides/convert/walk.ts +229 -2
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/architecture.md +0 -1
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +0 -1
- package/standards/rule.md +1 -0
- package/standards/tasks.md +1 -1
- package/standards/wireframes.md +0 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
|
@@ -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,16 +35,30 @@ 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'
|
|
45
|
+
import { embedFonts, type FontNotice } from '@/slides/package/fonts'
|
|
46
|
+
import {
|
|
47
|
+
type EntranceSpec,
|
|
48
|
+
entranceShapes,
|
|
49
|
+
type MotionNotice,
|
|
50
|
+
type SlideMotionEdit,
|
|
51
|
+
writeMotion,
|
|
52
|
+
} from '@/slides/package/motion'
|
|
20
53
|
|
|
21
54
|
/**
|
|
22
55
|
* Turns a folder of HTML slides into one editable deck. Chromium lays each
|
|
23
56
|
* slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
|
|
24
57
|
* placed, `planSlide` maps the records onto shapes, and an element whose CSS has
|
|
25
|
-
* no mapping lands as a screenshot of itself.
|
|
58
|
+
* no mapping lands as a screenshot of itself. The first slide's computed tokens
|
|
59
|
+
* build the masters, and `deck.json` sets the bands they carry. Once pptxgenjs
|
|
60
|
+
* has written the package, `src/slides/package/` adds the transitions,
|
|
61
|
+
* entrances, and embedded faces it declares nothing for.
|
|
26
62
|
*
|
|
27
63
|
* Every browser reference this feature adds lives here, and the command reaches
|
|
28
64
|
* it through a dynamic import so no other command resolves the engine.
|
|
@@ -45,8 +81,16 @@ export interface SlideRefusedLink extends RefusedLink {
|
|
|
45
81
|
readonly slide: number
|
|
46
82
|
}
|
|
47
83
|
|
|
84
|
+
/** A chart left out of the deck, with the reason the report prints. */
|
|
85
|
+
export interface RefusedChart {
|
|
86
|
+
readonly slide: number
|
|
87
|
+
readonly selector: string
|
|
88
|
+
readonly message: string
|
|
89
|
+
}
|
|
90
|
+
|
|
48
91
|
export type ExportRefusal =
|
|
49
92
|
| 'empty-source'
|
|
93
|
+
| 'deck-invalid'
|
|
50
94
|
| 'engine-missing'
|
|
51
95
|
| 'browser-missing'
|
|
52
96
|
| 'export-failed'
|
|
@@ -59,6 +103,13 @@ export type ExportResult =
|
|
|
59
103
|
readonly slideCount: number
|
|
60
104
|
readonly fallbacks: readonly SlideFallback[]
|
|
61
105
|
readonly refusedLinks: readonly SlideRefusedLink[]
|
|
106
|
+
readonly refusedCharts: readonly RefusedChart[]
|
|
107
|
+
/** Transitions and entrances left out, each with the reason. */
|
|
108
|
+
readonly refusedMotion: readonly MotionNotice[]
|
|
109
|
+
/** Faces `deck.json` lists that the deck does not carry, with the reason. */
|
|
110
|
+
readonly refusedFonts: readonly FontNotice[]
|
|
111
|
+
/** What the deck master could not take from the project's tokens. */
|
|
112
|
+
readonly notices: readonly string[]
|
|
62
113
|
}
|
|
63
114
|
| {
|
|
64
115
|
readonly status: 'refused'
|
|
@@ -102,6 +153,11 @@ export async function exportHtmlDeck(
|
|
|
102
153
|
if (files.length === 0) {
|
|
103
154
|
return refused('empty-source', `${sourceDir} holds no .html slides`)
|
|
104
155
|
}
|
|
156
|
+
const deckRead = readDeck(sourceDir)
|
|
157
|
+
if (deckRead.status === 'refused') {
|
|
158
|
+
return refused('deck-invalid', deckRead.message)
|
|
159
|
+
}
|
|
160
|
+
const deck = deckRead.deck
|
|
105
161
|
|
|
106
162
|
let chromium: typeof import('playwright-core').chromium
|
|
107
163
|
try {
|
|
@@ -125,15 +181,36 @@ export async function exportHtmlDeck(
|
|
|
125
181
|
const tokens = resolveFrameTokens(options.root).css
|
|
126
182
|
const pptx = new PptxGenJS()
|
|
127
183
|
pptx.layout = 'LAYOUT_WIDE'
|
|
184
|
+
pptx.title = deck.title
|
|
128
185
|
const fallbacks: SlideFallback[] = []
|
|
129
186
|
const refusedLinks: SlideRefusedLink[] = []
|
|
187
|
+
const refusedCharts: RefusedChart[] = []
|
|
130
188
|
const patches: Patch[] = []
|
|
189
|
+
const motions: SlideMotionEdit[] = []
|
|
190
|
+
const notices: string[] = []
|
|
131
191
|
|
|
132
192
|
try {
|
|
133
193
|
const page = await browser.newPage({
|
|
134
194
|
viewport: { width: SLIDE_WIDTH, height: SLIDE_HEIGHT },
|
|
135
195
|
deviceScaleFactor: SCREENSHOT_SCALE,
|
|
136
196
|
})
|
|
197
|
+
await prepare(page, files[0] ?? '', tokens)
|
|
198
|
+
const reading = await page.evaluate(readTheme)
|
|
199
|
+
const read = deckTheme(reading)
|
|
200
|
+
const theme = read.theme
|
|
201
|
+
notices.push(...read.notices)
|
|
202
|
+
const drifted: number[] = []
|
|
203
|
+
const chartTheme: ChartTheme = {
|
|
204
|
+
colors: seriesColors(theme.accent, reading.roles),
|
|
205
|
+
ink: theme.ink,
|
|
206
|
+
muted: theme.muted,
|
|
207
|
+
face: theme.face,
|
|
208
|
+
}
|
|
209
|
+
pptx.theme = { headFontFace: theme.face, bodyFontFace: theme.face }
|
|
210
|
+
const markSize = deck.mark ? await measure(page, deck.mark) : undefined
|
|
211
|
+
const masters = new Set<string>()
|
|
212
|
+
const sections = new Sections(deck.title, declaresSections(files))
|
|
213
|
+
|
|
137
214
|
for (const [index, file] of files.entries()) {
|
|
138
215
|
const slideNumber = index + 1
|
|
139
216
|
const walked = await layOut(page, file, tokens)
|
|
@@ -144,13 +221,66 @@ export async function exportHtmlDeck(
|
|
|
144
221
|
refusedLinks.push(
|
|
145
222
|
...plan.refusedLinks.map((each) => ({ ...each, slide: slideNumber })),
|
|
146
223
|
)
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
224
|
+
const { textToken } = walked.meta
|
|
225
|
+
if (textToken !== undefined && textToken !== theme.ink) {
|
|
226
|
+
drifted.push(slideNumber)
|
|
227
|
+
}
|
|
228
|
+
// pptxgenjs binds a slide to its master at creation, so the bands this
|
|
229
|
+
// slide keeps decide the master before any shape is added.
|
|
230
|
+
const bands = masterBands(deck, walked.meta)
|
|
231
|
+
const master = masterName(bands)
|
|
232
|
+
if (!masters.has(master)) {
|
|
233
|
+
pptx.defineSlideMaster(buildMaster(deck, theme, bands, markSize))
|
|
234
|
+
masters.add(master)
|
|
235
|
+
}
|
|
236
|
+
const sectionTitle = sections.titleFor(walked.meta, pptx)
|
|
237
|
+
const slide = pptx.addSlide({
|
|
238
|
+
masterName: master,
|
|
239
|
+
...(sectionTitle ? { sectionTitle } : {}),
|
|
240
|
+
})
|
|
241
|
+
if (walked.background.hex !== theme.background) {
|
|
242
|
+
slide.background = { color: walked.background.hex }
|
|
243
|
+
}
|
|
244
|
+
if (walked.meta.isHidden) slide.hidden = true
|
|
245
|
+
for (const op of plan.ops) {
|
|
246
|
+
await draw(page, slide, slideNumber, op, patches)
|
|
247
|
+
}
|
|
248
|
+
for (const record of walked.charts) {
|
|
249
|
+
const chart = planChart(record, chartTheme)
|
|
250
|
+
if (chart.status === 'refused') {
|
|
251
|
+
refusedCharts.push({ ...chart, slide: slideNumber })
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
slide.addChart(chart.type, [...chart.data], chart.options)
|
|
255
|
+
}
|
|
256
|
+
drawOverrides(slide, deck, theme, walked.meta)
|
|
257
|
+
if (walked.meta.notes) slide.addNotes(walked.meta.notes)
|
|
258
|
+
const { transition } = walked.meta
|
|
259
|
+
const roots = new Set(
|
|
260
|
+
walked.entrances.flatMap(({ record }) =>
|
|
261
|
+
record === null ? [] : [record],
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
const entrances = walked.entrances.map(
|
|
265
|
+
({ record, ...spec }): EntranceSpec => ({
|
|
266
|
+
...spec,
|
|
267
|
+
shapes: entranceShapes(record, walked.records, plan.names, roots),
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
if (transition || entrances.length > 0) {
|
|
271
|
+
motions.push({
|
|
272
|
+
slide: slideNumber,
|
|
273
|
+
motion: { ...(transition ? { transition } : {}), entrances },
|
|
274
|
+
})
|
|
152
275
|
}
|
|
153
276
|
}
|
|
277
|
+
if (drifted.length > 0) {
|
|
278
|
+
const noun = drifted.length === 1 ? 'slide' : 'slides'
|
|
279
|
+
const verb = drifted.length === 1 ? 'sets' : 'set'
|
|
280
|
+
notices.push(
|
|
281
|
+
`${noun} ${drifted.join(', ')} ${verb} a --color-text apart from the master, so ${drifted.length === 1 ? 'its' : 'their'} bands keep the master colors`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
154
284
|
} catch (error) {
|
|
155
285
|
return refused(
|
|
156
286
|
'export-failed',
|
|
@@ -164,12 +294,32 @@ export async function exportHtmlDeck(
|
|
|
164
294
|
if (!(written instanceof Uint8Array)) {
|
|
165
295
|
return refused('export-failed', 'pptxgenjs returned no buffer')
|
|
166
296
|
}
|
|
167
|
-
|
|
297
|
+
// A deck needing no edit keeps the bytes pptxgenjs wrote.
|
|
298
|
+
let packaged: Uint8Array = written
|
|
299
|
+
const refusedMotion: MotionNotice[] = []
|
|
300
|
+
const refusedFonts: FontNotice[] = []
|
|
301
|
+
if (patches.length > 0 || motions.length > 0 || deck.fonts.length > 0) {
|
|
302
|
+
const zip = await JSZip.loadAsync(written)
|
|
303
|
+
await applyPatches(zip, patches)
|
|
304
|
+
refusedMotion.push(...(await writeMotion(zip, motions)))
|
|
305
|
+
const faces = deck.fonts.map((font) => ({
|
|
306
|
+
family: font.family,
|
|
307
|
+
weight: font.weight,
|
|
308
|
+
style: font.style,
|
|
309
|
+
path: font.source,
|
|
310
|
+
bytes: readFileSync(font.path),
|
|
311
|
+
}))
|
|
312
|
+
refusedFonts.push(...(await embedFonts(zip, faces)))
|
|
313
|
+
packaged = await zip.generateAsync({
|
|
314
|
+
type: 'nodebuffer',
|
|
315
|
+
compression: 'DEFLATE',
|
|
316
|
+
})
|
|
317
|
+
}
|
|
168
318
|
|
|
169
319
|
mkdirSync(outDir, { recursive: true })
|
|
170
320
|
const fileName = `${basename(sourceDir)}.pptx`
|
|
171
321
|
const pptxPath = join(outDir, fileName)
|
|
172
|
-
writeFileSync(pptxPath,
|
|
322
|
+
writeFileSync(pptxPath, packaged)
|
|
173
323
|
|
|
174
324
|
let mirrorPath: string | undefined
|
|
175
325
|
if (options.mirror) {
|
|
@@ -185,9 +335,115 @@ export async function exportHtmlDeck(
|
|
|
185
335
|
slideCount: files.length,
|
|
186
336
|
fallbacks,
|
|
187
337
|
refusedLinks,
|
|
338
|
+
refusedCharts,
|
|
339
|
+
refusedMotion,
|
|
340
|
+
refusedFonts,
|
|
341
|
+
notices,
|
|
188
342
|
}
|
|
189
343
|
}
|
|
190
344
|
|
|
345
|
+
/**
|
|
346
|
+
* A band stays on the master unless the deck has it off anyway, so a slide
|
|
347
|
+
* hiding only the header keeps the master's footer and numbers.
|
|
348
|
+
*/
|
|
349
|
+
function masterBands(deck: DeckConfig, meta: SlideMeta): MasterBands {
|
|
350
|
+
return {
|
|
351
|
+
header: deck.header.show && meta.header.kind === 'master',
|
|
352
|
+
footer: deck.footer.show && meta.footer.kind === 'master',
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
type Override = Extract<BandOverride, { readonly kind: 'override' }>
|
|
357
|
+
|
|
358
|
+
/** The slide's slots over the deck's, so overriding the center keeps the title. */
|
|
359
|
+
function mergeBand(override: Override, base: DeckConfig['header']) {
|
|
360
|
+
return {
|
|
361
|
+
left: override.left ?? base.left,
|
|
362
|
+
center: override.center ?? base.center,
|
|
363
|
+
right: override.right ?? base.right,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function drawOverrides(
|
|
368
|
+
slide: PptxGenJS.Slide,
|
|
369
|
+
deck: DeckConfig,
|
|
370
|
+
theme: DeckTheme,
|
|
371
|
+
meta: SlideMeta,
|
|
372
|
+
): void {
|
|
373
|
+
if (meta.header.kind === 'override') {
|
|
374
|
+
const band = mergeBand(meta.header, deck.header)
|
|
375
|
+
const hasMark = deck.mark !== undefined
|
|
376
|
+
for (const text of bandTexts(band, 'header', theme, { hasMark })) {
|
|
377
|
+
slide.addText(text.text, text.options)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (meta.footer.kind === 'override') {
|
|
381
|
+
const band = mergeBand(meta.footer, deck.footer)
|
|
382
|
+
for (const text of bandTexts(band, 'footer', theme)) {
|
|
383
|
+
slide.addText(text.text, text.options)
|
|
384
|
+
}
|
|
385
|
+
if (deck.slideNumbers && band.right === undefined) {
|
|
386
|
+
slide.slideNumber = slideNumberProps(theme)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A slide's `data-section` opens a section the slides after it stay in.
|
|
393
|
+
* PowerPoint wants every slide in a section once any exists, so slides ahead of
|
|
394
|
+
* the first declared one open a section named after the deck.
|
|
395
|
+
*
|
|
396
|
+
* pptxgenjs files a slide under the first section carrying its title, so a
|
|
397
|
+
* title the deck returns to takes a numbered suffix rather than sending the
|
|
398
|
+
* slide back into the earlier section, which PowerPoint reads as a broken list.
|
|
399
|
+
*/
|
|
400
|
+
export class Sections {
|
|
401
|
+
/** The title as declared, and as written once a repeat is numbered. */
|
|
402
|
+
private declared: string | undefined
|
|
403
|
+
private current: string | undefined
|
|
404
|
+
private readonly opened = new Map<string, number>()
|
|
405
|
+
|
|
406
|
+
constructor(
|
|
407
|
+
private readonly deckTitle: string,
|
|
408
|
+
private readonly isUsed: boolean,
|
|
409
|
+
) {}
|
|
410
|
+
|
|
411
|
+
titleFor(meta: SlideMeta, pptx: PptxGenJS): string | undefined {
|
|
412
|
+
if (!this.isUsed) return undefined
|
|
413
|
+
const isContinuing =
|
|
414
|
+
meta.section === undefined || meta.section === this.declared
|
|
415
|
+
if (isContinuing && this.current !== undefined) return this.current
|
|
416
|
+
const title = meta.section ?? this.deckTitle
|
|
417
|
+
const count = (this.opened.get(title) ?? 0) + 1
|
|
418
|
+
this.opened.set(title, count)
|
|
419
|
+
this.declared = title
|
|
420
|
+
this.current = count === 1 ? title : `${title} (${count})`
|
|
421
|
+
pptx.addSection({ title: this.current })
|
|
422
|
+
return this.current
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Whether any slide declares a section, read from the source ahead of layout,
|
|
428
|
+
* since the first slide is placed before the last is laid out.
|
|
429
|
+
*/
|
|
430
|
+
function declaresSections(files: readonly string[]): boolean {
|
|
431
|
+
return files.some((file) =>
|
|
432
|
+
/<body\b[^>]*\sdata-section\s*=/i.test(readFileSync(file, 'utf8')),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The mark's intrinsic size as Chromium decodes it, square where unknown. */
|
|
437
|
+
async function measure(page: Page, path: string): Promise<MarkSize> {
|
|
438
|
+
const size = await page.evaluate(async (src) => {
|
|
439
|
+
const image = new Image()
|
|
440
|
+
image.src = src
|
|
441
|
+
await image.decode().catch(() => undefined)
|
|
442
|
+
return { width: image.naturalWidth, height: image.naturalHeight }
|
|
443
|
+
}, pathToFileURL(path).href)
|
|
444
|
+
return size.width > 0 && size.height > 0 ? size : { width: 1, height: 1 }
|
|
445
|
+
}
|
|
446
|
+
|
|
191
447
|
/**
|
|
192
448
|
* The tokens go in ahead of the slide's own styles, so a slide reads
|
|
193
449
|
* `var(--color-*)` from the project and can still override any of it.
|
|
@@ -197,6 +453,15 @@ async function layOut(
|
|
|
197
453
|
file: string,
|
|
198
454
|
tokens: string,
|
|
199
455
|
): Promise<WalkedSlide> {
|
|
456
|
+
await prepare(page, file, tokens)
|
|
457
|
+
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function prepare(
|
|
461
|
+
page: Page,
|
|
462
|
+
file: string,
|
|
463
|
+
tokens: string,
|
|
464
|
+
): Promise<void> {
|
|
200
465
|
await page.goto(pathToFileURL(file).href, { waitUntil: 'load' })
|
|
201
466
|
if (tokens) {
|
|
202
467
|
await page.evaluate((css) => {
|
|
@@ -206,7 +471,6 @@ async function layOut(
|
|
|
206
471
|
}, tokens)
|
|
207
472
|
}
|
|
208
473
|
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
209
|
-
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
210
474
|
}
|
|
211
475
|
|
|
212
476
|
async function screenshot(page: Page, id: number): Promise<Buffer> {
|
|
@@ -227,10 +491,11 @@ const base64 = (bytes: Buffer | string): string =>
|
|
|
227
491
|
async function draw(
|
|
228
492
|
page: Page,
|
|
229
493
|
slide: PptxGenJS.Slide,
|
|
230
|
-
|
|
494
|
+
slideNumber: number,
|
|
231
495
|
op: DrawOp,
|
|
232
496
|
patches: Patch[],
|
|
233
497
|
): Promise<void> {
|
|
498
|
+
const target = { slide: slideNumber, name: op.options.objectName ?? '' }
|
|
234
499
|
if (op.kind === 'shape') {
|
|
235
500
|
slide.addShape(op.shape, op.options)
|
|
236
501
|
} else if (op.kind === 'text') {
|
|
@@ -238,18 +503,13 @@ async function draw(
|
|
|
238
503
|
} else if (op.kind === 'table') {
|
|
239
504
|
slide.addTable(op.rows, op.options)
|
|
240
505
|
} else if (op.kind === 'image') {
|
|
241
|
-
slide.addImage({
|
|
242
|
-
...op.options,
|
|
243
|
-
...imageSource(op.src),
|
|
244
|
-
objectName: target.name,
|
|
245
|
-
})
|
|
506
|
+
slide.addImage({ ...op.options, ...imageSource(op.src) })
|
|
246
507
|
if (op.radius > 0) patches.push({ ...target, radius: op.radius })
|
|
247
508
|
if (op.frame) slide.addShape(op.radius > 0 ? 'roundRect' : 'rect', op.frame)
|
|
248
509
|
} else if (op.kind === 'svg') {
|
|
249
510
|
slide.addImage({
|
|
250
511
|
...op.options,
|
|
251
512
|
data: `image/svg+xml;base64,${base64(op.markup)}`,
|
|
252
|
-
objectName: target.name,
|
|
253
513
|
})
|
|
254
514
|
patches.push({ ...target, png: await screenshot(page, op.id) })
|
|
255
515
|
} else {
|
|
@@ -262,14 +522,13 @@ async function draw(
|
|
|
262
522
|
* Two things pptxgenjs cannot write, fixed in the package it wrote. An SVG's
|
|
263
523
|
* PNG fallback comes out as the SVG's own bytes under a `.png` name in Node, so
|
|
264
524
|
* 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 `
|
|
266
|
-
* it, which is the only handle the written XML keeps.
|
|
525
|
+
* corner radius. Each patched picture is found by the object name `planSlide`
|
|
526
|
+
* gave it, which is the only handle the written XML keeps.
|
|
267
527
|
*/
|
|
268
528
|
async function applyPatches(
|
|
269
|
-
|
|
529
|
+
zip: JSZip,
|
|
270
530
|
patches: readonly Patch[],
|
|
271
|
-
): Promise<
|
|
272
|
-
const zip = await JSZip.loadAsync(deck)
|
|
531
|
+
): Promise<void> {
|
|
273
532
|
const bySlide = Map.groupBy(patches, (patch) => patch.slide)
|
|
274
533
|
for (const [slideNumber, slidePatches] of bySlide) {
|
|
275
534
|
const slidePath = `ppt/slides/slide${slideNumber}.xml`
|
|
@@ -294,7 +553,6 @@ async function applyPatches(
|
|
|
294
553
|
}
|
|
295
554
|
zip.file(slidePath, xml)
|
|
296
555
|
}
|
|
297
|
-
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
|
|
298
556
|
}
|
|
299
557
|
|
|
300
558
|
function pictureXml(xml: string, name: string): string | undefined {
|
|
@@ -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
|
+
}
|