dsh-ppt 0.7.0 → 0.8.1

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 (37) hide show
  1. package/CHANGELOG.md +114 -91
  2. package/LICENSE +21 -21
  3. package/README.en.md +42 -219
  4. package/README.md +42 -234
  5. package/cordis.patch.yml +19 -19
  6. package/docs/CHANGELOG.en.md +29 -0
  7. package/docs/FEATURE-PLAN-2026-10-01.md +50 -50
  8. package/docs/LIBREOFFICE-INTEGRATION-2026-10-01.md +69 -69
  9. package/docs/USAGE.en.md +194 -0
  10. package/docs/USAGE.md +209 -0
  11. package/docs/VALIDATION.md +17 -0
  12. package/docs/validation/0.8.0.md +10 -0
  13. package/docs/validation-language-2026-10-03.md +16 -0
  14. package/lib/client.js +304 -0
  15. package/lib/execution.d.ts +1 -0
  16. package/lib/execution.js +1 -1
  17. package/lib/index.d.ts +1 -0
  18. package/lib/index.js +15 -1
  19. package/lib/tools.js +2 -1
  20. package/lib/types.d.ts +1 -0
  21. package/lib/workbench-language.d.ts +4 -0
  22. package/lib/workbench-language.js +65 -0
  23. package/lib/workbench.d.ts +32 -0
  24. package/lib/workbench.js +270 -0
  25. package/package.json +91 -84
  26. package/skills/dsh-ppt/SKILL.md +74 -74
  27. package/skills/dsh-ppt/package.json +10 -10
  28. package/skills/dsh-ppt/references/advanced.md +39 -39
  29. package/skills/dsh-ppt/references/copywriting.md +59 -59
  30. package/skills/dsh-ppt/references/syntax.md +44 -44
  31. package/skills/dsh-ppt/references/themes.md +61 -61
  32. package/skills/dsh-ppt/references/troubleshooting.md +19 -19
  33. package/skills/dsh-ppt/scripts/build-deck.mjs +184 -184
  34. package/skills/dsh-ppt/scripts/deck-advanced.mjs +468 -456
  35. package/skills/dsh-ppt/scripts/deck-core.mjs +2347 -2347
  36. package/skills/dsh-ppt/scripts/deck-media.mjs +160 -160
  37. package/skills/dsh-ppt/scripts/deck-render.mjs +270 -236
@@ -1,160 +1,160 @@
1
- // Shared geometry and chart data for the offline player and native PPTX export.
2
- export const MEDIA_LAYOUTS = new Set(['image', 'image-left', 'image-right', 'chart'])
3
- export const CANVAS = { w: 13.3333333333, h: 7.5 }
4
- export const LOGO_BOX = { x: 11.55, y: 0.12, w: 0.95, h: 0.42 }
5
- export const FOOTER_BOX = { x: 0.85, y: 6.95, w: 9.3, h: 0.3 }
6
-
7
- export function mediaBoxes(layout) {
8
- const title = { x: 0.85, y: 0.64, w: 11.6, h: 0.85 }
9
- const left = { x: 0.85, y: 1.85, w: 5.55, h: 4.65 }
10
- const right = { x: 6.88, y: 1.85, w: 5.6, h: 4.65 }
11
- const full = { x: 0.85, y: 1.85, w: 11.6, h: 4.65 }
12
- return { title, image: { ...(layout === 'image-left' ? left : layout === 'image-right' ? right : full), h: 4.45 },
13
- body: layout === 'image-left' ? right : layout === 'image-right' ? left : null,
14
- chart: { ...full, h: 4.45 }, caption: { x: 0.85, y: 6.38, w: 11.6, h: 0.35 } }
15
- }
16
-
17
- function numeric(value, page) {
18
- if (typeof value !== 'number' && !(typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))) {
19
- throw new Error(`dsh-ppt:第 ${page} 页图表有非数值数据:${String(value).slice(0, 40)}`)
20
- }
21
- const result = Number(value)
22
- if (!Number.isFinite(result)) throw new Error(`dsh-ppt:第 ${page} 页图表数值必须有限`)
23
- return result
24
- }
25
-
26
- export function normalizeChart(input, page = 1) {
27
- if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error(`dsh-ppt:第 ${page} 页缺少 chart 对象`)
28
- const kind = input.kind ?? 'column'
29
- if (!['column', 'bar', 'line', 'pie'].includes(kind)) throw new Error(`dsh-ppt:第 ${page} 页未知图表类型 ${kind}`)
30
- let categories = input.categories
31
- let series = input.series
32
- if (input.rows !== undefined) {
33
- const rows = input.rows
34
- if (!Array.isArray(rows) || rows.length < 2 || !Array.isArray(rows[0]) || rows[0].length < 2
35
- || rows.some(row => !Array.isArray(row) || row.length !== rows[0].length)) {
36
- throw new Error(`dsh-ppt:第 ${page} 页图表 rows 需包含表头、类别和数值列,各行列数一致`)
37
- }
38
- categories = rows.slice(1).map(row => row[0])
39
- series = rows[0].slice(1).map((name, column) => ({ name, values: rows.slice(1).map(row => row[column + 1]) }))
40
- }
41
- if (!Array.isArray(categories) || categories.length < 1 || categories.length > 24 || categories.some(c => String(c ?? '').trim() === '')) {
42
- throw new Error(`dsh-ppt:第 ${page} 页图表需 1–24 个非空类别`)
43
- }
44
- if (!Array.isArray(series) || series.length < 1 || series.length > 6) throw new Error(`dsh-ppt:第 ${page} 页图表需 1–6 个数据系列`)
45
- const clean = series.map((s, i) => {
46
- if (!s || !Array.isArray(s.values) || s.values.length !== categories.length) throw new Error(`dsh-ppt:第 ${page} 页图表第 ${i + 1} 个系列与类别数量不一致`)
47
- return { name: String(s.name ?? `系列 ${i + 1}`).trim() || `系列 ${i + 1}`, values: s.values.map(v => numeric(v, page)) }
48
- })
49
- if (kind === 'pie' && (clean.length !== 1 || categories.length > 12 || clean[0].values.some(v => v < 0) || clean[0].values.reduce((a, b) => a + b, 0) <= 0)) {
50
- throw new Error(`dsh-ppt:第 ${page} 页饼图只支持一个系列、最多 12 个类别,数值需非负且总和大于零`)
51
- }
52
- return { kind, categories: categories.map(c => String(c).trim()), series: clean,
53
- ...(input.unit ? { unit: String(input.unit) } : {}), ...(input.caption ? { caption: String(input.caption) } : {}) }
54
- }
55
-
56
- export function normalizeMediaSlide(source, normalized, index) {
57
- if (!MEDIA_LAYOUTS.has(normalized.layout)) return normalized
58
- if (normalized.layout === 'chart') return { ...normalized, chart: normalizeChart(source.chart, index + 1) }
59
- const image = typeof source.image === 'string' ? { src: source.image } : source.image
60
- if (!image || typeof image !== 'object' || (!image.src && !image.assetId)) throw new Error(`dsh-ppt:第 ${index + 1} 页缺少 image.src`)
61
- const fit = image.fit ?? 'contain'
62
- if (!['contain', 'cover'].includes(fit)) throw new Error(`dsh-ppt:第 ${index + 1} 页 image.fit 仅支持 contain / cover`)
63
- return { ...normalized, image: { ...(image.src ? { src: String(image.src) } : {}), ...(image.assetId ? { assetId: String(image.assetId) } : {}),
64
- alt: String(image.alt ?? normalized.title ?? ''), fit, ...(image.caption ? { caption: String(image.caption) } : {}) } }
65
- }
66
-
67
- const esc = value => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
68
- const position = box => `left:${box.x / CANVAS.w * 100}%;top:${box.y / CANVAS.h * 100}%;width:${box.w / CANVAS.w * 100}%;height:${box.h / CANVAS.h * 100}%`
69
- export function chartColors(theme) { return [theme.palette.accent, theme.palette.accent2, '#55B5AA', '#A580D3', '#E67979', '#7EA4C4'] }
70
-
71
- export function renderChartSvg(chart, theme) {
72
- const colors = chartColors(theme), fg = theme.palette.fg, muted = theme.palette.muted
73
- const width = 1120, height = 430, n = chart.categories.length
74
- const rect = (x, y, w, h, color) => `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(0, h)}" fill="${color}"/>`
75
- const text = (x, y, content, anchor = 'start', color = fg, size = 18) => `<text x="${x}" y="${y}" text-anchor="${anchor}" fill="${color}" font-size="${size}">${esc(content)}</text>`
76
- const line = (x1, y1, x2, y2, color = muted) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1"/>`
77
- let shapes = ''
78
- if (chart.kind === 'pie') {
79
- const values = chart.series[0].values, sum = values.reduce((a, b) => a + b, 0)
80
- let angle = -Math.PI / 2
81
- values.forEach((value, i) => {
82
- const next = angle + value / sum * Math.PI * 2
83
- if (value === sum) shapes += `<circle cx="350" cy="205" r="173" fill="${colors[i % colors.length]}"/>`
84
- else if (value > 0) shapes += `<path d="M 350 205 L ${350 + Math.cos(angle) * 173} ${205 + Math.sin(angle) * 173} A 173 173 0 ${next - angle > Math.PI ? 1 : 0} 1 ${350 + Math.cos(next) * 173} ${205 + Math.sin(next) * 173} Z" fill="${colors[i % colors.length]}"/>`
85
- angle = next
86
- const y = 35 + i * 30
87
- shapes += rect(625, y - 15, 15, 15, colors[i % colors.length]) + text(651, y, chart.categories[i])
88
- + text(1095, y, `${(value / sum * 100).toFixed(1)}%`, 'end')
89
- })
90
- } else {
91
- const values = chart.series.flatMap(s => s.values), min = Math.min(0, ...values), max = Math.max(0, ...values)
92
- const span = max - min || 1, left = chart.kind === 'bar' ? 170 : 82, right = 1090, top = 18, bottom = 340
93
- const y = value => bottom - (value - min) / span * (bottom - top)
94
- const x = value => left + (value - min) / span * (right - left)
95
- for (let tick = 0; tick <= 4; tick++) {
96
- const value = min + span * tick / 4, label = Number(value.toPrecision(4)).toLocaleString('en-US')
97
- if (chart.kind === 'bar') shapes += line(x(value), top, x(value), bottom, `${muted}66`) + text(x(value), bottom + 28, label, 'middle', muted, 16)
98
- else shapes += line(left, y(value), right, y(value), `${muted}66`) + text(left - 12, y(value) + 6, label, 'end', muted, 16)
99
- }
100
- chart.categories.forEach((category, i) => {
101
- if (chart.kind === 'bar') shapes += text(left - 14, top + (i + 0.5) * (bottom - top) / n + 6, category, 'end', fg, 16)
102
- else shapes += text(left + (i + 0.5) * (right - left) / n, bottom + 30, category, 'middle', fg, 16)
103
- })
104
- chart.series.forEach((series, s) => {
105
- const color = colors[s % colors.length]
106
- if (chart.kind === 'line') {
107
- const points = series.values.map((v, i) => `${left + (i + 0.5) * (right - left) / n},${y(v)}`).join(' ')
108
- shapes += `<polyline points="${points}" fill="none" stroke="${color}" stroke-width="4"/>`
109
- series.values.forEach((v, i) => { shapes += `<circle cx="${left + (i + 0.5) * (right - left) / n}" cy="${y(v)}" r="5" fill="${color}"/>` })
110
- } else series.values.forEach((v, i) => {
111
- if (chart.kind === 'bar') {
112
- const group = (bottom - top) / n, h = group * 0.7 / chart.series.length
113
- shapes += rect(Math.min(x(0), x(v)), top + i * group + group * 0.15 + s * h, Math.abs(x(v) - x(0)), Math.max(1, h - 2), color)
114
- } else {
115
- const group = (right - left) / n, w = group * 0.7 / chart.series.length
116
- shapes += rect(left + i * group + group * 0.15 + s * w, Math.min(y(0), y(v)), Math.max(1, w - 3), Math.abs(y(v) - y(0)), color)
117
- }
118
- })
119
- })
120
- shapes += chart.kind === 'bar' ? line(x(0), top, x(0), bottom, fg) : line(left, y(0), right, y(0), fg)
121
- chart.series.forEach((s, i) => { shapes += rect(left + i * 175, 397, 14, 14, colors[i % colors.length]) + text(left + 22 + i * 175, 410, s.name, 'start', fg, 17) })
122
- }
123
- if (chart.unit) shapes += text(1095, 428, chart.unit, 'end', muted, 16)
124
- // Accessible data remains available even when SVG cannot be viewed.
125
- const table = '<table class="chart-data"><caption>' + esc(chart.caption || 'Chart data') + '</caption><thead><tr><th></th>' + chart.series.map(s => `<th>${esc(s.name)}</th>`).join('') + '</tr></thead><tbody>'
126
- + chart.categories.map((c, i) => `<tr><th>${esc(c)}</th>${chart.series.map(s => `<td>${s.values[i]}</td>`).join('')}</tr>`).join('') + '</tbody></table>'
127
- return `<svg class="native-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(chart.caption || chart.kind + ' chart')}"><title>${esc(chart.caption || chart.kind)}</title>${shapes}</svg>${table}`
128
- }
129
-
130
- export function renderMediaHtml(slide, manifest, theme) {
131
- const boxes = mediaBoxes(slide.layout)
132
- let body = `<h2 class="media-title" style="${position(boxes.title)}">${esc(slide.title)}</h2>`
133
- if (slide.layout === 'chart') {
134
- body += `<div class="media-chart" style="${position(boxes.chart)}">${renderChartSvg(slide.chart, theme)}</div>`
135
- if (slide.chart.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(slide.chart.caption)}</div>`
136
- } else {
137
- const image = slide.image, asset = manifest.assets?.[image?.assetId]
138
- body += `<figure class="media-image" style="${position(boxes.image)}"><img src="${esc(asset?.data ?? '')}" alt="${esc(image?.alt)}" style="object-fit:${image?.fit || 'contain'}"/></figure>`
139
- if (boxes.body) body += `<div class="media-body" style="${position(boxes.body)}">${slide.subtitle ? `<p>${esc(slide.subtitle)}</p>` : ''}${slide.bullets?.length ? `<ul>${slide.bullets.map(b => `<li>${esc(b)}</li>`).join('')}</ul>` : ''}</div>`
140
- if (image?.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(image.caption)}</div>`
141
- }
142
- return `<div class="media-canvas">${body}</div>`
143
- }
144
-
145
- export const MEDIA_CSS = `
146
- .slide--image,.slide--image-left,.slide--image-right,.slide--chart{padding:0;align-items:center}
147
- .slide--image .slide-inner,.slide--image-left .slide-inner,.slide--image-right .slide-inner,.slide--chart .slide-inner{width:100%;display:flex;justify-content:center}
148
- .media-canvas{position:relative;width:min(100vw,177.7778vh);height:min(100vh,56.25vw);container-type:inline-size}
149
- .media-title,.media-image,.media-body,.media-chart,.media-caption{position:absolute;margin:0;max-width:none;box-sizing:border-box}
150
- .media-title{font-size:3.54cqw;line-height:1.17;overflow-wrap:anywhere}
151
- .media-image img{width:100%;height:100%;display:block}
152
- .media-body{font-size:2.083cqw;line-height:1.45;overflow-wrap:anywhere}
153
- .media-body p{margin:0 0 1.2em}.media-body ul{margin:0;padding-left:1.1em}.media-body li{margin-bottom:.6em}
154
- .media-caption{color:var(--muted);font-size:1.4cqw;line-height:1.2}
155
- .native-chart{width:100%;height:100%;font-family:var(--font-body)}
156
- .chart-data{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
157
- .brand-logo{position:absolute;right:6.2%;top:1.6%;width:7.1%;height:5.6%;object-fit:contain;pointer-events:none}
158
- .brand-footer{position:absolute;left:6.4%;bottom:3.4%;max-width:69%;font-size:clamp(10px,1vw,15px);color:var(--muted)}
159
- body.printing .brand-footer{display:block}
160
- `
1
+ // Shared geometry and chart data for the offline player and native PPTX export.
2
+ export const MEDIA_LAYOUTS = new Set(['image', 'image-left', 'image-right', 'chart'])
3
+ export const CANVAS = { w: 13.3333333333, h: 7.5 }
4
+ export const LOGO_BOX = { x: 11.55, y: 0.12, w: 0.95, h: 0.42 }
5
+ export const FOOTER_BOX = { x: 0.85, y: 6.95, w: 9.3, h: 0.3 }
6
+
7
+ export function mediaBoxes(layout) {
8
+ const title = { x: 0.85, y: 0.64, w: 11.6, h: 0.85 }
9
+ const left = { x: 0.85, y: 1.85, w: 5.55, h: 4.65 }
10
+ const right = { x: 6.88, y: 1.85, w: 5.6, h: 4.65 }
11
+ const full = { x: 0.85, y: 1.85, w: 11.6, h: 4.65 }
12
+ return { title, image: { ...(layout === 'image-left' ? left : layout === 'image-right' ? right : full), h: 4.45 },
13
+ body: layout === 'image-left' ? right : layout === 'image-right' ? left : null,
14
+ chart: { ...full, h: 4.45 }, caption: { x: 0.85, y: 6.38, w: 11.6, h: 0.35 } }
15
+ }
16
+
17
+ function numeric(value, page) {
18
+ if (typeof value !== 'number' && !(typeof value === 'string' && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim()))) {
19
+ throw new Error(`dsh-ppt:第 ${page} 页图表有非数值数据:${String(value).slice(0, 40)}`)
20
+ }
21
+ const result = Number(value)
22
+ if (!Number.isFinite(result)) throw new Error(`dsh-ppt:第 ${page} 页图表数值必须有限`)
23
+ return result
24
+ }
25
+
26
+ export function normalizeChart(input, page = 1) {
27
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error(`dsh-ppt:第 ${page} 页缺少 chart 对象`)
28
+ const kind = input.kind ?? 'column'
29
+ if (!['column', 'bar', 'line', 'pie'].includes(kind)) throw new Error(`dsh-ppt:第 ${page} 页未知图表类型 ${kind}`)
30
+ let categories = input.categories
31
+ let series = input.series
32
+ if (input.rows !== undefined) {
33
+ const rows = input.rows
34
+ if (!Array.isArray(rows) || rows.length < 2 || !Array.isArray(rows[0]) || rows[0].length < 2
35
+ || rows.some(row => !Array.isArray(row) || row.length !== rows[0].length)) {
36
+ throw new Error(`dsh-ppt:第 ${page} 页图表 rows 需包含表头、类别和数值列,各行列数一致`)
37
+ }
38
+ categories = rows.slice(1).map(row => row[0])
39
+ series = rows[0].slice(1).map((name, column) => ({ name, values: rows.slice(1).map(row => row[column + 1]) }))
40
+ }
41
+ if (!Array.isArray(categories) || categories.length < 1 || categories.length > 24 || categories.some(c => String(c ?? '').trim() === '')) {
42
+ throw new Error(`dsh-ppt:第 ${page} 页图表需 1–24 个非空类别`)
43
+ }
44
+ if (!Array.isArray(series) || series.length < 1 || series.length > 6) throw new Error(`dsh-ppt:第 ${page} 页图表需 1–6 个数据系列`)
45
+ const clean = series.map((s, i) => {
46
+ if (!s || !Array.isArray(s.values) || s.values.length !== categories.length) throw new Error(`dsh-ppt:第 ${page} 页图表第 ${i + 1} 个系列与类别数量不一致`)
47
+ return { name: String(s.name ?? `系列 ${i + 1}`).trim() || `系列 ${i + 1}`, values: s.values.map(v => numeric(v, page)) }
48
+ })
49
+ if (kind === 'pie' && (clean.length !== 1 || categories.length > 12 || clean[0].values.some(v => v < 0) || clean[0].values.reduce((a, b) => a + b, 0) <= 0)) {
50
+ throw new Error(`dsh-ppt:第 ${page} 页饼图只支持一个系列、最多 12 个类别,数值需非负且总和大于零`)
51
+ }
52
+ return { kind, categories: categories.map(c => String(c).trim()), series: clean,
53
+ ...(input.unit ? { unit: String(input.unit) } : {}), ...(input.caption ? { caption: String(input.caption) } : {}) }
54
+ }
55
+
56
+ export function normalizeMediaSlide(source, normalized, index) {
57
+ if (!MEDIA_LAYOUTS.has(normalized.layout)) return normalized
58
+ if (normalized.layout === 'chart') return { ...normalized, chart: normalizeChart(source.chart, index + 1) }
59
+ const image = typeof source.image === 'string' ? { src: source.image } : source.image
60
+ if (!image || typeof image !== 'object' || (!image.src && !image.assetId)) throw new Error(`dsh-ppt:第 ${index + 1} 页缺少 image.src`)
61
+ const fit = image.fit ?? 'contain'
62
+ if (!['contain', 'cover'].includes(fit)) throw new Error(`dsh-ppt:第 ${index + 1} 页 image.fit 仅支持 contain / cover`)
63
+ return { ...normalized, image: { ...(image.src ? { src: String(image.src) } : {}), ...(image.assetId ? { assetId: String(image.assetId) } : {}),
64
+ alt: String(image.alt ?? normalized.title ?? ''), fit, ...(image.caption ? { caption: String(image.caption) } : {}) } }
65
+ }
66
+
67
+ const esc = value => String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;')
68
+ const position = box => `left:${box.x / CANVAS.w * 100}%;top:${box.y / CANVAS.h * 100}%;width:${box.w / CANVAS.w * 100}%;height:${box.h / CANVAS.h * 100}%`
69
+ export function chartColors(theme) { return [theme.palette.accent, theme.palette.accent2, '#55B5AA', '#A580D3', '#E67979', '#7EA4C4'] }
70
+
71
+ export function renderChartSvg(chart, theme) {
72
+ const colors = chartColors(theme), fg = theme.palette.fg, muted = theme.palette.muted
73
+ const width = 1120, height = 430, n = chart.categories.length
74
+ const rect = (x, y, w, h, color) => `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(0, h)}" fill="${color}"/>`
75
+ const text = (x, y, content, anchor = 'start', color = fg, size = 18) => `<text x="${x}" y="${y}" text-anchor="${anchor}" fill="${color}" font-size="${size}">${esc(content)}</text>`
76
+ const line = (x1, y1, x2, y2, color = muted) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="1"/>`
77
+ let shapes = ''
78
+ if (chart.kind === 'pie') {
79
+ const values = chart.series[0].values, sum = values.reduce((a, b) => a + b, 0)
80
+ let angle = -Math.PI / 2
81
+ values.forEach((value, i) => {
82
+ const next = angle + value / sum * Math.PI * 2
83
+ if (value === sum) shapes += `<circle cx="350" cy="205" r="173" fill="${colors[i % colors.length]}"/>`
84
+ else if (value > 0) shapes += `<path d="M 350 205 L ${350 + Math.cos(angle) * 173} ${205 + Math.sin(angle) * 173} A 173 173 0 ${next - angle > Math.PI ? 1 : 0} 1 ${350 + Math.cos(next) * 173} ${205 + Math.sin(next) * 173} Z" fill="${colors[i % colors.length]}"/>`
85
+ angle = next
86
+ const y = 35 + i * 30
87
+ shapes += rect(625, y - 15, 15, 15, colors[i % colors.length]) + text(651, y, chart.categories[i])
88
+ + text(1095, y, `${(value / sum * 100).toFixed(1)}%`, 'end')
89
+ })
90
+ } else {
91
+ const values = chart.series.flatMap(s => s.values), min = Math.min(0, ...values), max = Math.max(0, ...values)
92
+ const span = max - min || 1, left = chart.kind === 'bar' ? 170 : 82, right = 1090, top = 18, bottom = 340
93
+ const y = value => bottom - (value - min) / span * (bottom - top)
94
+ const x = value => left + (value - min) / span * (right - left)
95
+ for (let tick = 0; tick <= 4; tick++) {
96
+ const value = min + span * tick / 4, label = Number(value.toPrecision(4)).toLocaleString('en-US')
97
+ if (chart.kind === 'bar') shapes += line(x(value), top, x(value), bottom, `${muted}66`) + text(x(value), bottom + 28, label, 'middle', muted, 16)
98
+ else shapes += line(left, y(value), right, y(value), `${muted}66`) + text(left - 12, y(value) + 6, label, 'end', muted, 16)
99
+ }
100
+ chart.categories.forEach((category, i) => {
101
+ if (chart.kind === 'bar') shapes += text(left - 14, top + (i + 0.5) * (bottom - top) / n + 6, category, 'end', fg, 16)
102
+ else shapes += text(left + (i + 0.5) * (right - left) / n, bottom + 30, category, 'middle', fg, 16)
103
+ })
104
+ chart.series.forEach((series, s) => {
105
+ const color = colors[s % colors.length]
106
+ if (chart.kind === 'line') {
107
+ const points = series.values.map((v, i) => `${left + (i + 0.5) * (right - left) / n},${y(v)}`).join(' ')
108
+ shapes += `<polyline points="${points}" fill="none" stroke="${color}" stroke-width="4"/>`
109
+ series.values.forEach((v, i) => { shapes += `<circle cx="${left + (i + 0.5) * (right - left) / n}" cy="${y(v)}" r="5" fill="${color}"/>` })
110
+ } else series.values.forEach((v, i) => {
111
+ if (chart.kind === 'bar') {
112
+ const group = (bottom - top) / n, h = group * 0.7 / chart.series.length
113
+ shapes += rect(Math.min(x(0), x(v)), top + i * group + group * 0.15 + s * h, Math.abs(x(v) - x(0)), Math.max(1, h - 2), color)
114
+ } else {
115
+ const group = (right - left) / n, w = group * 0.7 / chart.series.length
116
+ shapes += rect(left + i * group + group * 0.15 + s * w, Math.min(y(0), y(v)), Math.max(1, w - 3), Math.abs(y(v) - y(0)), color)
117
+ }
118
+ })
119
+ })
120
+ shapes += chart.kind === 'bar' ? line(x(0), top, x(0), bottom, fg) : line(left, y(0), right, y(0), fg)
121
+ chart.series.forEach((s, i) => { shapes += rect(left + i * 175, 397, 14, 14, colors[i % colors.length]) + text(left + 22 + i * 175, 410, s.name, 'start', fg, 17) })
122
+ }
123
+ if (chart.unit) shapes += text(1095, 428, chart.unit, 'end', muted, 16)
124
+ // Accessible data remains available even when SVG cannot be viewed.
125
+ const table = '<table class="chart-data"><caption>' + esc(chart.caption || 'Chart data') + '</caption><thead><tr><th></th>' + chart.series.map(s => `<th>${esc(s.name)}</th>`).join('') + '</tr></thead><tbody>'
126
+ + chart.categories.map((c, i) => `<tr><th>${esc(c)}</th>${chart.series.map(s => `<td>${s.values[i]}</td>`).join('')}</tr>`).join('') + '</tbody></table>'
127
+ return `<svg class="native-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(chart.caption || chart.kind + ' chart')}"><title>${esc(chart.caption || chart.kind)}</title>${shapes}</svg>${table}`
128
+ }
129
+
130
+ export function renderMediaHtml(slide, manifest, theme) {
131
+ const boxes = mediaBoxes(slide.layout)
132
+ let body = `<h2 class="media-title" style="${position(boxes.title)}">${esc(slide.title)}</h2>`
133
+ if (slide.layout === 'chart') {
134
+ body += `<div class="media-chart" style="${position(boxes.chart)}">${renderChartSvg(slide.chart, theme)}</div>`
135
+ if (slide.chart.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(slide.chart.caption)}</div>`
136
+ } else {
137
+ const image = slide.image, asset = manifest.assets?.[image?.assetId]
138
+ body += `<figure class="media-image" style="${position(boxes.image)}"><img src="${esc(asset?.data ?? '')}" alt="${esc(image?.alt)}" style="object-fit:${image?.fit || 'contain'}"/></figure>`
139
+ if (boxes.body) body += `<div class="media-body" style="${position(boxes.body)}">${slide.subtitle ? `<p>${esc(slide.subtitle)}</p>` : ''}${slide.bullets?.length ? `<ul>${slide.bullets.map(b => `<li>${esc(b)}</li>`).join('')}</ul>` : ''}</div>`
140
+ if (image?.caption) body += `<div class="media-caption" style="${position(boxes.caption)}">${esc(image.caption)}</div>`
141
+ }
142
+ return `<div class="media-canvas">${body}</div>`
143
+ }
144
+
145
+ export const MEDIA_CSS = `
146
+ .slide--image,.slide--image-left,.slide--image-right,.slide--chart{padding:0;align-items:center}
147
+ .slide--image .slide-inner,.slide--image-left .slide-inner,.slide--image-right .slide-inner,.slide--chart .slide-inner{width:100%;display:flex;justify-content:center}
148
+ .media-canvas{position:relative;width:min(100vw,177.7778vh);height:min(100vh,56.25vw);container-type:inline-size}
149
+ .media-title,.media-image,.media-body,.media-chart,.media-caption{position:absolute;margin:0;max-width:none;box-sizing:border-box}
150
+ .media-title{font-size:3.54cqw;line-height:1.17;overflow-wrap:anywhere}
151
+ .media-image img{width:100%;height:100%;display:block}
152
+ .media-body{font-size:2.083cqw;line-height:1.45;overflow-wrap:anywhere}
153
+ .media-body p{margin:0 0 1.2em}.media-body ul{margin:0;padding-left:1.1em}.media-body li{margin-bottom:.6em}
154
+ .media-caption{color:var(--muted);font-size:1.4cqw;line-height:1.2}
155
+ .native-chart{width:100%;height:100%;font-family:var(--font-body)}
156
+ .chart-data{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
157
+ .brand-logo{position:absolute;right:6.2%;top:1.6%;width:7.1%;height:5.6%;object-fit:contain;pointer-events:none}
158
+ .brand-footer{position:absolute;left:6.4%;bottom:3.4%;max-width:69%;font-size:clamp(10px,1vw,15px);color:var(--muted)}
159
+ body.printing .brand-footer{display:block}
160
+ `