@erclx/canon 5.18.0 → 5.20.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/README.md +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/create-skill/SKILL.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +1 -1
- package/claude/skills/plan-groundwork/SKILL.md +1 -1
- package/claude/skills/record-screencast/REQUIREMENT.md +2 -1
- package/claude/skills/record-screencast/SKILL.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -3
- package/claude/skills/session-resume/SKILL.md +1 -1
- package/claude/skills/task-board/REQUIREMENT.md +1 -0
- package/claude/skills/task-board/SKILL.md +6 -0
- package/claude/skills/task-board/references/decline.md +2 -0
- package/claude/skills/video-craft/REQUIREMENT.md +48 -0
- package/claude/skills/video-craft/SKILL.md +80 -0
- package/claude/skills/video-craft/references/adopted.md +66 -0
- package/docs/agents/canvas.md +4 -0
- package/docs/agents/commands.md +1 -0
- package/docs/agents/demo.md +1 -1
- package/docs/agents/install-and-sync.md +17 -3
- package/docs/agents/rule-citations.md +4 -4
- package/docs/agents/sandbox.md +8 -7
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/operating-model.md +9 -9
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map.md +1 -0
- package/governance/renames.toml +24 -0
- package/governance/rules/{canon/640-architecture.md → standards/architecture.md} +3 -2
- package/governance/rules/standards/context.md +15 -0
- package/governance/rules/standards/design.md +13 -0
- package/governance/rules/standards/diagrams.md +17 -0
- package/governance/rules/standards/groundwork.md +13 -0
- package/governance/rules/standards/intake.md +12 -0
- package/governance/rules/standards/markdown.md +15 -0
- package/governance/rules/standards/memory.md +12 -0
- package/governance/rules/standards/mermaid.md +15 -0
- package/governance/rules/{canon/658-plan.md → standards/plan.md} +5 -9
- package/governance/rules/standards/readme.md +12 -0
- package/governance/rules/standards/ready.md +13 -0
- package/governance/rules/standards/requirements.md +13 -0
- package/governance/rules/standards/rule.md +13 -0
- package/governance/rules/standards/session.md +13 -0
- package/governance/rules/{claude/570-skill.md → standards/skill.md} +6 -10
- package/governance/rules/standards/standard.md +13 -0
- package/governance/rules/standards/tasks.md +15 -0
- package/governance/rules/{canon/661-teach.md → standards/teach.md} +3 -2
- package/governance/rules/standards/wiki.md +13 -0
- package/governance/rules/standards/wireframes.md +14 -0
- package/governance/rules/writing/800-prose.md +1 -1
- package/governance/stacks/base.toml +4 -2
- package/package.json +2 -1
- package/src/canvas/capture.ts +228 -15
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/routing.ts +1 -1
- package/src/commands/canvas.ts +76 -2
- package/src/commands/gov.ts +19 -4
- package/src/commands/sandbox.ts +7 -0
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/gate/stages.ts +7 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/standard-rules.ts +179 -0
- package/src/sandbox/equivalence.ts +43 -14
- package/src/sandbox/stub-remote.ts +140 -0
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +5 -0
- package/standards/context.md +6 -0
- package/standards/design.md +3 -0
- package/standards/diagrams.md +8 -0
- package/standards/groundwork-spikes.md +66 -0
- package/standards/groundwork.md +5 -25
- package/standards/index.md +6 -2
- package/standards/intake.md +2 -0
- package/standards/markdown.md +6 -0
- package/standards/memory.md +2 -0
- package/standards/mermaid.md +6 -0
- package/standards/plan-lifecycle.md +36 -0
- package/standards/plan.md +13 -11
- package/standards/readme.md +2 -0
- package/standards/ready.md +4 -0
- package/standards/requirements.md +3 -0
- package/standards/rule.md +6 -1
- package/standards/session.md +4 -0
- package/standards/skill-paths.md +50 -0
- package/standards/skill.md +15 -23
- package/standards/standard.md +7 -1
- package/standards/tasks-decline.md +38 -0
- package/standards/tasks.md +8 -13
- package/standards/teach.md +5 -0
- package/standards/wiki.md +4 -0
- package/standards/wireframes.md +5 -0
- package/governance/rules/canon/610-context.md +0 -17
- package/governance/rules/canon/620-wireframes.md +0 -13
- package/governance/rules/canon/630-requirements.md +0 -12
- package/governance/rules/canon/650-design.md +0 -12
- package/governance/rules/canon/655-tasks.md +0 -13
- package/governance/rules/canon/656-groundwork.md +0 -11
- package/governance/rules/canon/657-intake.md +0 -11
- package/governance/rules/canon/659-memory.md +0 -11
- package/governance/rules/canon/660-diagrams.md +0 -19
- package/governance/rules/canon/662-session.md +0 -15
- package/governance/rules/canon/663-ready.md +0 -15
- package/governance/rules/claude/590-rule-authoring.md +0 -12
- package/governance/rules/writing/801-markdown.md +0 -14
- package/governance/rules/writing/802-mermaid.md +0 -14
- package/governance/rules/writing/880-readme.md +0 -11
- package/governance/rules/writing/891-standard-authoring.md +0 -12
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
import type PptxGenJS from 'pptxgenjs'
|
|
2
|
+
import { resolveSvgColors, type SvgColors } from '@/slides/convert/svg'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The contract between the walk, which runs inside the laid-out page, and the
|
|
6
|
+
* mapping below, which runs here. Every record is plain data, since it crosses
|
|
7
|
+
* `page.evaluate`. Lengths are CSS pixels and colors are already normalized by
|
|
8
|
+
* the browser, so nothing on this side parses a color the page computed a second time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Rgba {
|
|
12
|
+
/** Six hex digits, no leading `#`, the form pptxgenjs takes. */
|
|
13
|
+
readonly hex: string
|
|
14
|
+
readonly alpha: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Rect {
|
|
18
|
+
readonly x: number
|
|
19
|
+
readonly y: number
|
|
20
|
+
readonly w: number
|
|
21
|
+
readonly h: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Edges {
|
|
25
|
+
readonly top: number
|
|
26
|
+
readonly right: number
|
|
27
|
+
readonly bottom: number
|
|
28
|
+
readonly left: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ShadowRecord {
|
|
32
|
+
readonly x: number
|
|
33
|
+
readonly y: number
|
|
34
|
+
readonly blur: number
|
|
35
|
+
readonly color: Rgba
|
|
36
|
+
readonly inset: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Computed values of the properties no rule here maps, read as written. */
|
|
40
|
+
export interface RawStyle {
|
|
41
|
+
readonly backgroundImage: string
|
|
42
|
+
readonly transform: string
|
|
43
|
+
readonly filter: string
|
|
44
|
+
readonly clipPath: string
|
|
45
|
+
readonly mask: string
|
|
46
|
+
readonly mixBlendMode: string
|
|
47
|
+
readonly backdropFilter: string
|
|
48
|
+
/** The four-side shorthands, which say whether the sides agree. */
|
|
49
|
+
readonly borderWidth: string
|
|
50
|
+
readonly borderColor: string
|
|
51
|
+
readonly borderStyle: string
|
|
52
|
+
readonly borderRadius: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BoxStyle {
|
|
56
|
+
readonly background: Rgba
|
|
57
|
+
readonly borderWidth: Edges
|
|
58
|
+
/** The top edge's color and style. A border whose sides differ is unmapped. */
|
|
59
|
+
readonly borderColor: Rgba
|
|
60
|
+
readonly borderStyle: string
|
|
61
|
+
/** The top-left radius. Corners that differ are unmapped. */
|
|
62
|
+
readonly radius: number
|
|
63
|
+
readonly padding: Edges
|
|
64
|
+
readonly shadows: readonly ShadowRecord[]
|
|
65
|
+
readonly raw: RawStyle
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TextRun {
|
|
69
|
+
readonly text: string
|
|
70
|
+
readonly color: Rgba
|
|
71
|
+
readonly fontSize: number
|
|
72
|
+
readonly fontWeight: number
|
|
73
|
+
readonly italic: boolean
|
|
74
|
+
readonly underline: boolean
|
|
75
|
+
readonly fontFamily: string
|
|
76
|
+
/** Zero where the computed value is `normal`. */
|
|
77
|
+
readonly letterSpacing: number
|
|
78
|
+
/** The background of the inline element carrying the run, where it paints one. */
|
|
79
|
+
readonly highlight?: Rgba
|
|
80
|
+
readonly href?: string
|
|
81
|
+
/** Set on the run a `<br>` precedes. */
|
|
82
|
+
readonly breakBefore?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ListMarker {
|
|
86
|
+
/** The computed `list-style-type`, such as `disc` or `decimal`. */
|
|
87
|
+
readonly style: string
|
|
88
|
+
/** One for a top-level item. */
|
|
89
|
+
readonly depth: number
|
|
90
|
+
/** The item's position in its list, counted from one. */
|
|
91
|
+
readonly ordinal: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TextContent {
|
|
95
|
+
readonly runs: readonly TextRun[]
|
|
96
|
+
readonly align: string
|
|
97
|
+
readonly lineHeight: number
|
|
98
|
+
readonly fontSize: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface TableCellRecord extends TextContent {
|
|
102
|
+
readonly box: Rect
|
|
103
|
+
readonly colspan: number
|
|
104
|
+
readonly rowspan: number
|
|
105
|
+
readonly style: BoxStyle
|
|
106
|
+
readonly verticalAlign: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface RecordBase {
|
|
110
|
+
readonly id: number
|
|
111
|
+
readonly parent: number | null
|
|
112
|
+
readonly selector: string
|
|
113
|
+
/** The layout box before any transform, in page coordinates. */
|
|
114
|
+
readonly box: Rect
|
|
115
|
+
/** What the element covers on screen, which a screenshot of it spans. */
|
|
116
|
+
readonly bounds: Rect
|
|
117
|
+
readonly style: BoxStyle
|
|
118
|
+
/** The element's rendered text, which a picture standing in for it carries. */
|
|
119
|
+
readonly text: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type ElementRecord =
|
|
123
|
+
| (RecordBase & { readonly kind: 'box' })
|
|
124
|
+
| (RecordBase & {
|
|
125
|
+
readonly kind: 'text'
|
|
126
|
+
readonly content: TextContent
|
|
127
|
+
readonly list?: ListMarker
|
|
128
|
+
/** The style of each inline element the runs were gathered through. */
|
|
129
|
+
readonly inlineStyles: readonly BoxStyle[]
|
|
130
|
+
})
|
|
131
|
+
| (RecordBase & {
|
|
132
|
+
readonly kind: 'image'
|
|
133
|
+
readonly src: string
|
|
134
|
+
readonly alt: string
|
|
135
|
+
})
|
|
136
|
+
| (RecordBase & {
|
|
137
|
+
readonly kind: 'svg'
|
|
138
|
+
readonly markup: string
|
|
139
|
+
readonly colors: SvgColors
|
|
140
|
+
readonly alt: string
|
|
141
|
+
})
|
|
142
|
+
| (RecordBase & {
|
|
143
|
+
readonly kind: 'table'
|
|
144
|
+
readonly rows: readonly (readonly TableCellRecord[])[]
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const PX_PER_INCH = 96
|
|
148
|
+
const PT_PER_PX = 0.75
|
|
149
|
+
|
|
150
|
+
const inches = (px: number): number => px / PX_PER_INCH
|
|
151
|
+
const points = (px: number): number => px * PT_PER_PX
|
|
152
|
+
|
|
153
|
+
function position(box: Rect): PptxGenJS.PositionProps {
|
|
154
|
+
return {
|
|
155
|
+
x: inches(box.x),
|
|
156
|
+
y: inches(box.y),
|
|
157
|
+
w: inches(box.w),
|
|
158
|
+
h: inches(box.h),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The CSS this converter has no shape for. An element computing any of them
|
|
164
|
+
* becomes one picture of itself rather than a shape that drops the effect.
|
|
165
|
+
* `box-shadow` flags a second shadow, since the first one maps, and `border`
|
|
166
|
+
* and `border-radius` flag sides or corners that differ from each other.
|
|
167
|
+
*/
|
|
168
|
+
export const UNMAPPED_PROPERTIES = [
|
|
169
|
+
'background-image',
|
|
170
|
+
'transform',
|
|
171
|
+
'filter',
|
|
172
|
+
'clip-path',
|
|
173
|
+
'mask',
|
|
174
|
+
'mix-blend-mode',
|
|
175
|
+
'backdrop-filter',
|
|
176
|
+
'box-shadow',
|
|
177
|
+
'border',
|
|
178
|
+
'border-radius',
|
|
179
|
+
] as const
|
|
180
|
+
|
|
181
|
+
export type UnmappedProperty = (typeof UNMAPPED_PROPERTIES)[number]
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A computed transform is a matrix, and only rotation survives into a shape.
|
|
185
|
+
* Translation is already in the box the browser reported, so it maps too.
|
|
186
|
+
* Anything scaling or skewing returns `undefined`.
|
|
187
|
+
*/
|
|
188
|
+
export function rotationOf(transform: string): number | undefined {
|
|
189
|
+
if (transform === 'none') return 0
|
|
190
|
+
const match = /^matrix\(([^)]+)\)$/.exec(transform)
|
|
191
|
+
if (!match?.[1]) return undefined
|
|
192
|
+
const [a = 0, b = 0, c = 0, d = 0] = match[1].split(',').map(Number)
|
|
193
|
+
const isRotation =
|
|
194
|
+
Math.abs(a - d) < 1e-3 &&
|
|
195
|
+
Math.abs(b + c) < 1e-3 &&
|
|
196
|
+
Math.abs(a * a + b * b - 1) < 1e-3
|
|
197
|
+
if (!isRotation) return undefined
|
|
198
|
+
const degrees = (Math.atan2(b, a) * 180) / Math.PI
|
|
199
|
+
return Math.round(((degrees % 360) + 360) % 360)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A computed shorthand's top, right, bottom, and left values. */
|
|
203
|
+
function fourSides(value: string): string[] {
|
|
204
|
+
const tokens: string[] = []
|
|
205
|
+
let depth = 0
|
|
206
|
+
let current = ''
|
|
207
|
+
for (const char of value.trim()) {
|
|
208
|
+
if (char === '(') depth += 1
|
|
209
|
+
if (char === ')') depth -= 1
|
|
210
|
+
if (char === ' ' && depth === 0) {
|
|
211
|
+
if (current) tokens.push(current)
|
|
212
|
+
current = ''
|
|
213
|
+
} else {
|
|
214
|
+
current += char
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (current) tokens.push(current)
|
|
218
|
+
const [top = '', right = top, bottom = top, left = right] = tokens
|
|
219
|
+
return [top, right, bottom, left]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const isSame = (values: readonly string[]): boolean => new Set(values).size <= 1
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A shape takes one line for all four edges, so a border mapping needs every
|
|
226
|
+
* drawn side alike and no side left off. A table cell writes each edge's width
|
|
227
|
+
* on its own, so only the color and style of its drawn sides have to agree.
|
|
228
|
+
*/
|
|
229
|
+
function isBorderUnmapped(raw: RawStyle, isCell: boolean): boolean {
|
|
230
|
+
const widths = fourSides(raw.borderWidth)
|
|
231
|
+
const colors = fourSides(raw.borderColor)
|
|
232
|
+
const styles = fourSides(raw.borderStyle)
|
|
233
|
+
const drawn = [0, 1, 2, 3].filter(
|
|
234
|
+
(side) =>
|
|
235
|
+
Number.parseFloat(widths[side] ?? '0') > 0 &&
|
|
236
|
+
styles[side] !== 'none' &&
|
|
237
|
+
styles[side] !== 'hidden',
|
|
238
|
+
)
|
|
239
|
+
if (drawn.length === 0) return false
|
|
240
|
+
const pick = (values: string[]): string[] =>
|
|
241
|
+
drawn.map((side) => values[side] ?? '')
|
|
242
|
+
const agree = isSame(pick(colors)) && isSame(pick(styles))
|
|
243
|
+
if (isCell) return !agree
|
|
244
|
+
return drawn.length < 4 || !agree || !isSame(pick(widths))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isUnmapped(
|
|
248
|
+
style: BoxStyle,
|
|
249
|
+
property: UnmappedProperty,
|
|
250
|
+
isCell: boolean,
|
|
251
|
+
): boolean {
|
|
252
|
+
const { raw, shadows } = style
|
|
253
|
+
const found: Record<UnmappedProperty, boolean> = {
|
|
254
|
+
'background-image': raw.backgroundImage !== 'none',
|
|
255
|
+
transform: rotationOf(raw.transform) === undefined,
|
|
256
|
+
filter: raw.filter !== 'none',
|
|
257
|
+
'clip-path': raw.clipPath !== 'none',
|
|
258
|
+
mask: raw.mask !== 'none',
|
|
259
|
+
'mix-blend-mode': raw.mixBlendMode !== 'normal',
|
|
260
|
+
'backdrop-filter': raw.backdropFilter !== 'none',
|
|
261
|
+
'box-shadow': shadows.length > 1,
|
|
262
|
+
border: isBorderUnmapped(raw, isCell),
|
|
263
|
+
'border-radius':
|
|
264
|
+
raw.borderRadius.includes('/') || !isSame(fourSides(raw.borderRadius)),
|
|
265
|
+
}
|
|
266
|
+
return found[property]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Every unmapped property the record computes, in the order listed above. A
|
|
271
|
+
* text block counts the inline elements its runs came from, and a table counts
|
|
272
|
+
* its cells, since neither is drawn as a shape of its own and a property on one
|
|
273
|
+
* would otherwise vanish unreported.
|
|
274
|
+
*/
|
|
275
|
+
export function unmappedProperties(record: ElementRecord): UnmappedProperty[] {
|
|
276
|
+
const styles = [
|
|
277
|
+
record.style,
|
|
278
|
+
...(record.kind === 'text' ? record.inlineStyles : []),
|
|
279
|
+
]
|
|
280
|
+
const cells =
|
|
281
|
+
record.kind === 'table' ? record.rows.flat().map((cell) => cell.style) : []
|
|
282
|
+
return UNMAPPED_PROPERTIES.filter(
|
|
283
|
+
(property) =>
|
|
284
|
+
styles.some((style) => isUnmapped(style, property, false)) ||
|
|
285
|
+
cells.some((style) => isUnmapped(style, property, true)),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function transparency(color: Rgba): number | undefined {
|
|
290
|
+
return color.alpha < 1 ? Math.round((1 - color.alpha) * 100) : undefined
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function fill(style: BoxStyle): PptxGenJS.ShapeFillProps | undefined {
|
|
294
|
+
if (style.background.alpha === 0) return undefined
|
|
295
|
+
return {
|
|
296
|
+
color: style.background.hex,
|
|
297
|
+
transparency: transparency(style.background),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function hasBorder(style: BoxStyle): boolean {
|
|
302
|
+
return (
|
|
303
|
+
style.borderWidth.top > 0 &&
|
|
304
|
+
style.borderStyle !== 'none' &&
|
|
305
|
+
style.borderColor.alpha > 0
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function line(style: BoxStyle): PptxGenJS.ShapeLineProps | undefined {
|
|
310
|
+
if (!hasBorder(style)) return undefined
|
|
311
|
+
return {
|
|
312
|
+
color: style.borderColor.hex,
|
|
313
|
+
width: points(style.borderWidth.top),
|
|
314
|
+
dashType: style.borderStyle === 'dashed' ? 'dash' : 'solid',
|
|
315
|
+
transparency: transparency(style.borderColor),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The first shadow only, since a second one is unmapped and sends the element
|
|
321
|
+
* to a picture before this runs. PowerPoint measures the direction clockwise
|
|
322
|
+
* from the right, which is the same sense as a CSS offset with y pointing down.
|
|
323
|
+
*/
|
|
324
|
+
export function shadowOf(style: BoxStyle): PptxGenJS.ShadowProps | undefined {
|
|
325
|
+
const [shadow] = style.shadows
|
|
326
|
+
if (!shadow || shadow.color.alpha === 0) return undefined
|
|
327
|
+
const angle = (Math.atan2(shadow.y, shadow.x) * 180) / Math.PI
|
|
328
|
+
return {
|
|
329
|
+
type: shadow.inset ? 'inner' : 'outer',
|
|
330
|
+
offset: points(Math.hypot(shadow.x, shadow.y)),
|
|
331
|
+
angle: Math.round((angle + 360) % 360),
|
|
332
|
+
blur: points(shadow.blur),
|
|
333
|
+
color: shadow.color.hex,
|
|
334
|
+
opacity: shadow.color.alpha,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function geometry(style: BoxStyle): {
|
|
339
|
+
shape: PptxGenJS.SHAPE_NAME
|
|
340
|
+
rectRadius?: number
|
|
341
|
+
} {
|
|
342
|
+
if (style.radius <= 0) return { shape: 'rect' }
|
|
343
|
+
return { shape: 'roundRect', rectRadius: inches(style.radius) }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The first family the page asked for, which is the name PowerPoint looks up.
|
|
348
|
+
* A variable face installs under its plain family name.
|
|
349
|
+
*/
|
|
350
|
+
export function fontFace(fontFamily: string): string {
|
|
351
|
+
const first = fontFamily.split(',')[0] ?? ''
|
|
352
|
+
return first
|
|
353
|
+
.trim()
|
|
354
|
+
.replace(/^['"]|['"]$/g, '')
|
|
355
|
+
.replace(/ Variable$/, '')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A link refused rather than written, with the reason the report prints. */
|
|
359
|
+
export interface RefusedLink {
|
|
360
|
+
readonly selector: string
|
|
361
|
+
readonly href: string
|
|
362
|
+
readonly reason: string
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface LinkContext {
|
|
366
|
+
readonly slideCount: number
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
type LinkResult =
|
|
370
|
+
| { readonly kind: 'link'; readonly hyperlink: PptxGenJS.HyperlinkProps }
|
|
371
|
+
| { readonly kind: 'refused'; readonly reason: string }
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* `#slide-N` jumps to a slide in this deck and an absolute URL opens outside
|
|
375
|
+
* it. A slide number past the deck's end, or a target in neither form, is
|
|
376
|
+
* refused rather than written as a link that goes nowhere.
|
|
377
|
+
*/
|
|
378
|
+
export function linkOf(href: string, context: LinkContext): LinkResult {
|
|
379
|
+
const slide = /^#slide-(\d+)$/.exec(href)
|
|
380
|
+
if (slide?.[1]) {
|
|
381
|
+
const number = Number(slide[1])
|
|
382
|
+
if (number >= 1 && number <= context.slideCount) {
|
|
383
|
+
return { kind: 'link', hyperlink: { slide: number } }
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
kind: 'refused',
|
|
387
|
+
reason: `slide ${number} is past the deck's ${context.slideCount}`,
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (/^(https?:|mailto:)/i.test(href)) {
|
|
391
|
+
return { kind: 'link', hyperlink: { url: href } }
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
kind: 'refused',
|
|
395
|
+
reason: 'not a #slide-N anchor or an absolute URL',
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function runProps(
|
|
400
|
+
run: TextRun,
|
|
401
|
+
context: LinkContext,
|
|
402
|
+
selector: string,
|
|
403
|
+
refused: RefusedLink[],
|
|
404
|
+
): PptxGenJS.TextProps {
|
|
405
|
+
const options: PptxGenJS.TextPropsOptions = {
|
|
406
|
+
color: run.color.hex,
|
|
407
|
+
fontSize: points(run.fontSize),
|
|
408
|
+
fontFace: fontFace(run.fontFamily),
|
|
409
|
+
bold: run.fontWeight >= 600,
|
|
410
|
+
italic: run.italic,
|
|
411
|
+
}
|
|
412
|
+
if (run.underline) options.underline = { style: 'sng' }
|
|
413
|
+
if (run.letterSpacing !== 0) options.charSpacing = points(run.letterSpacing)
|
|
414
|
+
if (run.highlight) options.highlight = run.highlight.hex
|
|
415
|
+
if (run.breakBefore) options.softBreakBefore = true
|
|
416
|
+
if (run.href) {
|
|
417
|
+
const link = linkOf(run.href, context)
|
|
418
|
+
if (link.kind === 'link') options.hyperlink = link.hyperlink
|
|
419
|
+
else refused.push({ selector, href: run.href, reason: link.reason })
|
|
420
|
+
}
|
|
421
|
+
return { text: run.text, options }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const ALIGN: Readonly<Record<string, PptxGenJS.HAlign>> = {
|
|
425
|
+
left: 'left',
|
|
426
|
+
start: 'left',
|
|
427
|
+
center: 'center',
|
|
428
|
+
right: 'right',
|
|
429
|
+
end: 'right',
|
|
430
|
+
justify: 'justify',
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const MARKERS: Readonly<Record<string, string>> = {
|
|
434
|
+
disc: '2022',
|
|
435
|
+
circle: '25E6',
|
|
436
|
+
square: '25AA',
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The glyph a browser draws at each depth when the list names no style. */
|
|
440
|
+
const DEPTH_MARKERS = ['2022', '25E6', '25AA'] as const
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* An outside marker sits left of the item's box, so the text box widens left
|
|
444
|
+
* by the gap and the bullet indent takes it back. One em is the gap Chromium
|
|
445
|
+
* leaves for a disc and for a one-digit number alike.
|
|
446
|
+
*/
|
|
447
|
+
function bulletOf(
|
|
448
|
+
list: ListMarker,
|
|
449
|
+
fontSize: number,
|
|
450
|
+
): { bullet: PptxGenJS.TextPropsOptions['bullet']; gap: number } {
|
|
451
|
+
const gap = fontSize
|
|
452
|
+
const indent = points(gap)
|
|
453
|
+
if (list.style === 'decimal') {
|
|
454
|
+
return {
|
|
455
|
+
bullet: {
|
|
456
|
+
type: 'number',
|
|
457
|
+
numberType: 'arabicPeriod',
|
|
458
|
+
numberStartAt: list.ordinal,
|
|
459
|
+
indent,
|
|
460
|
+
},
|
|
461
|
+
gap,
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const characterCode =
|
|
465
|
+
MARKERS[list.style] ??
|
|
466
|
+
DEPTH_MARKERS[Math.min(list.depth, DEPTH_MARKERS.length) - 1] ??
|
|
467
|
+
DEPTH_MARKERS[0]
|
|
468
|
+
return { bullet: { characterCode, indent }, gap }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* PowerPoint sets an exact line spacing with the whole leading above the first
|
|
473
|
+
* line, where the browser splits it above and below. Lifting the text by half
|
|
474
|
+
* the leading puts the first baseline where the browser drew it.
|
|
475
|
+
*/
|
|
476
|
+
export function halfLeading(content: TextContent): number {
|
|
477
|
+
return Math.max(0, (content.lineHeight - content.fontSize) / 2)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** A shape, a picture, or a table, in the order the slide stacks them. */
|
|
481
|
+
export type DrawOp =
|
|
482
|
+
| {
|
|
483
|
+
readonly kind: 'shape'
|
|
484
|
+
readonly shape: PptxGenJS.SHAPE_NAME
|
|
485
|
+
readonly options: PptxGenJS.ShapeProps
|
|
486
|
+
}
|
|
487
|
+
| {
|
|
488
|
+
readonly kind: 'text'
|
|
489
|
+
readonly runs: PptxGenJS.TextProps[]
|
|
490
|
+
readonly options: PptxGenJS.TextPropsOptions
|
|
491
|
+
}
|
|
492
|
+
| {
|
|
493
|
+
readonly kind: 'image'
|
|
494
|
+
readonly src: string
|
|
495
|
+
readonly options: PptxGenJS.ImageProps
|
|
496
|
+
/** Corner radius in inches, which pptxgenjs cannot write on a picture. */
|
|
497
|
+
readonly radius: number
|
|
498
|
+
/** The border, drawn as an unfilled shape over the picture. */
|
|
499
|
+
readonly frame?: PptxGenJS.ShapeProps
|
|
500
|
+
}
|
|
501
|
+
| {
|
|
502
|
+
readonly kind: 'svg'
|
|
503
|
+
readonly id: number
|
|
504
|
+
readonly markup: string
|
|
505
|
+
readonly options: PptxGenJS.ImageProps
|
|
506
|
+
}
|
|
507
|
+
| {
|
|
508
|
+
readonly kind: 'table'
|
|
509
|
+
readonly rows: PptxGenJS.TableRow[]
|
|
510
|
+
readonly options: PptxGenJS.TableProps
|
|
511
|
+
}
|
|
512
|
+
| {
|
|
513
|
+
readonly kind: 'fallback'
|
|
514
|
+
readonly id: number
|
|
515
|
+
readonly options: PptxGenJS.ImageProps
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export interface Fallback {
|
|
519
|
+
readonly id: number
|
|
520
|
+
readonly selector: string
|
|
521
|
+
readonly properties: readonly UnmappedProperty[]
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export interface SlidePlan {
|
|
525
|
+
readonly ops: readonly DrawOp[]
|
|
526
|
+
readonly fallbacks: readonly Fallback[]
|
|
527
|
+
readonly refusedLinks: readonly RefusedLink[]
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function boxOp(record: ElementRecord): DrawOp | undefined {
|
|
531
|
+
const { style } = record
|
|
532
|
+
const shapeFill = fill(style)
|
|
533
|
+
const shapeLine = line(style)
|
|
534
|
+
const shadow = shadowOf(style)
|
|
535
|
+
if (!shapeFill && !shapeLine && !shadow) return undefined
|
|
536
|
+
const { shape, rectRadius } = geometry(style)
|
|
537
|
+
return {
|
|
538
|
+
kind: 'shape',
|
|
539
|
+
shape,
|
|
540
|
+
options: {
|
|
541
|
+
...position(record.box),
|
|
542
|
+
fill: shapeFill ?? { type: 'none' },
|
|
543
|
+
line: shapeLine ?? { type: 'none' },
|
|
544
|
+
rectRadius,
|
|
545
|
+
shadow,
|
|
546
|
+
rotate: rotationOf(style.raw.transform) || undefined,
|
|
547
|
+
},
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* The inset carries the padding and the border width together, since the text
|
|
553
|
+
* box spans the border box and the line is drawn on its edge. pptxgenjs reads a
|
|
554
|
+
* text margin as left, right, bottom, top, in points.
|
|
555
|
+
*/
|
|
556
|
+
function textOp(
|
|
557
|
+
record: Extract<ElementRecord, { kind: 'text' }>,
|
|
558
|
+
context: LinkContext,
|
|
559
|
+
refused: RefusedLink[],
|
|
560
|
+
): DrawOp {
|
|
561
|
+
const { style, content, list } = record
|
|
562
|
+
const runs = content.runs.map((run) =>
|
|
563
|
+
runProps(run, context, record.selector, refused),
|
|
564
|
+
)
|
|
565
|
+
const lift = halfLeading(content)
|
|
566
|
+
const inset = {
|
|
567
|
+
top: style.padding.top + style.borderWidth.top - lift,
|
|
568
|
+
right: style.padding.right + style.borderWidth.right,
|
|
569
|
+
bottom: style.padding.bottom + style.borderWidth.bottom,
|
|
570
|
+
left: style.padding.left + style.borderWidth.left,
|
|
571
|
+
}
|
|
572
|
+
const bullet = list ? bulletOf(list, content.fontSize) : undefined
|
|
573
|
+
const gap = bullet?.gap ?? 0
|
|
574
|
+
const box = {
|
|
575
|
+
x: record.box.x - gap,
|
|
576
|
+
y: record.box.y + Math.min(0, inset.top),
|
|
577
|
+
w: record.box.w + gap,
|
|
578
|
+
h: record.box.h,
|
|
579
|
+
}
|
|
580
|
+
const { shape, rectRadius } = geometry(style)
|
|
581
|
+
return {
|
|
582
|
+
kind: 'text',
|
|
583
|
+
runs,
|
|
584
|
+
options: {
|
|
585
|
+
...position(box),
|
|
586
|
+
shape,
|
|
587
|
+
rectRadius,
|
|
588
|
+
fill: fill(style),
|
|
589
|
+
line: line(style),
|
|
590
|
+
shadow: shadowOf(style),
|
|
591
|
+
margin: [
|
|
592
|
+
points(inset.left),
|
|
593
|
+
points(inset.right),
|
|
594
|
+
points(inset.bottom),
|
|
595
|
+
points(Math.max(0, inset.top)),
|
|
596
|
+
],
|
|
597
|
+
valign: 'top',
|
|
598
|
+
align: ALIGN[content.align] ?? 'left',
|
|
599
|
+
lineSpacing: points(content.lineHeight),
|
|
600
|
+
bullet: bullet?.bullet,
|
|
601
|
+
rotate: rotationOf(style.raw.transform) || undefined,
|
|
602
|
+
fit: 'none',
|
|
603
|
+
},
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function imageOp(record: Extract<ElementRecord, { kind: 'image' }>): DrawOp {
|
|
608
|
+
const { style } = record
|
|
609
|
+
const frameLine = line(style)
|
|
610
|
+
const { rectRadius } = geometry(style)
|
|
611
|
+
return {
|
|
612
|
+
kind: 'image',
|
|
613
|
+
src: record.src,
|
|
614
|
+
radius: rectRadius ?? 0,
|
|
615
|
+
options: {
|
|
616
|
+
...position(record.box),
|
|
617
|
+
altText: record.alt,
|
|
618
|
+
shadow: shadowOf(style),
|
|
619
|
+
rotate: rotationOf(style.raw.transform) || undefined,
|
|
620
|
+
},
|
|
621
|
+
frame: frameLine
|
|
622
|
+
? {
|
|
623
|
+
...position(record.box),
|
|
624
|
+
fill: { type: 'none' },
|
|
625
|
+
line: frameLine,
|
|
626
|
+
rectRadius,
|
|
627
|
+
}
|
|
628
|
+
: undefined,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function svgOp(record: Extract<ElementRecord, { kind: 'svg' }>): DrawOp {
|
|
633
|
+
return {
|
|
634
|
+
kind: 'svg',
|
|
635
|
+
id: record.id,
|
|
636
|
+
markup: resolveSvgColors(record.markup, record.colors),
|
|
637
|
+
options: { ...position(record.box), altText: record.alt || record.text },
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Columns and rows are the distinct cell edges the browser laid out, so a
|
|
643
|
+
* spanning cell lands on the boundaries its neighbors share. pptxgenjs reads a
|
|
644
|
+
* row as the cells that start in it, which is the order the walk emits.
|
|
645
|
+
*/
|
|
646
|
+
function tableOp(
|
|
647
|
+
record: Extract<ElementRecord, { kind: 'table' }>,
|
|
648
|
+
context: LinkContext,
|
|
649
|
+
refused: RefusedLink[],
|
|
650
|
+
): DrawOp {
|
|
651
|
+
const cells = record.rows.flat()
|
|
652
|
+
const edges = (values: number[]): number[] =>
|
|
653
|
+
[...new Set(values.map((value) => Math.round(value)))].sort((a, b) => a - b)
|
|
654
|
+
const columns = edges(
|
|
655
|
+
cells.flatMap((cell) => [cell.box.x, cell.box.x + cell.box.w]),
|
|
656
|
+
)
|
|
657
|
+
const rowEdges = edges(
|
|
658
|
+
cells.flatMap((cell) => [cell.box.y, cell.box.y + cell.box.h]),
|
|
659
|
+
)
|
|
660
|
+
const spans = (list: number[]): number[] =>
|
|
661
|
+
list.slice(1).map((edge, index) => inches(edge - (list[index] ?? edge)))
|
|
662
|
+
|
|
663
|
+
const rows = record.rows.map((row) =>
|
|
664
|
+
row.map((cell): PptxGenJS.TableCell => {
|
|
665
|
+
const border = (width: number): PptxGenJS.BorderProps =>
|
|
666
|
+
width > 0 && cell.style.borderStyle !== 'none'
|
|
667
|
+
? {
|
|
668
|
+
type: 'solid',
|
|
669
|
+
pt: points(width),
|
|
670
|
+
color: cell.style.borderColor.hex,
|
|
671
|
+
}
|
|
672
|
+
: { type: 'none' }
|
|
673
|
+
const cellFill = fill(cell.style)
|
|
674
|
+
return {
|
|
675
|
+
text: cell.runs.map((run) =>
|
|
676
|
+
runProps(run, context, record.selector, refused),
|
|
677
|
+
),
|
|
678
|
+
options: {
|
|
679
|
+
colspan: cell.colspan > 1 ? cell.colspan : undefined,
|
|
680
|
+
rowspan: cell.rowspan > 1 ? cell.rowspan : undefined,
|
|
681
|
+
fill: cellFill,
|
|
682
|
+
align: ALIGN[cell.align] ?? 'left',
|
|
683
|
+
valign:
|
|
684
|
+
cell.verticalAlign === 'top'
|
|
685
|
+
? 'top'
|
|
686
|
+
: cell.verticalAlign === 'bottom'
|
|
687
|
+
? 'bottom'
|
|
688
|
+
: 'middle',
|
|
689
|
+
margin: [
|
|
690
|
+
inches(cell.style.padding.top),
|
|
691
|
+
inches(cell.style.padding.right),
|
|
692
|
+
inches(cell.style.padding.bottom),
|
|
693
|
+
inches(cell.style.padding.left),
|
|
694
|
+
],
|
|
695
|
+
border: [
|
|
696
|
+
border(cell.style.borderWidth.top),
|
|
697
|
+
border(cell.style.borderWidth.right),
|
|
698
|
+
border(cell.style.borderWidth.bottom),
|
|
699
|
+
border(cell.style.borderWidth.left),
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
}
|
|
703
|
+
}),
|
|
704
|
+
)
|
|
705
|
+
const first = columns[0] ?? record.box.x
|
|
706
|
+
const top = rowEdges[0] ?? record.box.y
|
|
707
|
+
return {
|
|
708
|
+
kind: 'table',
|
|
709
|
+
rows,
|
|
710
|
+
options: {
|
|
711
|
+
x: inches(first),
|
|
712
|
+
y: inches(top),
|
|
713
|
+
w: inches((columns.at(-1) ?? first) - first),
|
|
714
|
+
colW: spans(columns),
|
|
715
|
+
rowH: spans(rowEdges),
|
|
716
|
+
},
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Maps one slide's records in document order. A fallback element drops every
|
|
722
|
+
* record beneath it, since its picture already shows them, while its parent and
|
|
723
|
+
* siblings stay native.
|
|
724
|
+
*/
|
|
725
|
+
export function planSlide(
|
|
726
|
+
records: readonly ElementRecord[],
|
|
727
|
+
context: LinkContext,
|
|
728
|
+
): SlidePlan {
|
|
729
|
+
const ops: DrawOp[] = []
|
|
730
|
+
const fallbacks: Fallback[] = []
|
|
731
|
+
const refusedLinks: RefusedLink[] = []
|
|
732
|
+
const dropped = new Set<number>()
|
|
733
|
+
|
|
734
|
+
for (const record of records) {
|
|
735
|
+
if (record.parent !== null && dropped.has(record.parent)) {
|
|
736
|
+
dropped.add(record.id)
|
|
737
|
+
continue
|
|
738
|
+
}
|
|
739
|
+
const properties = unmappedProperties(record)
|
|
740
|
+
if (properties.length > 0) {
|
|
741
|
+
dropped.add(record.id)
|
|
742
|
+
fallbacks.push({ id: record.id, selector: record.selector, properties })
|
|
743
|
+
ops.push({
|
|
744
|
+
kind: 'fallback',
|
|
745
|
+
id: record.id,
|
|
746
|
+
options: { ...position(record.bounds), altText: record.text },
|
|
747
|
+
})
|
|
748
|
+
continue
|
|
749
|
+
}
|
|
750
|
+
const op =
|
|
751
|
+
record.kind === 'box'
|
|
752
|
+
? boxOp(record)
|
|
753
|
+
: record.kind === 'text'
|
|
754
|
+
? textOp(record, context, refusedLinks)
|
|
755
|
+
: record.kind === 'image'
|
|
756
|
+
? imageOp(record)
|
|
757
|
+
: record.kind === 'svg'
|
|
758
|
+
? svgOp(record)
|
|
759
|
+
: tableOp(record, context, refusedLinks)
|
|
760
|
+
if (op) ops.push(op)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return { ops, fallbacks, refusedLinks }
|
|
764
|
+
}
|