@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.
Files changed (98) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/SKILL.md +1 -1
  3. package/claude/skills/auto-ship/references/draft-mark.md +8 -6
  4. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  5. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  6. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  7. package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
  8. package/claude/skills/draft-doc/SKILL.md +77 -0
  9. package/claude/skills/draft-doc/references/context.md +31 -0
  10. package/claude/skills/draft-doc/references/docs.md +33 -0
  11. package/claude/skills/draft-doc/references/readme.md +58 -0
  12. package/claude/skills/draft-doc/references/wiki.md +53 -0
  13. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  14. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  15. package/claude/skills/draft-figure/SKILL.md +1 -1
  16. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  17. package/claude/skills/draft-ready/SKILL.md +6 -4
  18. package/claude/skills/git-pr/SKILL.md +5 -46
  19. package/claude/skills/git-pr/references/final-command.md +75 -0
  20. package/claude/skills/git-pr/references/labels.md +2 -2
  21. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  22. package/claude/skills/markdown-propose/SKILL.md +1 -1
  23. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  24. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  25. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  26. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  27. package/claude/skills/search-craft/SKILL.md +1 -1
  28. package/docs/agents/commands.md +1 -0
  29. package/docs/agents/drift-surfaces.md +20 -9
  30. package/docs/agents/index.md +2 -1
  31. package/docs/agents/install-and-sync.md +3 -3
  32. package/docs/agents/markdown-audit.md +1 -3
  33. package/docs/agents/migrate.md +1 -1
  34. package/docs/agents/rule-citations.md +1 -1
  35. package/docs/agents/slides.md +164 -0
  36. package/docs/agents/tasks-validate.md +1 -1
  37. package/docs/agents/tasks.md +2 -2
  38. package/docs/target/migrations.md +4 -2
  39. package/docs/target/sync.md +1 -1
  40. package/docs/workflow/ai-workflow.md +1 -1
  41. package/docs/workflow/skill-map.md +25 -31
  42. package/governance/rules/canon/604-scratch.md +0 -1
  43. package/governance/rules/claude/575-hooks.md +1 -2
  44. package/governance/rules/claude/576-settings.md +1 -2
  45. package/governance/rules/lib/330-testing-py.md +1 -1
  46. package/governance/rules/lib/336-testing-php.md +1 -1
  47. package/governance/rules/standards/architecture.md +0 -1
  48. package/governance/rules/standards/context.md +0 -1
  49. package/governance/rules/standards/design.md +0 -1
  50. package/governance/rules/standards/diagrams.md +0 -3
  51. package/governance/rules/standards/requirements.md +0 -1
  52. package/governance/rules/standards/wireframes.md +0 -1
  53. package/package.json +1 -1
  54. package/scripts/manage-sandbox.sh +7 -8
  55. package/src/claude/cases/authoring.ts +5 -5
  56. package/src/claude/cases/workflow.ts +4 -20
  57. package/src/commands/slides.ts +23 -3
  58. package/src/commands/sync.ts +10 -0
  59. package/src/commands/tasks.ts +46 -2
  60. package/src/gov/citations.ts +1 -2
  61. package/src/migrate/skill-names.ts +12 -7
  62. package/src/slides/convert/chart.ts +165 -0
  63. package/src/slides/convert/deck.ts +244 -0
  64. package/src/slides/convert/export.ts +281 -23
  65. package/src/slides/convert/master.ts +195 -0
  66. package/src/slides/convert/shapes.ts +44 -4
  67. package/src/slides/convert/walk.ts +229 -2
  68. package/src/slides/package/fonts.ts +297 -0
  69. package/src/slides/package/motion.ts +316 -0
  70. package/src/slides/package/test-face.ts +113 -0
  71. package/src/sync/check.ts +9 -0
  72. package/src/sync/layout.ts +27 -1
  73. package/src/tasks/record.ts +11 -1
  74. package/src/tasks/validate.ts +14 -0
  75. package/standards/architecture.md +0 -1
  76. package/standards/context.md +0 -1
  77. package/standards/design.md +0 -1
  78. package/standards/diagrams.md +0 -3
  79. package/standards/requirements.md +0 -1
  80. package/standards/rule.md +1 -0
  81. package/standards/tasks.md +1 -1
  82. package/standards/wireframes.md +0 -1
  83. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  84. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
  85. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  86. package/claude/skills/docs-fold/SKILL.md +0 -9
  87. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  88. package/claude/skills/draft-context/SKILL.md +0 -61
  89. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  90. package/claude/skills/draft-docs/SKILL.md +0 -59
  91. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  92. package/claude/skills/draft-readme/SKILL.md +0 -73
  93. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  94. package/claude/skills/draft-wiki/SKILL.md +0 -83
  95. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  96. package/claude/skills/draft-wireframes/SKILL.md +0 -69
  97. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  98. package/claude/skills/standards-audit/SKILL.md +0 -99
@@ -1,4 +1,10 @@
1
- import { copyFileSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'
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 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)
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
- const deck = await applyPatches(written, patches)
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, deck)
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
- target: { readonly slide: number; readonly name: string },
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 `draw` gave
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
- deck: Uint8Array,
529
+ zip: JSZip,
270
530
  patches: readonly Patch[],
271
- ): Promise<Buffer> {
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
+ }