@erclx/canon 5.17.0 → 5.19.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 (51) hide show
  1. package/README.md +1 -1
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
  4. package/claude/skills/deploy-app/SKILL.md +45 -0
  5. package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
  6. package/claude/skills/deploy-app/references/vercel.md +72 -0
  7. package/docs/agents/architecture-record.md +12 -4
  8. package/docs/agents/canvas.md +13 -1
  9. package/docs/agents/commands.md +3 -2
  10. package/docs/agents/context-audit-checks.md +1 -1
  11. package/docs/agents/context-audit.md +4 -4
  12. package/docs/agents/index.md +2 -2
  13. package/docs/agents/teach.md +7 -1
  14. package/docs/workflow/skill-map.md +9 -9
  15. package/package.json +2 -1
  16. package/src/audits/catalog.ts +46 -1
  17. package/src/canvas/address.ts +73 -39
  18. package/src/canvas/client/inspector.tsx +241 -33
  19. package/src/canvas/client/pages-panel.tsx +51 -0
  20. package/src/canvas/client/shell.css +126 -1
  21. package/src/canvas/client/state.ts +120 -4
  22. package/src/canvas/client/theme-panel.tsx +55 -0
  23. package/src/canvas/content.ts +14 -3
  24. package/src/canvas/edit.ts +360 -0
  25. package/src/canvas/server.ts +50 -5
  26. package/src/canvas/tokens.ts +82 -0
  27. package/src/claude/cases/misc.ts +5 -1
  28. package/src/commands/canvas.ts +95 -1
  29. package/src/commands/context.ts +85 -5
  30. package/src/commands/slides.ts +73 -4
  31. package/src/commands/teach.ts +112 -4
  32. package/src/context/architecture.ts +98 -18
  33. package/src/context/gate.ts +30 -3
  34. package/src/context/requirements.ts +77 -0
  35. package/src/gate/measures.ts +69 -28
  36. package/src/gate/stages.ts +5 -5
  37. package/src/serve/live.ts +113 -0
  38. package/src/serve/static.ts +60 -2
  39. package/src/slides/convert/export.ts +323 -0
  40. package/src/slides/convert/shapes.ts +764 -0
  41. package/src/slides/convert/svg.ts +38 -0
  42. package/src/slides/convert/walk.ts +444 -0
  43. package/src/teach/browser/reload.ts +9 -0
  44. package/src/teach/nav.ts +7 -6
  45. package/src/teach/workspace.ts +2 -1
  46. package/src/teach/write-if-changed.ts +17 -0
  47. package/standards/architecture.md +13 -7
  48. package/standards/requirements.md +4 -0
  49. package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
  50. package/tooling/cloudflare/reference.md +1 -1
  51. package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
@@ -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
+ }