@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.
Files changed (135) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/auto-ship/references/verb-absent.md +1 -1
  3. package/claude/skills/create-skill/SKILL.md +1 -0
  4. package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
  5. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  6. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  7. package/claude/skills/draft-ready/SKILL.md +6 -4
  8. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  9. package/claude/skills/markdown-propose/SKILL.md +1 -1
  10. package/claude/skills/plan-feature/SKILL.md +1 -1
  11. package/claude/skills/plan-groundwork/SKILL.md +1 -1
  12. package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
  13. package/claude/skills/record-screencast/SKILL.md +2 -2
  14. package/claude/skills/role-worker/REQUIREMENT.md +3 -1
  15. package/claude/skills/role-worker/SKILL.md +5 -3
  16. package/claude/skills/session-resume/SKILL.md +1 -1
  17. package/claude/skills/task-board/REQUIREMENT.md +1 -0
  18. package/claude/skills/task-board/SKILL.md +6 -0
  19. package/claude/skills/task-board/references/decline.md +2 -0
  20. package/claude/skills/video-craft/REQUIREMENT.md +48 -0
  21. package/claude/skills/video-craft/SKILL.md +80 -0
  22. package/claude/skills/video-craft/references/adopted.md +66 -0
  23. package/docs/agents/canvas.md +4 -0
  24. package/docs/agents/commands.md +1 -0
  25. package/docs/agents/demo.md +1 -1
  26. package/docs/agents/index.md +1 -0
  27. package/docs/agents/install-and-sync.md +17 -3
  28. package/docs/agents/markdown-audit.md +1 -3
  29. package/docs/agents/rule-citations.md +4 -4
  30. package/docs/agents/sandbox.md +8 -7
  31. package/docs/agents/slides.md +120 -0
  32. package/docs/agents/tasks-validate.md +1 -1
  33. package/docs/agents/tasks.md +1 -1
  34. package/docs/target/migrations.md +1 -1
  35. package/docs/workflow/ai-workflow.md +1 -1
  36. package/docs/workflow/operating-model.md +9 -9
  37. package/docs/workflow/parallel-features.md +1 -1
  38. package/docs/workflow/skill-map.md +12 -13
  39. package/governance/renames.toml +24 -0
  40. package/governance/rules/lib/330-testing-py.md +1 -1
  41. package/governance/rules/lib/336-testing-php.md +1 -1
  42. package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
  43. package/governance/rules/standards/context.md +15 -0
  44. package/governance/rules/standards/design.md +13 -0
  45. package/governance/rules/standards/diagrams.md +17 -0
  46. package/governance/rules/standards/groundwork.md +13 -0
  47. package/governance/rules/standards/intake.md +12 -0
  48. package/governance/rules/standards/markdown.md +15 -0
  49. package/governance/rules/standards/memory.md +12 -0
  50. package/governance/rules/standards/mermaid.md +15 -0
  51. package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
  52. package/governance/rules/standards/readme.md +12 -0
  53. package/governance/rules/standards/ready.md +13 -0
  54. package/governance/rules/standards/requirements.md +13 -0
  55. package/governance/rules/standards/rule.md +13 -0
  56. package/governance/rules/standards/session.md +13 -0
  57. package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
  58. package/governance/rules/standards/standard.md +13 -0
  59. package/governance/rules/standards/tasks.md +15 -0
  60. package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
  61. package/governance/rules/standards/wiki.md +13 -0
  62. package/governance/rules/standards/wireframes.md +14 -0
  63. package/governance/rules/writing/800-prose.md +1 -1
  64. package/governance/stacks/base.toml +4 -2
  65. package/package.json +1 -1
  66. package/src/canvas/capture.ts +228 -15
  67. package/src/claude/cases/misc.ts +5 -0
  68. package/src/claude/cases/workflow.ts +4 -20
  69. package/src/claude/routing.ts +1 -1
  70. package/src/commands/canvas.ts +76 -2
  71. package/src/commands/gov.ts +19 -4
  72. package/src/commands/sandbox.ts +7 -0
  73. package/src/commands/slides.ts +13 -3
  74. package/src/commands/tasks.ts +46 -2
  75. package/src/gate/stages.ts +7 -1
  76. package/src/gov/citations.ts +1 -1
  77. package/src/gov/standard-rules.ts +179 -0
  78. package/src/migrate/skill-names.ts +0 -7
  79. package/src/sandbox/equivalence.ts +43 -14
  80. package/src/sandbox/stub-remote.ts +140 -0
  81. package/src/slides/convert/chart.ts +165 -0
  82. package/src/slides/convert/deck.ts +164 -0
  83. package/src/slides/convert/export.ts +217 -7
  84. package/src/slides/convert/master.ts +195 -0
  85. package/src/slides/convert/shapes.ts +1 -1
  86. package/src/slides/convert/walk.ts +187 -2
  87. package/src/tasks/record.ts +11 -1
  88. package/src/tasks/validate.ts +14 -0
  89. package/standards/architecture.md +5 -0
  90. package/standards/context.md +6 -0
  91. package/standards/design.md +3 -0
  92. package/standards/diagrams.md +8 -0
  93. package/standards/groundwork-spikes.md +66 -0
  94. package/standards/groundwork.md +5 -25
  95. package/standards/index.md +6 -2
  96. package/standards/intake.md +2 -0
  97. package/standards/markdown.md +6 -0
  98. package/standards/memory.md +2 -0
  99. package/standards/mermaid.md +6 -0
  100. package/standards/plan-lifecycle.md +36 -0
  101. package/standards/plan.md +13 -11
  102. package/standards/readme.md +2 -0
  103. package/standards/ready.md +4 -0
  104. package/standards/requirements.md +3 -0
  105. package/standards/rule.md +6 -1
  106. package/standards/session.md +4 -0
  107. package/standards/skill-paths.md +50 -0
  108. package/standards/skill.md +15 -23
  109. package/standards/standard.md +7 -1
  110. package/standards/tasks-decline.md +38 -0
  111. package/standards/tasks.md +9 -14
  112. package/standards/teach.md +5 -0
  113. package/standards/wiki.md +4 -0
  114. package/standards/wireframes.md +5 -0
  115. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  116. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  117. package/claude/skills/docs-fold/SKILL.md +0 -9
  118. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  119. package/claude/skills/standards-audit/SKILL.md +0 -99
  120. package/governance/rules/canon/610-context.md +0 -17
  121. package/governance/rules/canon/620-wireframes.md +0 -13
  122. package/governance/rules/canon/630-requirements.md +0 -12
  123. package/governance/rules/canon/650-design.md +0 -12
  124. package/governance/rules/canon/655-tasks.md +0 -13
  125. package/governance/rules/canon/656-groundwork.md +0 -11
  126. package/governance/rules/canon/657-intake.md +0 -11
  127. package/governance/rules/canon/659-memory.md +0 -11
  128. package/governance/rules/canon/660-diagrams.md +0 -19
  129. package/governance/rules/canon/662-session.md +0 -15
  130. package/governance/rules/canon/663-ready.md +0 -15
  131. package/governance/rules/claude/590-rule-authoring.md +0 -12
  132. package/governance/rules/writing/801-markdown.md +0 -14
  133. package/governance/rules/writing/802-mermaid.md +0 -14
  134. package/governance/rules/writing/880-readme.md +0 -11
  135. package/governance/rules/writing/891-standard-authoring.md +0 -12
@@ -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,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 slide = pptx.addSlide()
148
- slide.background = { color: walked.background.hex }
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 deck = await applyPatches(written, patches)
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, deck)
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),