@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/draft-mark.md +8 -6
- package/claude/skills/draft-doc/REQUIREMENT.md +45 -0
- package/claude/skills/draft-doc/SKILL.md +77 -0
- package/claude/skills/draft-doc/references/context.md +31 -0
- package/claude/skills/draft-doc/references/docs.md +33 -0
- package/claude/skills/draft-doc/references/readme.md +58 -0
- package/claude/skills/draft-doc/references/wiki.md +53 -0
- package/claude/skills/draft-doc/references/wireframes.md +48 -0
- package/claude/skills/draft-figure/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +1 -1
- package/claude/skills/git-pr/SKILL.md +5 -46
- package/claude/skills/git-pr/references/final-command.md +75 -0
- package/claude/skills/git-pr/references/labels.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +76 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +11 -1
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -1
- package/claude/skills/search-craft/REQUIREMENT.md +4 -4
- package/claude/skills/search-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/drift-surfaces.md +20 -9
- package/docs/agents/index.md +2 -2
- package/docs/agents/install-and-sync.md +3 -3
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/slides.md +47 -3
- package/docs/agents/tasks.md +1 -1
- package/docs/target/migrations.md +3 -1
- package/docs/target/sync.md +1 -1
- package/docs/workflow/skill-map.md +14 -18
- package/governance/rules/canon/604-scratch.md +0 -1
- package/governance/rules/claude/575-hooks.md +1 -2
- package/governance/rules/claude/576-settings.md +1 -2
- package/governance/rules/standards/architecture.md +0 -1
- package/governance/rules/standards/context.md +0 -1
- package/governance/rules/standards/design.md +0 -1
- package/governance/rules/standards/diagrams.md +0 -3
- package/governance/rules/standards/requirements.md +0 -1
- package/governance/rules/standards/wireframes.md +0 -1
- package/package.json +1 -1
- package/scripts/manage-sandbox.sh +7 -8
- package/src/claude/cases/authoring.ts +5 -5
- package/src/commands/slides.ts +14 -4
- package/src/commands/sync.ts +10 -0
- package/src/gov/citations.ts +1 -2
- package/src/migrate/skill-names.ts +12 -0
- package/src/slides/convert/deck.ts +83 -3
- package/src/slides/convert/export.ts +67 -19
- package/src/slides/convert/shapes.ts +43 -3
- package/src/slides/convert/walk.ts +43 -1
- package/src/slides/package/fonts.ts +297 -0
- package/src/slides/package/motion.ts +316 -0
- package/src/slides/package/test-face.ts +113 -0
- package/src/sync/check.ts +9 -0
- package/src/sync/layout.ts +27 -1
- package/standards/architecture.md +0 -1
- package/standards/context.md +0 -1
- package/standards/design.md +0 -1
- package/standards/diagrams.md +0 -3
- package/standards/requirements.md +0 -1
- package/standards/rule.md +1 -0
- package/standards/wireframes.md +0 -1
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +8 -3
- package/claude/skills/draft-context/REQUIREMENT.md +0 -37
- package/claude/skills/draft-context/SKILL.md +0 -61
- package/claude/skills/draft-docs/REQUIREMENT.md +0 -35
- package/claude/skills/draft-docs/SKILL.md +0 -59
- package/claude/skills/draft-readme/REQUIREMENT.md +0 -38
- package/claude/skills/draft-readme/SKILL.md +0 -73
- package/claude/skills/draft-wiki/REQUIREMENT.md +0 -42
- package/claude/skills/draft-wiki/SKILL.md +0 -83
- package/claude/skills/draft-wireframes/REQUIREMENT.md +0 -38
- package/claude/skills/draft-wireframes/SKILL.md +0 -69
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type JSZip from 'jszip'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Embeds the deck's typeface in the written package, which pptxgenjs cannot do.
|
|
5
|
+
* PowerPoint reads an embedded face as an Embedded OpenType 2.2 container
|
|
6
|
+
* around the TrueType or OpenType data, stored as `ppt/fonts/fontN.fntdata`
|
|
7
|
+
* and listed in `presentation.xml`. The header is built from the face's own
|
|
8
|
+
* `OS/2`, `head`, and `name` tables, laid out per the W3C EOT submission:
|
|
9
|
+
* https://www.w3.org/submissions/EOT/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface EmbedFace {
|
|
13
|
+
readonly family: string
|
|
14
|
+
readonly weight: number
|
|
15
|
+
readonly style: 'normal' | 'italic'
|
|
16
|
+
/** As `deck.json` wrote it, which every notice names. */
|
|
17
|
+
readonly path: string
|
|
18
|
+
readonly bytes: Uint8Array
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FontNotice {
|
|
22
|
+
readonly path: string
|
|
23
|
+
readonly message: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type EotResult =
|
|
27
|
+
| { readonly status: 'embeddable'; readonly eot: Buffer }
|
|
28
|
+
| { readonly status: 'refused'; readonly message: string }
|
|
29
|
+
|
|
30
|
+
/** `fsType` bits from the OpenType `OS/2` table. */
|
|
31
|
+
const RESTRICTED = 0x0002
|
|
32
|
+
const BITMAP_ONLY = 0x0200
|
|
33
|
+
|
|
34
|
+
const SFNT_VERSIONS = new Set([0x00010000, 0x74727565, 0x4f54544f])
|
|
35
|
+
const WRAPPED: Readonly<Record<number, string>> = {
|
|
36
|
+
0x774f4632: 'woff2',
|
|
37
|
+
0x774f4646: 'woff',
|
|
38
|
+
0x74746366: 'a font collection',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const UNDECODABLE = 'it could not be decoded as a TrueType or OpenType font'
|
|
42
|
+
|
|
43
|
+
interface Table {
|
|
44
|
+
readonly offset: number
|
|
45
|
+
readonly length: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tables(font: Buffer): Map<string, Table> {
|
|
49
|
+
const found = new Map<string, Table>()
|
|
50
|
+
const count = font.readUInt16BE(4)
|
|
51
|
+
for (let index = 0; index < count; index += 1) {
|
|
52
|
+
const at = 12 + index * 16
|
|
53
|
+
const offset = font.readUInt32BE(at + 8)
|
|
54
|
+
const length = font.readUInt32BE(at + 12)
|
|
55
|
+
if (offset + length > font.length) throw new RangeError('table overruns')
|
|
56
|
+
found.set(font.toString('latin1', at, at + 4), { offset, length })
|
|
57
|
+
}
|
|
58
|
+
return found
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Windows platform's strings, in UTF-16, preferring US English where a
|
|
63
|
+
* face carries several languages.
|
|
64
|
+
*/
|
|
65
|
+
function names(font: Buffer, table: Table): Map<number, Buffer> {
|
|
66
|
+
const found = new Map<number, { language: number; bytes: Buffer }>()
|
|
67
|
+
const count = font.readUInt16BE(table.offset + 2)
|
|
68
|
+
const strings = table.offset + font.readUInt16BE(table.offset + 4)
|
|
69
|
+
for (let index = 0; index < count; index += 1) {
|
|
70
|
+
const at = table.offset + 6 + index * 12
|
|
71
|
+
if (font.readUInt16BE(at) !== 3) continue
|
|
72
|
+
const language = font.readUInt16BE(at + 4)
|
|
73
|
+
const id = font.readUInt16BE(at + 6)
|
|
74
|
+
const length = font.readUInt16BE(at + 8)
|
|
75
|
+
const start = strings + font.readUInt16BE(at + 10)
|
|
76
|
+
if (start + length > font.length) throw new RangeError('name overruns')
|
|
77
|
+
const held = found.get(id)
|
|
78
|
+
if (held && (held.language === 0x0409 || language !== 0x0409)) continue
|
|
79
|
+
const bytes = Buffer.from(font.subarray(start, start + length)).swap16()
|
|
80
|
+
found.set(id, { language, bytes })
|
|
81
|
+
}
|
|
82
|
+
return new Map([...found].map(([id, { bytes }]) => [id, bytes]))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sized(bytes: Buffer = Buffer.alloc(0)): Buffer {
|
|
86
|
+
const size = Buffer.alloc(2)
|
|
87
|
+
size.writeUInt16LE(bytes.length)
|
|
88
|
+
return Buffer.concat([size, bytes])
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function header(font: Buffer, os2: number, head: number): Buffer {
|
|
92
|
+
const fixed = Buffer.alloc(80)
|
|
93
|
+
fixed.writeUInt32LE(font.length, 4)
|
|
94
|
+
fixed.writeUInt32LE(0x00020002, 8)
|
|
95
|
+
font.copy(fixed, 16, os2 + 32, os2 + 42)
|
|
96
|
+
fixed.writeUInt8(1, 26)
|
|
97
|
+
fixed.writeUInt8(font.readUInt16BE(os2 + 62) & 1, 27)
|
|
98
|
+
fixed.writeUInt32LE(font.readUInt16BE(os2 + 4), 28)
|
|
99
|
+
fixed.writeUInt16LE(font.readUInt16BE(os2 + 8), 32)
|
|
100
|
+
fixed.writeUInt16LE(0x504c, 34)
|
|
101
|
+
for (let index = 0; index < 4; index += 1) {
|
|
102
|
+
fixed.writeUInt32LE(font.readUInt32BE(os2 + 42 + index * 4), 36 + index * 4)
|
|
103
|
+
}
|
|
104
|
+
for (let index = 0; index < 2; index += 1) {
|
|
105
|
+
fixed.writeUInt32LE(font.readUInt32BE(os2 + 78 + index * 4), 52 + index * 4)
|
|
106
|
+
}
|
|
107
|
+
fixed.writeUInt32LE(font.readUInt32BE(head + 8), 60)
|
|
108
|
+
return fixed
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The EOT container around one face, or the reason it may not travel. A face
|
|
113
|
+
* whose license restricts embedding or allows a bitmap only is refused, as is
|
|
114
|
+
* a variable face, which PowerPoint does not embed. Editable and print-preview
|
|
115
|
+
* embedding both pass, since the deck is meant to be edited.
|
|
116
|
+
*/
|
|
117
|
+
export function eotOf(bytes: Uint8Array): EotResult {
|
|
118
|
+
const font = Buffer.from(bytes)
|
|
119
|
+
const refused = (message: string): EotResult => ({
|
|
120
|
+
status: 'refused',
|
|
121
|
+
message,
|
|
122
|
+
})
|
|
123
|
+
if (font.length < 12) return refused(UNDECODABLE)
|
|
124
|
+
const version = font.readUInt32BE(0)
|
|
125
|
+
const wrapped = WRAPPED[version]
|
|
126
|
+
if (wrapped) {
|
|
127
|
+
return refused(
|
|
128
|
+
`it is ${wrapped}. Supply the TrueType or OpenType file instead`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
if (!SFNT_VERSIONS.has(version)) return refused(UNDECODABLE)
|
|
132
|
+
try {
|
|
133
|
+
const found = tables(font)
|
|
134
|
+
const os2 = found.get('OS/2')
|
|
135
|
+
const head = found.get('head')
|
|
136
|
+
const name = found.get('name')
|
|
137
|
+
// Version 0 of OS/2 stops before the code page ranges the header copies.
|
|
138
|
+
if (!os2 || os2.length < 86 || !head || head.length < 12 || !name) {
|
|
139
|
+
return refused(UNDECODABLE)
|
|
140
|
+
}
|
|
141
|
+
const fsType = font.readUInt16BE(os2.offset + 8)
|
|
142
|
+
if (fsType & RESTRICTED) {
|
|
143
|
+
return refused('its license forbids embedding (fsType restricted)')
|
|
144
|
+
}
|
|
145
|
+
if (fsType & BITMAP_ONLY) {
|
|
146
|
+
return refused('its license allows bitmap embedding only (fsType 0x0200)')
|
|
147
|
+
}
|
|
148
|
+
if (found.has('fvar')) {
|
|
149
|
+
return refused(
|
|
150
|
+
'it is a variable font, which PowerPoint does not embed. Supply a static instance',
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
const strings = names(font, name)
|
|
154
|
+
const padding = Buffer.alloc(2)
|
|
155
|
+
// RootStringCheckSum, EUDCCodePage, Padding6, SignatureSize, EUDCFlags,
|
|
156
|
+
// and EUDCFontSize, all zero for a face with no root string or EUDC data.
|
|
157
|
+
const tail = Buffer.alloc(20)
|
|
158
|
+
const eot = Buffer.concat([
|
|
159
|
+
header(font, os2.offset, head.offset),
|
|
160
|
+
padding,
|
|
161
|
+
sized(strings.get(1)),
|
|
162
|
+
padding,
|
|
163
|
+
sized(strings.get(2)),
|
|
164
|
+
padding,
|
|
165
|
+
sized(strings.get(5)),
|
|
166
|
+
padding,
|
|
167
|
+
sized(strings.get(4)),
|
|
168
|
+
padding,
|
|
169
|
+
sized(),
|
|
170
|
+
tail,
|
|
171
|
+
font,
|
|
172
|
+
])
|
|
173
|
+
eot.writeUInt32LE(eot.length, 0)
|
|
174
|
+
return { status: 'embeddable', eot }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof RangeError) return refused(UNDECODABLE)
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type Slot = 'regular' | 'bold' | 'italic' | 'boldItalic'
|
|
182
|
+
|
|
183
|
+
/** The child order `<p:embeddedFont>` requires. */
|
|
184
|
+
const SLOTS: readonly Slot[] = ['regular', 'bold', 'italic', 'boldItalic']
|
|
185
|
+
|
|
186
|
+
/** PowerPoint keeps four faces a family, so a weight from 600 reads as bold. */
|
|
187
|
+
function slotOf(face: EmbedFace): Slot {
|
|
188
|
+
const isBold = face.weight >= 600
|
|
189
|
+
if (face.style === 'italic') return isBold ? 'boldItalic' : 'italic'
|
|
190
|
+
return isBold ? 'bold' : 'regular'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FONT_RELATIONSHIP =
|
|
194
|
+
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/font'
|
|
195
|
+
const NOTES_SIZE = /<p:notesSz\b[^>]*\/>/
|
|
196
|
+
|
|
197
|
+
const escapeXml = (value: string): string =>
|
|
198
|
+
value
|
|
199
|
+
.replaceAll('&', '&')
|
|
200
|
+
.replaceAll('"', '"')
|
|
201
|
+
.replaceAll('<', '<')
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Embeds each face the deck lists and returns one notice per face left out.
|
|
205
|
+
* The font list goes after `<p:notesSz>`, where the schema orders it, and a
|
|
206
|
+
* package missing that anchor embeds nothing rather than placing it elsewhere.
|
|
207
|
+
*/
|
|
208
|
+
export async function embedFonts(
|
|
209
|
+
zip: JSZip,
|
|
210
|
+
faces: readonly EmbedFace[],
|
|
211
|
+
): Promise<FontNotice[]> {
|
|
212
|
+
const notices: FontNotice[] = []
|
|
213
|
+
const families = new Map<string, Map<Slot, number>>()
|
|
214
|
+
const parts: Buffer[] = []
|
|
215
|
+
|
|
216
|
+
for (const face of faces) {
|
|
217
|
+
const result = eotOf(face.bytes)
|
|
218
|
+
if (result.status === 'refused') {
|
|
219
|
+
notices.push({ path: face.path, message: result.message })
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const slots = families.get(face.family) ?? new Map<Slot, number>()
|
|
223
|
+
const slot = slotOf(face)
|
|
224
|
+
if (slots.has(slot)) {
|
|
225
|
+
notices.push({
|
|
226
|
+
path: face.path,
|
|
227
|
+
message: `${face.family} already embeds a ${slot} face, and PowerPoint holds one per slot`,
|
|
228
|
+
})
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
parts.push(result.eot)
|
|
232
|
+
slots.set(slot, parts.length)
|
|
233
|
+
families.set(face.family, slots)
|
|
234
|
+
}
|
|
235
|
+
if (parts.length === 0) return notices
|
|
236
|
+
|
|
237
|
+
const presentationPath = 'ppt/presentation.xml'
|
|
238
|
+
const relsPath = 'ppt/_rels/presentation.xml.rels'
|
|
239
|
+
const typesPath = '[Content_Types].xml'
|
|
240
|
+
const [presentation, rels, types] = await Promise.all(
|
|
241
|
+
[presentationPath, relsPath, typesPath].map(
|
|
242
|
+
async (path) => (await zip.file(path)?.async('string')) ?? '',
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
if (!presentation || !NOTES_SIZE.test(presentation)) {
|
|
246
|
+
const message = 'the deck has no <p:notesSz> to anchor the font list after'
|
|
247
|
+
return [
|
|
248
|
+
...notices,
|
|
249
|
+
...faces
|
|
250
|
+
.filter((face) => !notices.some((notice) => notice.path === face.path))
|
|
251
|
+
.map((face) => ({ path: face.path, message })),
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const id = (part: number): string => `rIdCanonFont${part}`
|
|
256
|
+
parts.forEach((eot, index) => {
|
|
257
|
+
zip.file(`ppt/fonts/font${index + 1}.fntdata`, eot)
|
|
258
|
+
})
|
|
259
|
+
const relationships = parts
|
|
260
|
+
.map(
|
|
261
|
+
(_, index) =>
|
|
262
|
+
`<Relationship Id="${id(index + 1)}" Type="${FONT_RELATIONSHIP}" Target="fonts/font${index + 1}.fntdata"/>`,
|
|
263
|
+
)
|
|
264
|
+
.join('')
|
|
265
|
+
zip.file(
|
|
266
|
+
relsPath,
|
|
267
|
+
rels.replace('</Relationships>', `${relationships}</Relationships>`),
|
|
268
|
+
)
|
|
269
|
+
if (!types.includes('Extension="fntdata"')) {
|
|
270
|
+
zip.file(
|
|
271
|
+
typesPath,
|
|
272
|
+
types.replace(
|
|
273
|
+
/(<Types\b[^>]*>)/,
|
|
274
|
+
'$1<Default Extension="fntdata" ContentType="application/x-fontdata"/>',
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
const list = [...families]
|
|
279
|
+
.map(([family, slots]) => {
|
|
280
|
+
const children = SLOTS.flatMap((slot) => {
|
|
281
|
+
const part = slots.get(slot)
|
|
282
|
+
return part ? [`<p:${slot} r:id="${id(part)}"/>`] : []
|
|
283
|
+
})
|
|
284
|
+
return `<p:embeddedFont><p:font typeface="${escapeXml(family)}"/>${children.join('')}</p:embeddedFont>`
|
|
285
|
+
})
|
|
286
|
+
.join('')
|
|
287
|
+
zip.file(
|
|
288
|
+
presentationPath,
|
|
289
|
+
presentation
|
|
290
|
+
.replace(
|
|
291
|
+
NOTES_SIZE,
|
|
292
|
+
(anchor) => `${anchor}<p:embeddedFontLst>${list}</p:embeddedFontLst>`,
|
|
293
|
+
)
|
|
294
|
+
.replace('<p:presentation ', '<p:presentation embedTrueTypeFonts="1" '),
|
|
295
|
+
)
|
|
296
|
+
return notices
|
|
297
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import type JSZip from 'jszip'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slide transitions and entrance animations, written into a slide's XML after
|
|
5
|
+
* pptxgenjs has written it, since pptxgenjs declares neither. A slide states
|
|
6
|
+
* them in its HTML, `walk.ts` reads the attributes, and the converter resolves
|
|
7
|
+
* each animated element to the object names `planSlide` gave its shapes. This
|
|
8
|
+
* module turns those names into the shape ids the written XML assigned.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TransitionSpec {
|
|
12
|
+
readonly effect: string
|
|
13
|
+
readonly duration?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface EntranceSpec {
|
|
17
|
+
/** The element that declared it, which every notice names. */
|
|
18
|
+
readonly selector: string
|
|
19
|
+
readonly effect: string
|
|
20
|
+
readonly order?: string
|
|
21
|
+
readonly duration?: string
|
|
22
|
+
/** Every shape drawn from the element and the elements inside it. */
|
|
23
|
+
readonly shapes: readonly string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SlideMotion {
|
|
27
|
+
readonly transition?: TransitionSpec
|
|
28
|
+
readonly entrances: readonly EntranceSpec[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MotionResult {
|
|
32
|
+
readonly xml: string
|
|
33
|
+
readonly notices: readonly string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A slide's motion, numbered as the package numbers its slide parts. */
|
|
37
|
+
export interface SlideMotionEdit {
|
|
38
|
+
readonly slide: number
|
|
39
|
+
readonly motion: SlideMotion
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MotionNotice {
|
|
43
|
+
readonly slide: number
|
|
44
|
+
readonly message: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The child element `<p:transition>` holds, each in its default direction. */
|
|
48
|
+
const TRANSITIONS: Readonly<Record<string, string>> = {
|
|
49
|
+
fade: '<p:fade/>',
|
|
50
|
+
push: '<p:push/>',
|
|
51
|
+
wipe: '<p:wipe/>',
|
|
52
|
+
cover: '<p:cover/>',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The strict schema states a transition's length only as one of three speeds,
|
|
57
|
+
* 0.5, 0.75, and 1 second. A length in milliseconds needs the PowerPoint 2010
|
|
58
|
+
* extension namespace, so a declared duration rounds to the nearest speed.
|
|
59
|
+
*/
|
|
60
|
+
function speedOf(duration: number | undefined): 'fast' | 'med' | 'slow' {
|
|
61
|
+
if (duration === undefined) return 'med'
|
|
62
|
+
if (duration < 625) return 'fast'
|
|
63
|
+
return duration < 875 ? 'med' : 'slow'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Behaviors = (spid: string, duration: number, next: () => number) => string
|
|
67
|
+
|
|
68
|
+
const target = (spid: string): string =>
|
|
69
|
+
`<p:tgtEl><p:spTgt spid="${spid}"/></p:tgtEl>`
|
|
70
|
+
|
|
71
|
+
const animEffect =
|
|
72
|
+
(filter: string): Behaviors =>
|
|
73
|
+
(spid, duration, next) =>
|
|
74
|
+
`<p:animEffect transition="in" filter="${filter}"><p:cBhvr><p:cTn id="${next()}" dur="${duration}"/>${target(spid)}</p:cBhvr></p:animEffect>`
|
|
75
|
+
|
|
76
|
+
function anim(
|
|
77
|
+
spid: string,
|
|
78
|
+
duration: number,
|
|
79
|
+
id: number,
|
|
80
|
+
attribute: string,
|
|
81
|
+
from: string,
|
|
82
|
+
to: string,
|
|
83
|
+
): string {
|
|
84
|
+
return `<p:anim calcmode="lin" valueType="num"><p:cBhvr additive="base"><p:cTn id="${id}" dur="${duration}" fill="hold"/>${target(spid)}<p:attrNameLst><p:attrName>${attribute}</p:attrName></p:attrNameLst></p:cBhvr><p:tavLst><p:tav tm="0"><p:val><p:strVal val="${from}"/></p:val></p:tav><p:tav tm="100000"><p:val><p:strVal val="${to}"/></p:val></p:tav></p:tavLst></p:anim>`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface Preset {
|
|
88
|
+
readonly id: number
|
|
89
|
+
readonly subtype: number
|
|
90
|
+
readonly behaviors: Behaviors
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* PowerPoint's own preset ids, so the effect shows by name in its animation
|
|
95
|
+
* pane. Fly comes in from the bottom and wipe from the left, the directions
|
|
96
|
+
* PowerPoint picks when none is chosen. A wipe filter names the edge the
|
|
97
|
+
* reveal starts from, so from the left pairs with `wipe(left)`.
|
|
98
|
+
*/
|
|
99
|
+
const ENTRANCES: Readonly<Record<string, Preset>> = {
|
|
100
|
+
fade: { id: 10, subtype: 0, behaviors: animEffect('fade') },
|
|
101
|
+
fly: {
|
|
102
|
+
id: 2,
|
|
103
|
+
subtype: 4,
|
|
104
|
+
behaviors: (spid, duration, next) =>
|
|
105
|
+
anim(spid, duration, next(), 'ppt_x', '#ppt_x', '#ppt_x') +
|
|
106
|
+
anim(spid, duration, next(), 'ppt_y', '1+#ppt_h/2', '#ppt_y'),
|
|
107
|
+
},
|
|
108
|
+
wipe: { id: 22, subtype: 8, behaviors: animEffect('wipe(left)') },
|
|
109
|
+
zoom: {
|
|
110
|
+
id: 53,
|
|
111
|
+
subtype: 16,
|
|
112
|
+
behaviors: (spid, duration, next) =>
|
|
113
|
+
anim(spid, duration, next(), 'ppt_w', '0', '#ppt_w') +
|
|
114
|
+
anim(spid, duration, next(), 'ppt_h', '0', '#ppt_h') +
|
|
115
|
+
animEffect('fade')(spid, duration, next),
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_ENTRANCE_MS = 500
|
|
120
|
+
|
|
121
|
+
const ANCHOR = '</p:clrMapOvr>'
|
|
122
|
+
|
|
123
|
+
const list = (names: readonly string[]): string =>
|
|
124
|
+
`${names.slice(0, -1).join(', ')}, or ${names.at(-1)}`
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The object names an entrance on `root` brings in: every shape drawn from it
|
|
128
|
+
* and from the elements inside it, so a card's box and its text arrive on one
|
|
129
|
+
* click. A descendant in `roots` declares its own entrance and keeps its
|
|
130
|
+
* subtree for that click. A record follows its parent in document order, which
|
|
131
|
+
* lets one pass collect the subtree.
|
|
132
|
+
*/
|
|
133
|
+
export function entranceShapes(
|
|
134
|
+
root: number | null,
|
|
135
|
+
records: readonly { readonly id: number; readonly parent: number | null }[],
|
|
136
|
+
names: readonly { readonly record: number; readonly name: string }[],
|
|
137
|
+
roots: ReadonlySet<number>,
|
|
138
|
+
): string[] {
|
|
139
|
+
if (root === null) return []
|
|
140
|
+
const subtree = new Set([root])
|
|
141
|
+
for (const record of records) {
|
|
142
|
+
const isOwnEntrance = roots.has(record.id)
|
|
143
|
+
if (
|
|
144
|
+
record.parent !== null &&
|
|
145
|
+
subtree.has(record.parent) &&
|
|
146
|
+
!isOwnEntrance
|
|
147
|
+
) {
|
|
148
|
+
subtree.add(record.id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return names
|
|
152
|
+
.filter((named) => subtree.has(named.record))
|
|
153
|
+
.map((named) => named.name)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Milliseconds from `500`, `500ms`, or `0.5s`, or undefined when unreadable. */
|
|
157
|
+
export function millisecondsOf(value: string | undefined): number | undefined {
|
|
158
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s)?\s*$/.exec(value ?? '')
|
|
159
|
+
if (!match?.[1]) return undefined
|
|
160
|
+
const amount = Number(match[1])
|
|
161
|
+
return Math.round(match[2] === 's' ? amount * 1000 : amount)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* pptxgenjs numbers a table `n * slideNumber + 1` and every other shape by its
|
|
166
|
+
* index plus two, so a table can share an id with a shape beside it. A timing
|
|
167
|
+
* target has to name one shape, so each repeat after the first takes a fresh id.
|
|
168
|
+
*/
|
|
169
|
+
function uniqueIds(xml: string): string {
|
|
170
|
+
const pattern = /<p:cNvPr id="(\d+)"/g
|
|
171
|
+
let highest = Math.max(
|
|
172
|
+
0,
|
|
173
|
+
...[...xml.matchAll(pattern)].map((match) => Number(match[1])),
|
|
174
|
+
)
|
|
175
|
+
const seen = new Set<string>()
|
|
176
|
+
return xml.replace(pattern, (whole, id: string) => {
|
|
177
|
+
if (!seen.has(id)) {
|
|
178
|
+
seen.add(id)
|
|
179
|
+
return whole
|
|
180
|
+
}
|
|
181
|
+
highest += 1
|
|
182
|
+
return `<p:cNvPr id="${highest}"`
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shapeIds(xml: string): Map<string, string> {
|
|
187
|
+
const ids = new Map<string, string>()
|
|
188
|
+
for (const [, id, name] of xml.matchAll(
|
|
189
|
+
/<p:cNvPr id="(\d+)" name="([^"]*)"/g,
|
|
190
|
+
)) {
|
|
191
|
+
if (id && name && !ids.has(name)) ids.set(name, id)
|
|
192
|
+
}
|
|
193
|
+
return ids
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
interface Click {
|
|
197
|
+
readonly preset: Preset
|
|
198
|
+
readonly duration: number
|
|
199
|
+
readonly spids: readonly string[]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function timingXml(clicks: readonly Click[]): string {
|
|
203
|
+
let counter = 2
|
|
204
|
+
const next = (): number => {
|
|
205
|
+
counter += 1
|
|
206
|
+
return counter
|
|
207
|
+
}
|
|
208
|
+
const pars = clicks.map((click) => {
|
|
209
|
+
const outer = next()
|
|
210
|
+
const inner = next()
|
|
211
|
+
const effects = click.spids.map((spid, index) => {
|
|
212
|
+
const nodeType = index === 0 ? 'clickEffect' : 'withEffect'
|
|
213
|
+
const effect = next()
|
|
214
|
+
const visible = `<p:set><p:cBhvr><p:cTn id="${next()}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn>${target(spid)}<p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set>`
|
|
215
|
+
const behaviors = click.preset.behaviors(spid, click.duration, next)
|
|
216
|
+
return `<p:par><p:cTn id="${effect}" presetID="${click.preset.id}" presetClass="entr" presetSubtype="${click.preset.subtype}" fill="hold" nodeType="${nodeType}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${visible}${behaviors}</p:childTnLst></p:cTn></p:par>`
|
|
217
|
+
})
|
|
218
|
+
return `<p:par><p:cTn id="${outer}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="${inner}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>${effects.join('')}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>`
|
|
219
|
+
})
|
|
220
|
+
return `<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>${pars.join('')}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Declared order first, then document order for ties and for no order. */
|
|
224
|
+
function ordered(entrances: readonly EntranceSpec[]): EntranceSpec[] {
|
|
225
|
+
const rank = (entrance: EntranceSpec): number => {
|
|
226
|
+
const order = Number.parseFloat(entrance.order ?? '')
|
|
227
|
+
return Number.isFinite(order) ? order : Number.POSITIVE_INFINITY
|
|
228
|
+
}
|
|
229
|
+
return entrances.toSorted((a, b) => rank(a) - rank(b))
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Inserts the transition and an on-click main sequence after the slide's color
|
|
234
|
+
* map override, where the schema orders them. Each entrance is one click, and
|
|
235
|
+
* every shape drawn from its element comes in on that click together. A slide
|
|
236
|
+
* missing the anchor is returned unchanged rather than patched elsewhere.
|
|
237
|
+
*/
|
|
238
|
+
export function addMotion(xml: string, motion: SlideMotion): MotionResult {
|
|
239
|
+
const notices: string[] = []
|
|
240
|
+
let transition = ''
|
|
241
|
+
if (motion.transition) {
|
|
242
|
+
const element = TRANSITIONS[motion.transition.effect]
|
|
243
|
+
if (element) {
|
|
244
|
+
const speed = speedOf(millisecondsOf(motion.transition.duration))
|
|
245
|
+
transition = `<p:transition spd="${speed}">${element}</p:transition>`
|
|
246
|
+
} else {
|
|
247
|
+
notices.push(
|
|
248
|
+
`unknown transition ${motion.transition.effect}. Use ${list(Object.keys(TRANSITIONS))}`,
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const numbered = uniqueIds(xml)
|
|
254
|
+
const ids = shapeIds(numbered)
|
|
255
|
+
const clicks: Click[] = []
|
|
256
|
+
for (const entrance of ordered(motion.entrances)) {
|
|
257
|
+
const preset = ENTRANCES[entrance.effect]
|
|
258
|
+
if (!preset) {
|
|
259
|
+
notices.push(
|
|
260
|
+
`${entrance.selector}: unknown entrance ${entrance.effect}. Use ${list(Object.keys(ENTRANCES))}`,
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
const spids = entrance.shapes.flatMap((name) => {
|
|
265
|
+
const id = ids.get(name)
|
|
266
|
+
return id ? [id] : []
|
|
267
|
+
})
|
|
268
|
+
if (spids.length === 0) {
|
|
269
|
+
notices.push(
|
|
270
|
+
`${entrance.selector}: draws no shape, so its entrance has nothing to animate`,
|
|
271
|
+
)
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
const duration = millisecondsOf(entrance.duration) ?? DEFAULT_ENTRANCE_MS
|
|
275
|
+
clicks.push({ preset, duration, spids })
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const timing = clicks.length > 0 ? timingXml(clicks) : ''
|
|
279
|
+
if (!transition && !timing) return { xml, notices }
|
|
280
|
+
if (!xml.includes(ANCHOR)) {
|
|
281
|
+
return {
|
|
282
|
+
xml,
|
|
283
|
+
notices: [
|
|
284
|
+
...notices,
|
|
285
|
+
`no ${ANCHOR} to anchor motion after, so none was written`,
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
xml: (timing ? numbered : xml).replace(
|
|
291
|
+
ANCHOR,
|
|
292
|
+
`${ANCHOR}${transition}${timing}`,
|
|
293
|
+
),
|
|
294
|
+
notices,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Writes each slide's motion into the opened package and returns its notices. */
|
|
299
|
+
export async function writeMotion(
|
|
300
|
+
zip: JSZip,
|
|
301
|
+
edits: readonly SlideMotionEdit[],
|
|
302
|
+
): Promise<MotionNotice[]> {
|
|
303
|
+
const notices: MotionNotice[] = []
|
|
304
|
+
for (const { slide, motion } of edits) {
|
|
305
|
+
const path = `ppt/slides/slide${slide}.xml`
|
|
306
|
+
const xml = await zip.file(path)?.async('string')
|
|
307
|
+
if (xml === undefined) {
|
|
308
|
+
notices.push({ slide, message: `no ${path} in the written deck` })
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
const result = addMotion(xml, motion)
|
|
312
|
+
if (result.xml !== xml) zip.file(path, result.xml)
|
|
313
|
+
notices.push(...result.notices.map((message) => ({ slide, message })))
|
|
314
|
+
}
|
|
315
|
+
return notices
|
|
316
|
+
}
|