@erclx/canon 5.20.0 → 5.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/verb-absent.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/document-health/REQUIREMENT.md +1 -1
- package/claude/skills/draft-ready/REQUIREMENT.md +2 -1
- package/claude/skills/draft-ready/SKILL.md +6 -4
- package/claude/skills/markdown-propose/REQUIREMENT.md +3 -3
- package/claude/skills/markdown-propose/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -0
- package/docs/agents/index.md +1 -0
- package/docs/agents/markdown-audit.md +1 -3
- package/docs/agents/slides.md +120 -0
- package/docs/agents/tasks-validate.md +1 -1
- package/docs/agents/tasks.md +1 -1
- package/docs/target/migrations.md +1 -1
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/skill-map.md +11 -13
- package/governance/rules/lib/330-testing-py.md +1 -1
- package/governance/rules/lib/336-testing-php.md +1 -1
- package/package.json +1 -1
- package/src/claude/cases/workflow.ts +4 -20
- package/src/commands/slides.ts +13 -3
- package/src/commands/tasks.ts +46 -2
- package/src/migrate/skill-names.ts +0 -7
- package/src/slides/convert/chart.ts +165 -0
- package/src/slides/convert/deck.ts +164 -0
- package/src/slides/convert/export.ts +217 -7
- package/src/slides/convert/master.ts +195 -0
- package/src/slides/convert/shapes.ts +1 -1
- package/src/slides/convert/walk.ts +187 -2
- package/src/tasks/record.ts +11 -1
- package/src/tasks/validate.ts +14 -0
- package/standards/tasks.md +1 -1
- package/tooling/astro/configs/card-src/pages/og-card.astro +2 -2
- package/claude/skills/docs-fold/REQUIREMENT.md +0 -25
- package/claude/skills/docs-fold/SKILL.md +0 -9
- package/claude/skills/standards-audit/REQUIREMENT.md +0 -33
- package/claude/skills/standards-audit/SKILL.md +0 -99
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type PptxGenJS from 'pptxgenjs'
|
|
2
|
+
import { position, type Rect } from '@/slides/convert/shapes'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A `<figure data-chart="...">` holding a `<table>` lands as a native chart.
|
|
6
|
+
* The table stays the page's accessible source and is not also drawn. Its
|
|
7
|
+
* header row names the series and its first column names the categories.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CHART_TYPES = ['bar', 'line', 'area', 'pie', 'doughnut'] as const
|
|
11
|
+
export type ChartType = (typeof CHART_TYPES)[number]
|
|
12
|
+
|
|
13
|
+
export interface ChartRecord {
|
|
14
|
+
readonly selector: string
|
|
15
|
+
readonly box: Rect
|
|
16
|
+
/** The raw `data-chart` value, checked against `CHART_TYPES` here. */
|
|
17
|
+
readonly type: string
|
|
18
|
+
readonly hasLabels: boolean
|
|
19
|
+
/** The figure's `<figcaption>`, when it has one. */
|
|
20
|
+
readonly title?: string
|
|
21
|
+
readonly rows: readonly (readonly string[])[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ChartTheme {
|
|
25
|
+
/** Series colors in order, repeated past the last. */
|
|
26
|
+
readonly colors: readonly string[]
|
|
27
|
+
readonly ink: string
|
|
28
|
+
readonly muted: string
|
|
29
|
+
readonly face: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChartSeries {
|
|
33
|
+
readonly name: string
|
|
34
|
+
readonly labels: readonly string[]
|
|
35
|
+
/** `null` is an empty cell, which the chart draws as a gap. */
|
|
36
|
+
readonly values: readonly (number | null)[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ChartPlan =
|
|
40
|
+
| {
|
|
41
|
+
readonly status: 'chart'
|
|
42
|
+
readonly type: ChartType
|
|
43
|
+
readonly data: readonly ChartSeries[]
|
|
44
|
+
readonly options: PptxGenJS.IChartOpts
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly status: 'refused'
|
|
48
|
+
readonly selector: string
|
|
49
|
+
readonly message: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ColorRole {
|
|
53
|
+
readonly name: string
|
|
54
|
+
readonly hex: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The grounds a series would vanish into. */
|
|
58
|
+
const GROUND_ROLES = new Set(['--color-background', '--color-surface'])
|
|
59
|
+
|
|
60
|
+
const NUMBER = /^-?(\d+\.?\d*|\.\d+)$/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A comma counts as a thousands separator only where it groups in threes, so a
|
|
64
|
+
* comma-decimal `3,5` refuses rather than reading as 35.
|
|
65
|
+
*/
|
|
66
|
+
const GROUPED = /^-?\d{1,3}(,\d{3})+(\.\d+)?$/
|
|
67
|
+
|
|
68
|
+
const isChartType = (value: string): value is ChartType =>
|
|
69
|
+
(CHART_TYPES as readonly string[]).includes(value)
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The accent, then every other declared color role in token order except the
|
|
73
|
+
* grounds, each color once. No project declares a chart palette, so the
|
|
74
|
+
* project's own roles stand in for one.
|
|
75
|
+
*/
|
|
76
|
+
export function seriesColors(
|
|
77
|
+
accent: string,
|
|
78
|
+
roles: readonly ColorRole[],
|
|
79
|
+
): string[] {
|
|
80
|
+
const colors = [accent]
|
|
81
|
+
for (const role of roles) {
|
|
82
|
+
if (GROUND_ROLES.has(role.name) || colors.includes(role.hex)) continue
|
|
83
|
+
colors.push(role.hex)
|
|
84
|
+
}
|
|
85
|
+
return colors
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cycle(colors: readonly string[], count: number): string[] {
|
|
89
|
+
return Array.from(
|
|
90
|
+
{ length: count },
|
|
91
|
+
(_, index) => colors[index % colors.length] ?? '',
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function planChart(record: ChartRecord, theme: ChartTheme): ChartPlan {
|
|
96
|
+
const refuse = (message: string): ChartPlan => ({
|
|
97
|
+
status: 'refused',
|
|
98
|
+
selector: record.selector,
|
|
99
|
+
message,
|
|
100
|
+
})
|
|
101
|
+
if (!isChartType(record.type)) {
|
|
102
|
+
return refuse(
|
|
103
|
+
`data-chart "${record.type}" is not one of ${CHART_TYPES.join(', ')}`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
const [header = [], ...body] = record.rows
|
|
107
|
+
if (header.length < 2 || body.length === 0) {
|
|
108
|
+
return refuse(
|
|
109
|
+
'the table needs a header row and at least one data row of two cells',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const labels = body.map((row) => row[0] ?? '')
|
|
114
|
+
const data: ChartSeries[] = []
|
|
115
|
+
for (let column = 1; column < header.length; column += 1) {
|
|
116
|
+
const values: (number | null)[] = []
|
|
117
|
+
for (const [index, row] of body.entries()) {
|
|
118
|
+
const text = (row[column] ?? '').trim()
|
|
119
|
+
if (text === '') {
|
|
120
|
+
values.push(null)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
const bare = GROUPED.test(text) ? text.replace(/,/g, '') : text
|
|
124
|
+
if (!NUMBER.test(bare)) {
|
|
125
|
+
return refuse(
|
|
126
|
+
`cell "${text}" at row ${index + 2}, column ${column + 1} is not a number`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
values.push(Number(bare))
|
|
130
|
+
}
|
|
131
|
+
data.push({ name: header[column] ?? '', labels, values })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isRound = record.type === 'pie' || record.type === 'doughnut'
|
|
135
|
+
return {
|
|
136
|
+
status: 'chart',
|
|
137
|
+
type: record.type,
|
|
138
|
+
data,
|
|
139
|
+
options: {
|
|
140
|
+
...position(record.box),
|
|
141
|
+
chartColors: cycle(theme.colors, isRound ? labels.length : data.length),
|
|
142
|
+
displayBlanksAs: 'gap',
|
|
143
|
+
showValue: record.hasLabels,
|
|
144
|
+
dataLabelColor: theme.ink,
|
|
145
|
+
dataLabelFontFace: theme.face,
|
|
146
|
+
showLegend: isRound || data.length > 1,
|
|
147
|
+
legendPos: 'b',
|
|
148
|
+
legendColor: theme.ink,
|
|
149
|
+
legendFontFace: theme.face,
|
|
150
|
+
catAxisLabelColor: theme.muted,
|
|
151
|
+
catAxisLabelFontFace: theme.face,
|
|
152
|
+
valAxisLabelColor: theme.muted,
|
|
153
|
+
valAxisLabelFontFace: theme.face,
|
|
154
|
+
...(record.title
|
|
155
|
+
? {
|
|
156
|
+
showTitle: true,
|
|
157
|
+
title: record.title,
|
|
158
|
+
titleColor: theme.ink,
|
|
159
|
+
titleFontFace: theme.face,
|
|
160
|
+
altText: record.title,
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What belongs to the deck rather than to one slide, read from `deck.json`
|
|
6
|
+
* beside the slides. A slide overrides a band through attributes on its own
|
|
7
|
+
* `<body>`, which `walk.ts` reads.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DECK_FILE = 'deck.json'
|
|
11
|
+
|
|
12
|
+
export const BAND_SLOTS = ['left', 'center', 'right'] as const
|
|
13
|
+
export type BandSlot = (typeof BAND_SLOTS)[number]
|
|
14
|
+
|
|
15
|
+
export interface Band {
|
|
16
|
+
readonly show: boolean
|
|
17
|
+
readonly left?: string
|
|
18
|
+
readonly center?: string
|
|
19
|
+
readonly right?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DeckConfig {
|
|
23
|
+
readonly title: string
|
|
24
|
+
readonly header: Band
|
|
25
|
+
readonly footer: Band
|
|
26
|
+
/** Drawn at the right of the footer, so they show only where it does. */
|
|
27
|
+
readonly slideNumbers: boolean
|
|
28
|
+
/** Absolute, resolved against the deck folder. */
|
|
29
|
+
readonly mark?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type DeckRead =
|
|
33
|
+
| { readonly status: 'read'; readonly deck: DeckConfig }
|
|
34
|
+
| {
|
|
35
|
+
readonly status: 'refused'
|
|
36
|
+
readonly field: string
|
|
37
|
+
readonly message: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DECK_FIELDS = new Set([
|
|
41
|
+
'title',
|
|
42
|
+
'header',
|
|
43
|
+
'footer',
|
|
44
|
+
'slideNumbers',
|
|
45
|
+
'mark',
|
|
46
|
+
])
|
|
47
|
+
const BAND_FIELDS = new Set<string>(['show', ...BAND_SLOTS])
|
|
48
|
+
|
|
49
|
+
class FieldError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
readonly field: string,
|
|
52
|
+
message: string,
|
|
53
|
+
) {
|
|
54
|
+
super(message)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
59
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
60
|
+
|
|
61
|
+
function rejectUnknown(
|
|
62
|
+
record: Record<string, unknown>,
|
|
63
|
+
known: ReadonlySet<string>,
|
|
64
|
+
prefix: string,
|
|
65
|
+
): void {
|
|
66
|
+
const unknown = Object.keys(record).find((key) => !known.has(key))
|
|
67
|
+
if (unknown !== undefined) {
|
|
68
|
+
throw new FieldError(
|
|
69
|
+
`${prefix}${unknown}`,
|
|
70
|
+
`unknown field ${prefix}${unknown}`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function stringField(value: unknown, field: string): string | undefined {
|
|
76
|
+
if (value === undefined) return undefined
|
|
77
|
+
if (typeof value !== 'string') {
|
|
78
|
+
throw new FieldError(field, `${field} must be a string`)
|
|
79
|
+
}
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function booleanField(value: unknown, field: string): boolean | undefined {
|
|
84
|
+
if (value === undefined) return undefined
|
|
85
|
+
if (typeof value !== 'boolean') {
|
|
86
|
+
throw new FieldError(field, `${field} must be true or false`)
|
|
87
|
+
}
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A declared band merges over its default, so `{ "center": "x" }` keeps the
|
|
93
|
+
* footer's title on the left. Declaring any text turns a band on unless it
|
|
94
|
+
* also says `show: false`.
|
|
95
|
+
*/
|
|
96
|
+
function band(value: unknown, field: string, fallback: Band): Band {
|
|
97
|
+
if (value === undefined) return fallback
|
|
98
|
+
if (!isRecord(value)) {
|
|
99
|
+
throw new FieldError(field, `${field} must be an object`)
|
|
100
|
+
}
|
|
101
|
+
rejectUnknown(value, BAND_FIELDS, `${field}.`)
|
|
102
|
+
const slots: { -readonly [slot in BandSlot]?: string } = {}
|
|
103
|
+
for (const slot of BAND_SLOTS) {
|
|
104
|
+
const text = stringField(value[slot], `${field}.${slot}`)
|
|
105
|
+
if (text !== undefined) slots[slot] = text
|
|
106
|
+
}
|
|
107
|
+
const show =
|
|
108
|
+
booleanField(value.show, `${field}.show`) ??
|
|
109
|
+
(Object.keys(slots).length > 0 || fallback.show)
|
|
110
|
+
return { ...fallback, ...slots, show }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parse(raw: unknown, folder: string): DeckConfig {
|
|
114
|
+
if (!isRecord(raw)) {
|
|
115
|
+
throw new FieldError(DECK_FILE, `${DECK_FILE} must hold an object`)
|
|
116
|
+
}
|
|
117
|
+
rejectUnknown(raw, DECK_FIELDS, '')
|
|
118
|
+
const title = stringField(raw.title, 'title') ?? basename(folder)
|
|
119
|
+
const header = band(raw.header, 'header', { show: false })
|
|
120
|
+
const footer = band(raw.footer, 'footer', { show: true, left: title })
|
|
121
|
+
const slideNumbers = booleanField(raw.slideNumbers, 'slideNumbers') ?? true
|
|
122
|
+
if (slideNumbers && footer.right !== undefined) {
|
|
123
|
+
throw new FieldError(
|
|
124
|
+
'footer.right',
|
|
125
|
+
'footer.right is where slide numbers sit. Set slideNumbers to false to use it',
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
const markPath = stringField(raw.mark, 'mark')
|
|
129
|
+
let mark: string | undefined
|
|
130
|
+
if (markPath !== undefined) {
|
|
131
|
+
mark = join(folder, markPath)
|
|
132
|
+
if (!existsSync(mark) || !statSync(mark).isFile()) {
|
|
133
|
+
throw new FieldError('mark', `mark ${mark} does not exist`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { title, header, footer, slideNumbers, mark }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readDeck(folder: string): DeckRead {
|
|
140
|
+
const path = join(folder, DECK_FILE)
|
|
141
|
+
let raw: unknown = {}
|
|
142
|
+
if (existsSync(path)) {
|
|
143
|
+
try {
|
|
144
|
+
raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
147
|
+
return {
|
|
148
|
+
status: 'refused',
|
|
149
|
+
field: DECK_FILE,
|
|
150
|
+
message: `${path}: not valid JSON (${reason})`,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
return { status: 'read', deck: parse(raw, folder) }
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!(error instanceof FieldError)) throw error
|
|
158
|
+
return {
|
|
159
|
+
status: 'refused',
|
|
160
|
+
field: error.field,
|
|
161
|
+
message: `${path}: ${error.message}`,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs'
|
|
2
8
|
import { basename, join } from 'node:path'
|
|
3
9
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
4
10
|
import JSZip from 'jszip'
|
|
@@ -6,6 +12,22 @@ import type { Browser, Page } from 'playwright-core'
|
|
|
6
12
|
import PptxGenJS from 'pptxgenjs'
|
|
7
13
|
import { isBrowserMissing, isEngineMissing } from '@/browser/engine'
|
|
8
14
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
15
|
+
import {
|
|
16
|
+
type ChartTheme,
|
|
17
|
+
planChart,
|
|
18
|
+
seriesColors,
|
|
19
|
+
} from '@/slides/convert/chart'
|
|
20
|
+
import { type DeckConfig, readDeck } from '@/slides/convert/deck'
|
|
21
|
+
import {
|
|
22
|
+
bandTexts,
|
|
23
|
+
buildMaster,
|
|
24
|
+
type DeckTheme,
|
|
25
|
+
deckTheme,
|
|
26
|
+
type MarkSize,
|
|
27
|
+
type MasterBands,
|
|
28
|
+
masterName,
|
|
29
|
+
slideNumberProps,
|
|
30
|
+
} from '@/slides/convert/master'
|
|
9
31
|
import {
|
|
10
32
|
type DrawOp,
|
|
11
33
|
type Fallback,
|
|
@@ -13,7 +35,10 @@ import {
|
|
|
13
35
|
type RefusedLink,
|
|
14
36
|
} from '@/slides/convert/shapes'
|
|
15
37
|
import {
|
|
38
|
+
type BandOverride,
|
|
16
39
|
ID_ATTRIBUTE,
|
|
40
|
+
readTheme,
|
|
41
|
+
type SlideMeta,
|
|
17
42
|
type WalkedSlide,
|
|
18
43
|
walkSlide,
|
|
19
44
|
} from '@/slides/convert/walk'
|
|
@@ -22,7 +47,8 @@ import {
|
|
|
22
47
|
* Turns a folder of HTML slides into one editable deck. Chromium lays each
|
|
23
48
|
* slide out at 1280 by 720 in the project's tokens, `walkSlide` reads what it
|
|
24
49
|
* placed, `planSlide` maps the records onto shapes, and an element whose CSS has
|
|
25
|
-
* no mapping lands as a screenshot of itself.
|
|
50
|
+
* no mapping lands as a screenshot of itself. The first slide's computed tokens
|
|
51
|
+
* build the masters, and `deck.json` sets the bands they carry.
|
|
26
52
|
*
|
|
27
53
|
* Every browser reference this feature adds lives here, and the command reaches
|
|
28
54
|
* it through a dynamic import so no other command resolves the engine.
|
|
@@ -45,8 +71,16 @@ export interface SlideRefusedLink extends RefusedLink {
|
|
|
45
71
|
readonly slide: number
|
|
46
72
|
}
|
|
47
73
|
|
|
74
|
+
/** A chart left out of the deck, with the reason the report prints. */
|
|
75
|
+
export interface RefusedChart {
|
|
76
|
+
readonly slide: number
|
|
77
|
+
readonly selector: string
|
|
78
|
+
readonly message: string
|
|
79
|
+
}
|
|
80
|
+
|
|
48
81
|
export type ExportRefusal =
|
|
49
82
|
| 'empty-source'
|
|
83
|
+
| 'deck-invalid'
|
|
50
84
|
| 'engine-missing'
|
|
51
85
|
| 'browser-missing'
|
|
52
86
|
| 'export-failed'
|
|
@@ -59,6 +93,9 @@ export type ExportResult =
|
|
|
59
93
|
readonly slideCount: number
|
|
60
94
|
readonly fallbacks: readonly SlideFallback[]
|
|
61
95
|
readonly refusedLinks: readonly SlideRefusedLink[]
|
|
96
|
+
readonly refusedCharts: readonly RefusedChart[]
|
|
97
|
+
/** What the deck master could not take from the project's tokens. */
|
|
98
|
+
readonly notices: readonly string[]
|
|
62
99
|
}
|
|
63
100
|
| {
|
|
64
101
|
readonly status: 'refused'
|
|
@@ -102,6 +139,11 @@ export async function exportHtmlDeck(
|
|
|
102
139
|
if (files.length === 0) {
|
|
103
140
|
return refused('empty-source', `${sourceDir} holds no .html slides`)
|
|
104
141
|
}
|
|
142
|
+
const deckRead = readDeck(sourceDir)
|
|
143
|
+
if (deckRead.status === 'refused') {
|
|
144
|
+
return refused('deck-invalid', deckRead.message)
|
|
145
|
+
}
|
|
146
|
+
const deck = deckRead.deck
|
|
105
147
|
|
|
106
148
|
let chromium: typeof import('playwright-core').chromium
|
|
107
149
|
try {
|
|
@@ -125,15 +167,35 @@ export async function exportHtmlDeck(
|
|
|
125
167
|
const tokens = resolveFrameTokens(options.root).css
|
|
126
168
|
const pptx = new PptxGenJS()
|
|
127
169
|
pptx.layout = 'LAYOUT_WIDE'
|
|
170
|
+
pptx.title = deck.title
|
|
128
171
|
const fallbacks: SlideFallback[] = []
|
|
129
172
|
const refusedLinks: SlideRefusedLink[] = []
|
|
173
|
+
const refusedCharts: RefusedChart[] = []
|
|
130
174
|
const patches: Patch[] = []
|
|
175
|
+
const notices: string[] = []
|
|
131
176
|
|
|
132
177
|
try {
|
|
133
178
|
const page = await browser.newPage({
|
|
134
179
|
viewport: { width: SLIDE_WIDTH, height: SLIDE_HEIGHT },
|
|
135
180
|
deviceScaleFactor: SCREENSHOT_SCALE,
|
|
136
181
|
})
|
|
182
|
+
await prepare(page, files[0] ?? '', tokens)
|
|
183
|
+
const reading = await page.evaluate(readTheme)
|
|
184
|
+
const read = deckTheme(reading)
|
|
185
|
+
const theme = read.theme
|
|
186
|
+
notices.push(...read.notices)
|
|
187
|
+
const drifted: number[] = []
|
|
188
|
+
const chartTheme: ChartTheme = {
|
|
189
|
+
colors: seriesColors(theme.accent, reading.roles),
|
|
190
|
+
ink: theme.ink,
|
|
191
|
+
muted: theme.muted,
|
|
192
|
+
face: theme.face,
|
|
193
|
+
}
|
|
194
|
+
pptx.theme = { headFontFace: theme.face, bodyFontFace: theme.face }
|
|
195
|
+
const markSize = deck.mark ? await measure(page, deck.mark) : undefined
|
|
196
|
+
const masters = new Set<string>()
|
|
197
|
+
const sections = new Sections(deck.title, declaresSections(files))
|
|
198
|
+
|
|
137
199
|
for (const [index, file] of files.entries()) {
|
|
138
200
|
const slideNumber = index + 1
|
|
139
201
|
const walked = await layOut(page, file, tokens)
|
|
@@ -144,12 +206,48 @@ export async function exportHtmlDeck(
|
|
|
144
206
|
refusedLinks.push(
|
|
145
207
|
...plan.refusedLinks.map((each) => ({ ...each, slide: slideNumber })),
|
|
146
208
|
)
|
|
147
|
-
const
|
|
148
|
-
|
|
209
|
+
const { textToken } = walked.meta
|
|
210
|
+
if (textToken !== undefined && textToken !== theme.ink) {
|
|
211
|
+
drifted.push(slideNumber)
|
|
212
|
+
}
|
|
213
|
+
// pptxgenjs binds a slide to its master at creation, so the bands this
|
|
214
|
+
// slide keeps decide the master before any shape is added.
|
|
215
|
+
const bands = masterBands(deck, walked.meta)
|
|
216
|
+
const master = masterName(bands)
|
|
217
|
+
if (!masters.has(master)) {
|
|
218
|
+
pptx.defineSlideMaster(buildMaster(deck, theme, bands, markSize))
|
|
219
|
+
masters.add(master)
|
|
220
|
+
}
|
|
221
|
+
const sectionTitle = sections.titleFor(walked.meta, pptx)
|
|
222
|
+
const slide = pptx.addSlide({
|
|
223
|
+
masterName: master,
|
|
224
|
+
...(sectionTitle ? { sectionTitle } : {}),
|
|
225
|
+
})
|
|
226
|
+
if (walked.background.hex !== theme.background) {
|
|
227
|
+
slide.background = { color: walked.background.hex }
|
|
228
|
+
}
|
|
229
|
+
if (walked.meta.isHidden) slide.hidden = true
|
|
149
230
|
for (const [opIndex, op] of plan.ops.entries()) {
|
|
150
231
|
const name = `canon-${slideNumber}-${opIndex + 1}`
|
|
151
232
|
await draw(page, slide, { slide: slideNumber, name }, op, patches)
|
|
152
233
|
}
|
|
234
|
+
for (const record of walked.charts) {
|
|
235
|
+
const chart = planChart(record, chartTheme)
|
|
236
|
+
if (chart.status === 'refused') {
|
|
237
|
+
refusedCharts.push({ ...chart, slide: slideNumber })
|
|
238
|
+
continue
|
|
239
|
+
}
|
|
240
|
+
slide.addChart(chart.type, [...chart.data], chart.options)
|
|
241
|
+
}
|
|
242
|
+
drawOverrides(slide, deck, theme, walked.meta)
|
|
243
|
+
if (walked.meta.notes) slide.addNotes(walked.meta.notes)
|
|
244
|
+
}
|
|
245
|
+
if (drifted.length > 0) {
|
|
246
|
+
const noun = drifted.length === 1 ? 'slide' : 'slides'
|
|
247
|
+
const verb = drifted.length === 1 ? 'sets' : 'set'
|
|
248
|
+
notices.push(
|
|
249
|
+
`${noun} ${drifted.join(', ')} ${verb} a --color-text apart from the master, so ${drifted.length === 1 ? 'its' : 'their'} bands keep the master colors`,
|
|
250
|
+
)
|
|
153
251
|
}
|
|
154
252
|
} catch (error) {
|
|
155
253
|
return refused(
|
|
@@ -164,12 +262,12 @@ export async function exportHtmlDeck(
|
|
|
164
262
|
if (!(written instanceof Uint8Array)) {
|
|
165
263
|
return refused('export-failed', 'pptxgenjs returned no buffer')
|
|
166
264
|
}
|
|
167
|
-
const
|
|
265
|
+
const patched = await applyPatches(written, patches)
|
|
168
266
|
|
|
169
267
|
mkdirSync(outDir, { recursive: true })
|
|
170
268
|
const fileName = `${basename(sourceDir)}.pptx`
|
|
171
269
|
const pptxPath = join(outDir, fileName)
|
|
172
|
-
writeFileSync(pptxPath,
|
|
270
|
+
writeFileSync(pptxPath, patched)
|
|
173
271
|
|
|
174
272
|
let mirrorPath: string | undefined
|
|
175
273
|
if (options.mirror) {
|
|
@@ -185,9 +283,113 @@ export async function exportHtmlDeck(
|
|
|
185
283
|
slideCount: files.length,
|
|
186
284
|
fallbacks,
|
|
187
285
|
refusedLinks,
|
|
286
|
+
refusedCharts,
|
|
287
|
+
notices,
|
|
188
288
|
}
|
|
189
289
|
}
|
|
190
290
|
|
|
291
|
+
/**
|
|
292
|
+
* A band stays on the master unless the deck has it off anyway, so a slide
|
|
293
|
+
* hiding only the header keeps the master's footer and numbers.
|
|
294
|
+
*/
|
|
295
|
+
function masterBands(deck: DeckConfig, meta: SlideMeta): MasterBands {
|
|
296
|
+
return {
|
|
297
|
+
header: deck.header.show && meta.header.kind === 'master',
|
|
298
|
+
footer: deck.footer.show && meta.footer.kind === 'master',
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
type Override = Extract<BandOverride, { readonly kind: 'override' }>
|
|
303
|
+
|
|
304
|
+
/** The slide's slots over the deck's, so overriding the center keeps the title. */
|
|
305
|
+
function mergeBand(override: Override, base: DeckConfig['header']) {
|
|
306
|
+
return {
|
|
307
|
+
left: override.left ?? base.left,
|
|
308
|
+
center: override.center ?? base.center,
|
|
309
|
+
right: override.right ?? base.right,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function drawOverrides(
|
|
314
|
+
slide: PptxGenJS.Slide,
|
|
315
|
+
deck: DeckConfig,
|
|
316
|
+
theme: DeckTheme,
|
|
317
|
+
meta: SlideMeta,
|
|
318
|
+
): void {
|
|
319
|
+
if (meta.header.kind === 'override') {
|
|
320
|
+
const band = mergeBand(meta.header, deck.header)
|
|
321
|
+
const hasMark = deck.mark !== undefined
|
|
322
|
+
for (const text of bandTexts(band, 'header', theme, { hasMark })) {
|
|
323
|
+
slide.addText(text.text, text.options)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (meta.footer.kind === 'override') {
|
|
327
|
+
const band = mergeBand(meta.footer, deck.footer)
|
|
328
|
+
for (const text of bandTexts(band, 'footer', theme)) {
|
|
329
|
+
slide.addText(text.text, text.options)
|
|
330
|
+
}
|
|
331
|
+
if (deck.slideNumbers && band.right === undefined) {
|
|
332
|
+
slide.slideNumber = slideNumberProps(theme)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* A slide's `data-section` opens a section the slides after it stay in.
|
|
339
|
+
* PowerPoint wants every slide in a section once any exists, so slides ahead of
|
|
340
|
+
* the first declared one open a section named after the deck.
|
|
341
|
+
*
|
|
342
|
+
* pptxgenjs files a slide under the first section carrying its title, so a
|
|
343
|
+
* title the deck returns to takes a numbered suffix rather than sending the
|
|
344
|
+
* slide back into the earlier section, which PowerPoint reads as a broken list.
|
|
345
|
+
*/
|
|
346
|
+
export class Sections {
|
|
347
|
+
/** The title as declared, and as written once a repeat is numbered. */
|
|
348
|
+
private declared: string | undefined
|
|
349
|
+
private current: string | undefined
|
|
350
|
+
private readonly opened = new Map<string, number>()
|
|
351
|
+
|
|
352
|
+
constructor(
|
|
353
|
+
private readonly deckTitle: string,
|
|
354
|
+
private readonly isUsed: boolean,
|
|
355
|
+
) {}
|
|
356
|
+
|
|
357
|
+
titleFor(meta: SlideMeta, pptx: PptxGenJS): string | undefined {
|
|
358
|
+
if (!this.isUsed) return undefined
|
|
359
|
+
const isContinuing =
|
|
360
|
+
meta.section === undefined || meta.section === this.declared
|
|
361
|
+
if (isContinuing && this.current !== undefined) return this.current
|
|
362
|
+
const title = meta.section ?? this.deckTitle
|
|
363
|
+
const count = (this.opened.get(title) ?? 0) + 1
|
|
364
|
+
this.opened.set(title, count)
|
|
365
|
+
this.declared = title
|
|
366
|
+
this.current = count === 1 ? title : `${title} (${count})`
|
|
367
|
+
pptx.addSection({ title: this.current })
|
|
368
|
+
return this.current
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether any slide declares a section, read from the source ahead of layout,
|
|
374
|
+
* since the first slide is placed before the last is laid out.
|
|
375
|
+
*/
|
|
376
|
+
function declaresSections(files: readonly string[]): boolean {
|
|
377
|
+
return files.some((file) =>
|
|
378
|
+
/<body\b[^>]*\sdata-section\s*=/i.test(readFileSync(file, 'utf8')),
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The mark's intrinsic size as Chromium decodes it, square where unknown. */
|
|
383
|
+
async function measure(page: Page, path: string): Promise<MarkSize> {
|
|
384
|
+
const size = await page.evaluate(async (src) => {
|
|
385
|
+
const image = new Image()
|
|
386
|
+
image.src = src
|
|
387
|
+
await image.decode().catch(() => undefined)
|
|
388
|
+
return { width: image.naturalWidth, height: image.naturalHeight }
|
|
389
|
+
}, pathToFileURL(path).href)
|
|
390
|
+
return size.width > 0 && size.height > 0 ? size : { width: 1, height: 1 }
|
|
391
|
+
}
|
|
392
|
+
|
|
191
393
|
/**
|
|
192
394
|
* The tokens go in ahead of the slide's own styles, so a slide reads
|
|
193
395
|
* `var(--color-*)` from the project and can still override any of it.
|
|
@@ -197,6 +399,15 @@ async function layOut(
|
|
|
197
399
|
file: string,
|
|
198
400
|
tokens: string,
|
|
199
401
|
): Promise<WalkedSlide> {
|
|
402
|
+
await prepare(page, file, tokens)
|
|
403
|
+
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function prepare(
|
|
407
|
+
page: Page,
|
|
408
|
+
file: string,
|
|
409
|
+
tokens: string,
|
|
410
|
+
): Promise<void> {
|
|
200
411
|
await page.goto(pathToFileURL(file).href, { waitUntil: 'load' })
|
|
201
412
|
if (tokens) {
|
|
202
413
|
await page.evaluate((css) => {
|
|
@@ -206,7 +417,6 @@ async function layOut(
|
|
|
206
417
|
}, tokens)
|
|
207
418
|
}
|
|
208
419
|
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
|
209
|
-
return page.evaluate(walkSlide, ID_ATTRIBUTE)
|
|
210
420
|
}
|
|
211
421
|
|
|
212
422
|
async function screenshot(page: Page, id: number): Promise<Buffer> {
|