@erclx/canon 5.20.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 (38) 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/docs-sync/REQUIREMENT.md +1 -1
  4. package/claude/skills/document-health/REQUIREMENT.md +1 -1
  5. package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
  6. package/claude/skills/draft-ready/SKILL.md +6 -4
  7. package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
  8. package/claude/skills/markdown-propose/SKILL.md +1 -1
  9. package/docs/agents/commands.md +1 -0
  10. package/docs/agents/index.md +1 -0
  11. package/docs/agents/markdown-audit.md +1 -3
  12. package/docs/agents/slides.md +120 -0
  13. package/docs/agents/tasks-validate.md +1 -1
  14. package/docs/agents/tasks.md +1 -1
  15. package/docs/target/migrations.md +1 -1
  16. package/docs/workflow/ai-workflow.md +1 -1
  17. package/docs/workflow/skill-map.md +11 -13
  18. package/governance/rules/lib/330-testing-py.md +1 -1
  19. package/governance/rules/lib/336-testing-php.md +1 -1
  20. package/package.json +1 -1
  21. package/src/claude/cases/workflow.ts +4 -20
  22. package/src/commands/slides.ts +13 -3
  23. package/src/commands/tasks.ts +46 -2
  24. package/src/migrate/skill-names.ts +0 -7
  25. package/src/slides/convert/chart.ts +165 -0
  26. package/src/slides/convert/deck.ts +164 -0
  27. package/src/slides/convert/export.ts +217 -7
  28. package/src/slides/convert/master.ts +195 -0
  29. package/src/slides/convert/shapes.ts +1 -1
  30. package/src/slides/convert/walk.ts +187 -2
  31. package/src/tasks/record.ts +11 -1
  32. package/src/tasks/validate.ts +14 -0
  33. package/standards/tasks.md +1 -1
  34. package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
  35. package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
  36. package/claude/skills/docs-fold/SKILL.md +0 -9
  37. package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
  38. package/claude/skills/standards-audit/SKILL.md +0 -99
@@ -0,0 +1,165 @@
1
+ import type PptxGenJS from 'pptxgenjs'
2
+ import { position, type Rect } from '@/slides/convert/shapes'
3
+
4
+ /**
5
+ * A `<figure data-chart="...">` holding a `<table>` lands as a native chart.
6
+ * The table stays the page's accessible source and is not also drawn. Its
7
+ * header row names the series and its first column names the categories.
8
+ */
9
+
10
+ export const CHART_TYPES = ['bar', 'line', 'area', 'pie', 'doughnut'] as const
11
+ export type ChartType = (typeof CHART_TYPES)[number]
12
+
13
+ export interface ChartRecord {
14
+ readonly selector: string
15
+ readonly box: Rect
16
+ /** The raw `data-chart` value, checked against `CHART_TYPES` here. */
17
+ readonly type: string
18
+ readonly hasLabels: boolean
19
+ /** The figure's `<figcaption>`, when it has one. */
20
+ readonly title?: string
21
+ readonly rows: readonly (readonly string[])[]
22
+ }
23
+
24
+ export interface ChartTheme {
25
+ /** Series colors in order, repeated past the last. */
26
+ readonly colors: readonly string[]
27
+ readonly ink: string
28
+ readonly muted: string
29
+ readonly face: string
30
+ }
31
+
32
+ export interface ChartSeries {
33
+ readonly name: string
34
+ readonly labels: readonly string[]
35
+ /** `null` is an empty cell, which the chart draws as a gap. */
36
+ readonly values: readonly (number | null)[]
37
+ }
38
+
39
+ export type ChartPlan =
40
+ | {
41
+ readonly status: 'chart'
42
+ readonly type: ChartType
43
+ readonly data: readonly ChartSeries[]
44
+ readonly options: PptxGenJS.IChartOpts
45
+ }
46
+ | {
47
+ readonly status: 'refused'
48
+ readonly selector: string
49
+ readonly message: string
50
+ }
51
+
52
+ export interface ColorRole {
53
+ readonly name: string
54
+ readonly hex: string
55
+ }
56
+
57
+ /** The grounds a series would vanish into. */
58
+ const GROUND_ROLES = new Set(['--color-background', '--color-surface'])
59
+
60
+ const NUMBER = /^-?(\d+\.?\d*|\.\d+)$/
61
+
62
+ /**
63
+ * A comma counts as a thousands separator only where it groups in threes, so a
64
+ * comma-decimal `3,5` refuses rather than reading as 35.
65
+ */
66
+ const GROUPED = /^-?\d{1,3}(,\d{3})+(\.\d+)?$/
67
+
68
+ const isChartType = (value: string): value is ChartType =>
69
+ (CHART_TYPES as readonly string[]).includes(value)
70
+
71
+ /**
72
+ * The accent, then every other declared color role in token order except the
73
+ * grounds, each color once. No project declares a chart palette, so the
74
+ * project's own roles stand in for one.
75
+ */
76
+ export function seriesColors(
77
+ accent: string,
78
+ roles: readonly ColorRole[],
79
+ ): string[] {
80
+ const colors = [accent]
81
+ for (const role of roles) {
82
+ if (GROUND_ROLES.has(role.name) || colors.includes(role.hex)) continue
83
+ colors.push(role.hex)
84
+ }
85
+ return colors
86
+ }
87
+
88
+ function cycle(colors: readonly string[], count: number): string[] {
89
+ return Array.from(
90
+ { length: count },
91
+ (_, index) => colors[index % colors.length] ?? '',
92
+ )
93
+ }
94
+
95
+ export function planChart(record: ChartRecord, theme: ChartTheme): ChartPlan {
96
+ const refuse = (message: string): ChartPlan => ({
97
+ status: 'refused',
98
+ selector: record.selector,
99
+ message,
100
+ })
101
+ if (!isChartType(record.type)) {
102
+ return refuse(
103
+ `data-chart "${record.type}" is not one of ${CHART_TYPES.join(', ')}`,
104
+ )
105
+ }
106
+ const [header = [], ...body] = record.rows
107
+ if (header.length < 2 || body.length === 0) {
108
+ return refuse(
109
+ 'the table needs a header row and at least one data row of two cells',
110
+ )
111
+ }
112
+
113
+ const labels = body.map((row) => row[0] ?? '')
114
+ const data: ChartSeries[] = []
115
+ for (let column = 1; column < header.length; column += 1) {
116
+ const values: (number | null)[] = []
117
+ for (const [index, row] of body.entries()) {
118
+ const text = (row[column] ?? '').trim()
119
+ if (text === '') {
120
+ values.push(null)
121
+ continue
122
+ }
123
+ const bare = GROUPED.test(text) ? text.replace(/,/g, '') : text
124
+ if (!NUMBER.test(bare)) {
125
+ return refuse(
126
+ `cell "${text}" at row ${index + 2}, column ${column + 1} is not a number`,
127
+ )
128
+ }
129
+ values.push(Number(bare))
130
+ }
131
+ data.push({ name: header[column] ?? '', labels, values })
132
+ }
133
+
134
+ const isRound = record.type === 'pie' || record.type === 'doughnut'
135
+ return {
136
+ status: 'chart',
137
+ type: record.type,
138
+ data,
139
+ options: {
140
+ ...position(record.box),
141
+ chartColors: cycle(theme.colors, isRound ? labels.length : data.length),
142
+ displayBlanksAs: 'gap',
143
+ showValue: record.hasLabels,
144
+ dataLabelColor: theme.ink,
145
+ dataLabelFontFace: theme.face,
146
+ showLegend: isRound || data.length > 1,
147
+ legendPos: 'b',
148
+ legendColor: theme.ink,
149
+ legendFontFace: theme.face,
150
+ catAxisLabelColor: theme.muted,
151
+ catAxisLabelFontFace: theme.face,
152
+ valAxisLabelColor: theme.muted,
153
+ valAxisLabelFontFace: theme.face,
154
+ ...(record.title
155
+ ? {
156
+ showTitle: true,
157
+ title: record.title,
158
+ titleColor: theme.ink,
159
+ titleFontFace: theme.face,
160
+ altText: record.title,
161
+ }
162
+ : {}),
163
+ },
164
+ }
165
+ }
@@ -0,0 +1,164 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ /**
5
+ * What belongs to the deck rather than to one slide, read from `deck.json`
6
+ * beside the slides. A slide overrides a band through attributes on its own
7
+ * `<body>`, which `walk.ts` reads.
8
+ */
9
+
10
+ export const DECK_FILE = 'deck.json'
11
+
12
+ export const BAND_SLOTS = ['left', 'center', 'right'] as const
13
+ export type BandSlot = (typeof BAND_SLOTS)[number]
14
+
15
+ export interface Band {
16
+ readonly show: boolean
17
+ readonly left?: string
18
+ readonly center?: string
19
+ readonly right?: string
20
+ }
21
+
22
+ export interface DeckConfig {
23
+ readonly title: string
24
+ readonly header: Band
25
+ readonly footer: Band
26
+ /** Drawn at the right of the footer, so they show only where it does. */
27
+ readonly slideNumbers: boolean
28
+ /** Absolute, resolved against the deck folder. */
29
+ readonly mark?: string
30
+ }
31
+
32
+ export type DeckRead =
33
+ | { readonly status: 'read'; readonly deck: DeckConfig }
34
+ | {
35
+ readonly status: 'refused'
36
+ readonly field: string
37
+ readonly message: string
38
+ }
39
+
40
+ const DECK_FIELDS = new Set([
41
+ 'title',
42
+ 'header',
43
+ 'footer',
44
+ 'slideNumbers',
45
+ 'mark',
46
+ ])
47
+ const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
48
+
49
+ class FieldError extends Error {
50
+ constructor(
51
+ readonly field: string,
52
+ message: string,
53
+ ) {
54
+ super(message)
55
+ }
56
+ }
57
+
58
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
59
+ typeof value === 'object' && value !== null && !Array.isArray(value)
60
+
61
+ function rejectUnknown(
62
+ record: Record<string, unknown>,
63
+ known: ReadonlySet<string>,
64
+ prefix: string,
65
+ ): void {
66
+ const unknown = Object.keys(record).find((key) => !known.has(key))
67
+ if (unknown !== undefined) {
68
+ throw new FieldError(
69
+ `${prefix}${unknown}`,
70
+ `unknown field ${prefix}${unknown}`,
71
+ )
72
+ }
73
+ }
74
+
75
+ function stringField(value: unknown, field: string): string | undefined {
76
+ if (value === undefined) return undefined
77
+ if (typeof value !== 'string') {
78
+ throw new FieldError(field, `${field} must be a string`)
79
+ }
80
+ return value
81
+ }
82
+
83
+ function booleanField(value: unknown, field: string): boolean | undefined {
84
+ if (value === undefined) return undefined
85
+ if (typeof value !== 'boolean') {
86
+ throw new FieldError(field, `${field} must be true or false`)
87
+ }
88
+ return value
89
+ }
90
+
91
+ /**
92
+ * A declared band merges over its default, so `{ "center": "x" }` keeps the
93
+ * footer's title on the left. Declaring any text turns a band on unless it
94
+ * also says `show: false`.
95
+ */
96
+ function band(value: unknown, field: string, fallback: Band): Band {
97
+ if (value === undefined) return fallback
98
+ if (!isRecord(value)) {
99
+ throw new FieldError(field, `${field} must be an object`)
100
+ }
101
+ rejectUnknown(value, BAND_FIELDS, `${field}.`)
102
+ const slots: { -readonly [slot in BandSlot]?: string } = {}
103
+ for (const slot of BAND_SLOTS) {
104
+ const text = stringField(value[slot], `${field}.${slot}`)
105
+ if (text !== undefined) slots[slot] = text
106
+ }
107
+ const show =
108
+ booleanField(value.show, `${field}.show`) ??
109
+ (Object.keys(slots).length > 0 || fallback.show)
110
+ return { ...fallback, ...slots, show }
111
+ }
112
+
113
+ function parse(raw: unknown, folder: string): DeckConfig {
114
+ if (!isRecord(raw)) {
115
+ throw new FieldError(DECK_FILE, `${DECK_FILE} must hold an object`)
116
+ }
117
+ rejectUnknown(raw, DECK_FIELDS, '')
118
+ const title = stringField(raw.title, 'title') ?? basename(folder)
119
+ const header = band(raw.header, 'header', { show: false })
120
+ const footer = band(raw.footer, 'footer', { show: true, left: title })
121
+ const slideNumbers = booleanField(raw.slideNumbers, 'slideNumbers') ?? true
122
+ if (slideNumbers && footer.right !== undefined) {
123
+ throw new FieldError(
124
+ 'footer.right',
125
+ 'footer.right is where slide numbers sit. Set slideNumbers to false to use it',
126
+ )
127
+ }
128
+ const markPath = stringField(raw.mark, 'mark')
129
+ let mark: string | undefined
130
+ if (markPath !== undefined) {
131
+ mark = join(folder, markPath)
132
+ if (!existsSync(mark) || !statSync(mark).isFile()) {
133
+ throw new FieldError('mark', `mark ${mark} does not exist`)
134
+ }
135
+ }
136
+ return { title, header, footer, slideNumbers, mark }
137
+ }
138
+
139
+ export function readDeck(folder: string): DeckRead {
140
+ const path = join(folder, DECK_FILE)
141
+ let raw: unknown = {}
142
+ if (existsSync(path)) {
143
+ try {
144
+ raw = JSON.parse(readFileSync(path, 'utf8'))
145
+ } catch (error) {
146
+ const reason = error instanceof Error ? error.message : String(error)
147
+ return {
148
+ status: 'refused',
149
+ field: DECK_FILE,
150
+ message: `${path}: not valid JSON (${reason})`,
151
+ }
152
+ }
153
+ }
154
+ try {
155
+ return { status: 'read', deck: parse(raw, folder) }
156
+ } catch (error) {
157
+ if (!(error instanceof FieldError)) throw error
158
+ return {
159
+ status: 'refused',
160
+ field: error.field,
161
+ message: `${path}: ${error.message}`,
162
+ }
163
+ }
164
+ }
@@ -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> {