@erclx/canon 5.21.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 (74) 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/draft-doc/REQUIREMENT.md +45 -0
  5. package/claude/skills/draft-doc/SKILL.md +77 -0
  6. package/claude/skills/draft-doc/references/context.md +31 -0
  7. package/claude/skills/draft-doc/references/docs.md +33 -0
  8. package/claude/skills/draft-doc/references/readme.md +58 -0
  9. package/claude/skills/draft-doc/references/wiki.md +53 -0
  10. package/claude/skills/draft-doc/references/wireframes.md +48 -0
  11. package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
  12. package/claude/skills/draft-figure/SKILL.md +1 -1
  13. package/claude/skills/git-pr/SKILL.md +5 -46
  14. package/claude/skills/git-pr/references/final-command.md +75 -0
  15. package/claude/skills/git-pr/references/labels.md +2 -2
  16. package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
  17. package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
  18. package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
  19. package/claude/skills/search-craft/REQUIREMENT.md +4 -4
  20. package/claude/skills/search-craft/SKILL.md +1 -1
  21. package/docs/agents/commands.md +1 -1
  22. package/docs/agents/drift-surfaces.md +20 -9
  23. package/docs/agents/index.md +2 -2
  24. package/docs/agents/install-and-sync.md +3 -3
  25. package/docs/agents/migrate.md +1 -1
  26. package/docs/agents/rule-citations.md +1 -1
  27. package/docs/agents/slides.md +47 -3
  28. package/docs/agents/tasks.md +1 -1
  29. package/docs/target/migrations.md +3 -1
  30. package/docs/target/sync.md +1 -1
  31. package/docs/workflow/skill-map.md +14 -18
  32. package/governance/rules/canon/604-scratch.md +0 -1
  33. package/governance/rules/claude/575-hooks.md +1 -2
  34. package/governance/rules/claude/576-settings.md +1 -2
  35. package/governance/rules/standards/architecture.md +0 -1
  36. package/governance/rules/standards/context.md +0 -1
  37. package/governance/rules/standards/design.md +0 -1
  38. package/governance/rules/standards/diagrams.md +0 -3
  39. package/governance/rules/standards/requirements.md +0 -1
  40. package/governance/rules/standards/wireframes.md +0 -1
  41. package/package.json +1 -1
  42. package/scripts/manage-sandbox.sh +7 -8
  43. package/src/claude/cases/authoring.ts +5 -5
  44. package/src/commands/slides.ts +14 -4
  45. package/src/commands/sync.ts +10 -0
  46. package/src/gov/citations.ts +1 -2
  47. package/src/migrate/skill-names.ts +12 -0
  48. package/src/slides/convert/deck.ts +83 -3
  49. package/src/slides/convert/export.ts +67 -19
  50. package/src/slides/convert/shapes.ts +43 -3
  51. package/src/slides/convert/walk.ts +43 -1
  52. package/src/slides/package/fonts.ts +297 -0
  53. package/src/slides/package/motion.ts +316 -0
  54. package/src/slides/package/test-face.ts +113 -0
  55. package/src/sync/check.ts +9 -0
  56. package/src/sync/layout.ts +27 -1
  57. package/standards/architecture.md +0 -1
  58. package/standards/context.md +0 -1
  59. package/standards/design.md +0 -1
  60. package/standards/diagrams.md +0 -3
  61. package/standards/requirements.md +0 -1
  62. package/standards/rule.md +1 -0
  63. package/standards/wireframes.md +0 -1
  64. package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
  65. package/claude/skills/draft-context/REQUIREMENT.md +0 -37
  66. package/claude/skills/draft-context/SKILL.md +0 -61
  67. package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
  68. package/claude/skills/draft-docs/SKILL.md +0 -59
  69. package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
  70. package/claude/skills/draft-readme/SKILL.md +0 -73
  71. package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
  72. package/claude/skills/draft-wiki/SKILL.md +0 -83
  73. package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
  74. package/claude/skills/draft-wireframes/SKILL.md +0 -69
@@ -190,10 +190,10 @@ provision_sandbox() {
190
190
  (cd "$SANDBOX" && configure_sandbox_git_credentials)
191
191
  }
192
192
 
193
- # Runs the real installer rather than copying the source tree. The two copies
194
- # these replaced each reimplemented an installer's selection rules, so a change
195
- # to what install produces left the sandbox provisioning the old shape and no
196
- # scenario could observe the difference.
193
+ # Runs the real installer rather than copying the source tree. The copies these
194
+ # replaced, for gov rules and for seeds, each reimplemented an installer's
195
+ # selection rules, so a change to what install produces left the sandbox
196
+ # provisioning the old shape and no scenario could observe the difference.
197
197
  run_sandbox_install() {
198
198
  local label="$1"
199
199
  shift
@@ -223,10 +223,9 @@ inject_gov_rules() {
223
223
  }
224
224
 
225
225
  inject_seeds() {
226
- local seeds_source="$PROJECT_ROOT/tooling/claude/seeds"
227
- if [ -d "$seeds_source" ]; then
228
- cp -r "$seeds_source/." "$SANDBOX/"
229
- fi
226
+ [ ! -d "$PROJECT_ROOT/tooling/claude/seeds" ] && return
227
+
228
+ run_sandbox_install "seeds" claude init "$SANDBOX"
230
229
  }
231
230
 
232
231
  commit_environment_setup() {
@@ -31,7 +31,7 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
31
31
  {
32
32
  prompt:
33
33
  'Write a brand-new docs page for the capture command, nothing under docs/ covers it yet.',
34
- expect: 'draft-docs',
34
+ expect: 'draft-doc',
35
35
  },
36
36
  {
37
37
  prompt:
@@ -40,22 +40,22 @@ export const AUTHORING_CASES: readonly SkillCase[] = [
40
40
  },
41
41
  {
42
42
  prompt: 'This project has no README.md at all, write one from scratch.',
43
- expect: 'draft-readme',
43
+ expect: 'draft-doc',
44
44
  },
45
45
  {
46
46
  prompt:
47
47
  'Write a context entry for the payments domain, there is no canon/context page for it yet.',
48
- expect: 'draft-context',
48
+ expect: 'draft-doc',
49
49
  },
50
50
  {
51
51
  prompt:
52
52
  'Draft a wireframe for the settings panel, nothing under canon/wireframes covers that surface yet.',
53
- expect: 'draft-wireframes',
53
+ expect: 'draft-doc',
54
54
  },
55
55
  {
56
56
  prompt:
57
57
  'Write a wiki reference page for Claude Code output styles, no page covers that subject yet.',
58
- expect: 'draft-wiki',
58
+ expect: 'draft-doc',
59
59
  },
60
60
  {
61
61
  prompt: 'Say what that dense answer actually means in plain terms.',
@@ -119,10 +119,10 @@ export function register(program: Command): void {
119
119
 
120
120
  /**
121
121
  * The converter loads on demand, so no other command resolves pptxgenjs,
122
- * jszip, or the token module at startup. A fallback, a refused link or chart,
123
- * or a token the master fell back on reports one line each and keeps the exit
124
- * at 0, as an unrecognized layout does on the markdown path, since the deck is
125
- * written either way. A malformed `deck.json` refuses before any slide is laid
122
+ * jszip, or the token module at startup. A fallback, a refused link, chart, or
123
+ * effect, or a token the master fell back on reports one line each and keeps
124
+ * the exit at 0, as an unrecognized layout does on the markdown path, since the
125
+ * deck is written either way. A malformed `deck.json` refuses before any slide is laid
126
126
  * out and exits 1.
127
127
  */
128
128
  async function renderHtmlFolder(
@@ -163,6 +163,16 @@ async function renderHtmlFolder(
163
163
  `${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} chart left out: ${message}\n`,
164
164
  )
165
165
  }
166
+ for (const { slide, message } of result.refusedMotion) {
167
+ process.stderr.write(
168
+ `${GREY}│${NC} ${RED}✗${NC} slide ${slide} motion left out: ${message}\n`,
169
+ )
170
+ }
171
+ for (const { path, message } of result.refusedFonts) {
172
+ process.stderr.write(
173
+ `${GREY}│${NC} ${RED}✗${NC} font ${path} not embedded: ${message}\n`,
174
+ )
175
+ }
166
176
  for (const notice of result.notices) {
167
177
  process.stderr.write(`${GREY}│${NC} ${RED}✗${NC} ${notice}\n`)
168
178
  }
@@ -168,6 +168,16 @@ function renderCheck(report: CheckReport): void {
168
168
  logInfo('Move the content yourself. No sync command touches these.')
169
169
  }
170
170
 
171
+ if (report.unmovedSurfaces.length > 0) {
172
+ logStep('Surfaces at the old .claude/ root')
173
+ for (const entry of report.unmovedSurfaces) {
174
+ logWarn(`${entry.rel} (moves to ${entry.movesTo})`)
175
+ }
176
+ logInfo(
177
+ 'Rules glob the canon/ spelling only. Run `canon migrate surface-roots` to move these.',
178
+ )
179
+ }
180
+
171
181
  renderSeeds(report)
172
182
 
173
183
  if (report.superseded.length > 0) {
@@ -266,8 +266,7 @@ export function collectCitations(text: string): RawCitation[] {
266
266
  *
267
267
  * A rule spelling a whole path there is declaring that exact artifact, and a
268
268
  * body line naming it again is naming what the rule governs rather than
269
- * pointing a reader somewhere. `governance/rules/standards/diagrams.md` tells
270
- * its reader to convert a `.claude/DIAGRAMS.md` left by an older install, which
269
+ * pointing a reader somewhere. Such a path names a file a target holds, so it
271
270
  * is correctly absent from this tree and correctly named in the rule.
272
271
  */
273
272
  function governedPaths(text: string): string[] {
@@ -33,6 +33,13 @@ import { defineRenameRules, type RenameRules } from '@/migrate/rename'
33
33
  * its name collided with `docs-sync`, which owns `docs/`. `claude-docs` is
34
34
  * retargeted onto `context-fold` for the same reason `claude-ui-test` was.
35
35
  *
36
+ * Five rows merge skills rather than rename one. `draft-docs`,
37
+ * `draft-context`, `draft-wireframes`, `draft-readme`, and `draft-wiki` ran
38
+ * one procedure five times over, differing only in the standard each read and
39
+ * the catalog each checked, so they became `draft-doc`, which picks the kind
40
+ * from where the document lands. Every retired name lands on the one skill, so
41
+ * a target citing any of them resolves after a single sweep.
42
+ *
36
43
  * Every name takes two words. Ten of these would have landed as a bare single
37
44
  * word under a plain strip, and a bare word such as `review` or `docs` is a
38
45
  * substring of ordinary prose with no token left for a later sweep to find.
@@ -79,6 +86,11 @@ export const SKILL_NAME_MAP: Readonly<Record<string, string>> = {
79
86
  'canon-slides-draft': 'draft-slides',
80
87
  'canon-record': 'record-screencast',
81
88
  'canon-frames-read': 'read-frames',
89
+ 'draft-docs': 'draft-doc',
90
+ 'draft-context': 'draft-doc',
91
+ 'draft-wireframes': 'draft-doc',
92
+ 'draft-readme': 'draft-doc',
93
+ 'draft-wiki': 'draft-doc',
82
94
  }
83
95
 
84
96
  /**
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, statSync } from 'node:fs'
2
- import { basename, join } from 'node:path'
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
+ import { basename, isAbsolute, join, relative, resolve } from 'node:path'
3
3
 
4
4
  /**
5
5
  * What belongs to the deck rather than to one slide, read from `deck.json`
@@ -27,6 +27,18 @@ export interface DeckConfig {
27
27
  readonly slideNumbers: boolean
28
28
  /** Absolute, resolved against the deck folder. */
29
29
  readonly mark?: string
30
+ /** The faces the package step embeds, in the order the deck lists them. */
31
+ readonly fonts: readonly DeckFont[]
32
+ }
33
+
34
+ export interface DeckFont {
35
+ readonly family: string
36
+ readonly weight: number
37
+ readonly style: 'normal' | 'italic'
38
+ /** Absolute, resolved against the deck folder and kept inside it. */
39
+ readonly path: string
40
+ /** The path as `deck.json` wrote it, which a refusal names. */
41
+ readonly source: string
30
42
  }
31
43
 
32
44
  export type DeckRead =
@@ -43,8 +55,10 @@ const DECK_FIELDS = new Set([
43
55
  'footer',
44
56
  'slideNumbers',
45
57
  'mark',
58
+ 'fonts',
46
59
  ])
47
60
  const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
61
+ const FONT_FIELDS = new Set(['family', 'weight', 'style', 'path'])
48
62
 
49
63
  class FieldError extends Error {
50
64
  constructor(
@@ -133,7 +147,73 @@ function parse(raw: unknown, folder: string): DeckConfig {
133
147
  throw new FieldError('mark', `mark ${mark} does not exist`)
134
148
  }
135
149
  }
136
- return { title, header, footer, slideNumbers, mark }
150
+ const fonts = fontList(raw.fonts, folder)
151
+ return { title, header, footer, slideNumbers, mark, fonts }
152
+ }
153
+
154
+ function isWithin(folder: string, path: string): boolean {
155
+ const inside = relative(folder, path)
156
+ return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
157
+ }
158
+
159
+ /**
160
+ * A face is read from the deck folder only, so a `deck.json` cannot point the
161
+ * export at a file elsewhere on the machine and carry it out inside the deck.
162
+ */
163
+ function fontList(value: unknown, folder: string): DeckFont[] {
164
+ if (value === undefined) return []
165
+ if (!Array.isArray(value)) {
166
+ throw new FieldError('fonts', 'fonts must be a list')
167
+ }
168
+ return value.map((entry: unknown, index): DeckFont => {
169
+ const field = `fonts[${index}]`
170
+ if (!isRecord(entry)) {
171
+ throw new FieldError(field, `${field} must be an object`)
172
+ }
173
+ rejectUnknown(entry, FONT_FIELDS, `${field}.`)
174
+ const family = stringField(entry.family, `${field}.family`)
175
+ if (!family) {
176
+ throw new FieldError(`${field}.family`, `${field}.family is required`)
177
+ }
178
+ const weight = entry.weight ?? 400
179
+ if (
180
+ typeof weight !== 'number' ||
181
+ !Number.isInteger(weight) ||
182
+ weight < 1 ||
183
+ weight > 1000
184
+ ) {
185
+ throw new FieldError(
186
+ `${field}.weight`,
187
+ `${field}.weight must be a whole number from 1 to 1000`,
188
+ )
189
+ }
190
+ const style = entry.style ?? 'normal'
191
+ if (style !== 'normal' && style !== 'italic') {
192
+ throw new FieldError(
193
+ `${field}.style`,
194
+ `${field}.style must be normal or italic`,
195
+ )
196
+ }
197
+ const source = stringField(entry.path, `${field}.path`)
198
+ if (!source) {
199
+ throw new FieldError(`${field}.path`, `${field}.path is required`)
200
+ }
201
+ const path = resolve(folder, source)
202
+ const outside = new FieldError(
203
+ `${field}.path`,
204
+ `${field}.path ${source} lies outside the deck folder`,
205
+ )
206
+ if (!isWithin(folder, path)) throw outside
207
+ if (!existsSync(path) || !statSync(path).isFile()) {
208
+ throw new FieldError(
209
+ `${field}.path`,
210
+ `${field}.path ${path} does not exist`,
211
+ )
212
+ }
213
+ // A link inside the folder can still lead out of it.
214
+ if (!isWithin(realpathSync(folder), realpathSync(path))) throw outside
215
+ return { family, weight, style, path, source }
216
+ })
137
217
  }
138
218
 
139
219
  export function readDeck(folder: string): DeckRead {
@@ -42,13 +42,23 @@ import {
42
42
  type WalkedSlide,
43
43
  walkSlide,
44
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'
45
53
 
46
54
  /**
47
55
  * Turns a folder of HTML slides into one editable deck. Chromium lays each
48
56
  * slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
49
57
  * placed, `planSlide` maps the records onto shapes, and an element whose CSS has
50
58
  * 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.
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.
52
62
  *
53
63
  * Every browser reference this feature adds lives here, and the command reaches
54
64
  * it through a dynamic import so no other command resolves the engine.
@@ -94,6 +104,10 @@ export type ExportResult =
94
104
  readonly fallbacks: readonly SlideFallback[]
95
105
  readonly refusedLinks: readonly SlideRefusedLink[]
96
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[]
97
111
  /** What the deck master could not take from the project's tokens. */
98
112
  readonly notices: readonly string[]
99
113
  }
@@ -172,6 +186,7 @@ export async function exportHtmlDeck(
172
186
  const refusedLinks: SlideRefusedLink[] = []
173
187
  const refusedCharts: RefusedChart[] = []
174
188
  const patches: Patch[] = []
189
+ const motions: SlideMotionEdit[] = []
175
190
  const notices: string[] = []
176
191
 
177
192
  try {
@@ -227,9 +242,8 @@ export async function exportHtmlDeck(
227
242
  slide.background = { color: walked.background.hex }
228
243
  }
229
244
  if (walked.meta.isHidden) slide.hidden = true
230
- for (const [opIndex, op] of plan.ops.entries()) {
231
- const name = `canon-${slideNumber}-${opIndex + 1}`
232
- await draw(page, slide, { slide: slideNumber, name }, op, patches)
245
+ for (const op of plan.ops) {
246
+ await draw(page, slide, slideNumber, op, patches)
233
247
  }
234
248
  for (const record of walked.charts) {
235
249
  const chart = planChart(record, chartTheme)
@@ -241,6 +255,24 @@ export async function exportHtmlDeck(
241
255
  }
242
256
  drawOverrides(slide, deck, theme, walked.meta)
243
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
+ })
275
+ }
244
276
  }
245
277
  if (drifted.length > 0) {
246
278
  const noun = drifted.length === 1 ? 'slide' : 'slides'
@@ -262,12 +294,32 @@ export async function exportHtmlDeck(
262
294
  if (!(written instanceof Uint8Array)) {
263
295
  return refused('export-failed', 'pptxgenjs returned no buffer')
264
296
  }
265
- const patched = 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
+ }
266
318
 
267
319
  mkdirSync(outDir, { recursive: true })
268
320
  const fileName = `${basename(sourceDir)}.pptx`
269
321
  const pptxPath = join(outDir, fileName)
270
- writeFileSync(pptxPath, patched)
322
+ writeFileSync(pptxPath, packaged)
271
323
 
272
324
  let mirrorPath: string | undefined
273
325
  if (options.mirror) {
@@ -284,6 +336,8 @@ export async function exportHtmlDeck(
284
336
  fallbacks,
285
337
  refusedLinks,
286
338
  refusedCharts,
339
+ refusedMotion,
340
+ refusedFonts,
287
341
  notices,
288
342
  }
289
343
  }
@@ -437,10 +491,11 @@ const base64 = (bytes: Buffer | string): string =>
437
491
  async function draw(
438
492
  page: Page,
439
493
  slide: PptxGenJS.Slide,
440
- target: { readonly slide: number; readonly name: string },
494
+ slideNumber: number,
441
495
  op: DrawOp,
442
496
  patches: Patch[],
443
497
  ): Promise<void> {
498
+ const target = { slide: slideNumber, name: op.options.objectName ?? '' }
444
499
  if (op.kind === 'shape') {
445
500
  slide.addShape(op.shape, op.options)
446
501
  } else if (op.kind === 'text') {
@@ -448,18 +503,13 @@ async function draw(
448
503
  } else if (op.kind === 'table') {
449
504
  slide.addTable(op.rows, op.options)
450
505
  } else if (op.kind === 'image') {
451
- slide.addImage({
452
- ...op.options,
453
- ...imageSource(op.src),
454
- objectName: target.name,
455
- })
506
+ slide.addImage({ ...op.options, ...imageSource(op.src) })
456
507
  if (op.radius > 0) patches.push({ ...target, radius: op.radius })
457
508
  if (op.frame) slide.addShape(op.radius > 0 ? 'roundRect' : 'rect', op.frame)
458
509
  } else if (op.kind === 'svg') {
459
510
  slide.addImage({
460
511
  ...op.options,
461
512
  data: `image/svg+xml;base64,${base64(op.markup)}`,
462
- objectName: target.name,
463
513
  })
464
514
  patches.push({ ...target, png: await screenshot(page, op.id) })
465
515
  } else {
@@ -472,14 +522,13 @@ async function draw(
472
522
  * Two things pptxgenjs cannot write, fixed in the package it wrote. An SVG's
473
523
  * PNG fallback comes out as the SVG's own bytes under a `.png` name in Node, so
474
524
  * a viewer that cannot draw SVG gets a broken image, and a picture takes no
475
- * corner radius. Each patched picture is found by the object name `draw` gave
476
- * 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.
477
527
  */
478
528
  async function applyPatches(
479
- deck: Uint8Array,
529
+ zip: JSZip,
480
530
  patches: readonly Patch[],
481
- ): Promise<Buffer> {
482
- const zip = await JSZip.loadAsync(deck)
531
+ ): Promise<void> {
483
532
  const bySlide = Map.groupBy(patches, (patch) => patch.slide)
484
533
  for (const [slideNumber, slidePatches] of bySlide) {
485
534
  const slidePath = `ppt/slides/slide${slideNumber}.xml`
@@ -504,7 +553,6 @@ async function applyPatches(
504
553
  }
505
554
  zip.file(slidePath, xml)
506
555
  }
507
- return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
508
556
  }
509
557
 
510
558
  function pictureXml(xml: string, name: string): string | undefined {
@@ -521,10 +521,44 @@ export interface Fallback {
521
521
  readonly properties: readonly UnmappedProperty[]
522
522
  }
523
523
 
524
+ /** A shape the slide will hold, by the record that produced it. */
525
+ export interface NamedShape {
526
+ readonly record: number
527
+ readonly name: string
528
+ }
529
+
524
530
  export interface SlidePlan {
525
531
  readonly ops: readonly DrawOp[]
526
532
  readonly fallbacks: readonly Fallback[]
527
533
  readonly refusedLinks: readonly RefusedLink[]
534
+ /** Every object name the ops carry, in the order they are drawn. */
535
+ readonly names: readonly NamedShape[]
536
+ }
537
+
538
+ /**
539
+ * pptxgenjs numbers shape ids itself, so the object name is the only handle a
540
+ * later pass over the written XML can find a shape by.
541
+ */
542
+ export const objectNameOf = (record: number): string => `canon-${record}`
543
+
544
+ /** An image's border is a second shape over it, named after the picture. */
545
+ function named<T extends DrawOp>(op: T, record: number): T {
546
+ const name = objectNameOf(record)
547
+ const options = { ...op.options, objectName: name }
548
+ if (op.kind === 'image' && op.frame) {
549
+ return {
550
+ ...op,
551
+ options,
552
+ frame: { ...op.frame, objectName: `${name}-frame` },
553
+ }
554
+ }
555
+ return { ...op, options }
556
+ }
557
+
558
+ function namesOf(record: number, op: DrawOp): NamedShape[] {
559
+ const names = [op.options.objectName]
560
+ if (op.kind === 'image') names.push(op.frame?.objectName)
561
+ return names.flatMap((name) => (name ? [{ record, name }] : []))
528
562
  }
529
563
 
530
564
  function boxOp(record: ElementRecord): DrawOp | undefined {
@@ -729,7 +763,13 @@ export function planSlide(
729
763
  const ops: DrawOp[] = []
730
764
  const fallbacks: Fallback[] = []
731
765
  const refusedLinks: RefusedLink[] = []
766
+ const names: NamedShape[] = []
732
767
  const dropped = new Set<number>()
768
+ const add = (record: number, op: DrawOp): void => {
769
+ const withName = named(op, record)
770
+ ops.push(withName)
771
+ names.push(...namesOf(record, withName))
772
+ }
733
773
 
734
774
  for (const record of records) {
735
775
  if (record.parent !== null && dropped.has(record.parent)) {
@@ -740,7 +780,7 @@ export function planSlide(
740
780
  if (properties.length > 0) {
741
781
  dropped.add(record.id)
742
782
  fallbacks.push({ id: record.id, selector: record.selector, properties })
743
- ops.push({
783
+ add(record.id, {
744
784
  kind: 'fallback',
745
785
  id: record.id,
746
786
  options: { ...position(record.bounds), altText: record.text },
@@ -757,8 +797,8 @@ export function planSlide(
757
797
  : record.kind === 'svg'
758
798
  ? svgOp(record)
759
799
  : tableOp(record, context, refusedLinks)
760
- if (op) ops.push(op)
800
+ if (op) add(record.id, op)
761
801
  }
762
802
 
763
- return { ops, fallbacks, refusedLinks }
803
+ return { ops, fallbacks, refusedLinks, names }
764
804
  }
@@ -1,5 +1,6 @@
1
1
  import type { ChartRecord } from '@/slides/convert/chart'
2
2
  import type { ThemeReading } from '@/slides/convert/master'
3
+ import type { TransitionSpec } from '@/slides/package/motion'
3
4
  import type {
4
5
  BoxStyle,
5
6
  ElementRecord,
@@ -31,6 +32,20 @@ export interface SlideMeta {
31
32
  readonly footer: BandOverride
32
33
  /** The `--color-text` the slide's `<body>` computes, as hex. */
33
34
  readonly textToken?: string
35
+ /** `data-transition` and `data-transition-duration` on `<body>`. */
36
+ readonly transition?: TransitionSpec
37
+ }
38
+
39
+ /**
40
+ * An element's `data-enter`, with the record it was walked as, or null when it
41
+ * was folded into another record's text or never drawn.
42
+ */
43
+ export interface EntranceRecord {
44
+ readonly record: number | null
45
+ readonly selector: string
46
+ readonly effect: string
47
+ readonly order?: string
48
+ readonly duration?: string
34
49
  }
35
50
 
36
51
  /** What one laid-out slide hands back across `page.evaluate`. */
@@ -38,6 +53,7 @@ export interface WalkedSlide {
38
53
  readonly background: Rgba
39
54
  readonly records: ElementRecord[]
40
55
  readonly charts: ChartRecord[]
56
+ readonly entrances: EntranceRecord[]
41
57
  readonly meta: SlideMeta
42
58
  }
43
59
 
@@ -532,6 +548,10 @@ export function walkSlide(idAttribute: string): WalkedSlide {
532
548
  : { kind: 'master' }
533
549
  }
534
550
  const section = body.getAttribute('data-section')?.trim()
551
+ const attribute = (element: Element, name: string): string | undefined =>
552
+ element.getAttribute(name)?.trim() || undefined
553
+ const transitionEffect = attribute(body, 'data-transition')
554
+ const transitionDuration = attribute(body, 'data-transition-duration')
535
555
  const textValue = getComputedStyle(body)
536
556
  .getPropertyValue('--color-text')
537
557
  .trim()
@@ -544,9 +564,31 @@ export function walkSlide(idAttribute: string): WalkedSlide {
544
564
  body.getAttribute('data-hidden') !== 'false',
545
565
  header: band('header'),
546
566
  footer: band('footer'),
567
+ ...(transitionEffect
568
+ ? {
569
+ transition: {
570
+ effect: transitionEffect,
571
+ ...(transitionDuration ? { duration: transitionDuration } : {}),
572
+ },
573
+ }
574
+ : {}),
547
575
  }
548
576
 
549
- return { background, records, charts, meta }
577
+ const entrances: EntranceRecord[] = Array.from(
578
+ body.querySelectorAll('[data-enter]'),
579
+ ).map((element) => {
580
+ const order = attribute(element, 'data-enter-order')
581
+ const duration = attribute(element, 'data-enter-duration')
582
+ return {
583
+ record: ids.get(element) ?? null,
584
+ selector: describe(element),
585
+ effect: attribute(element, 'data-enter') ?? '',
586
+ ...(order ? { order } : {}),
587
+ ...(duration ? { duration } : {}),
588
+ }
589
+ })
590
+
591
+ return { background, records, charts, entrances, meta }
550
592
  }
551
593
 
552
594
  /**