dsh-ppt 0.8.0 → 0.9.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.
@@ -1,104 +1,104 @@
1
- import { basename } from 'node:path'
2
-
3
- const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
4
-
5
- /** Offline, sibling-file links only. No credentials or application server needed. */
6
- export function renderDelivery(manifest, files, language) {
7
- const label = (zh, en) => language === 'en' ? en : language === 'bilingual' ? zh + ' · ' + en : zh
8
- const issues = manifest.quality?.issues ?? []
9
- const draft = issues.some(issue => issue.code === 'template-unfilled')
10
- const href = path => './' + encodeURIComponent(basename(path))
11
- const download = (kind, text, id) => files[kind] ? `<a id="${id}" href="${escape(href(files[kind]))}" download>${escape(text)}</a>` : ''
12
- const pptxLabel = label('下载 PPTX', 'Download PPTX')
13
- const reviewLabel = label('检查与导出', 'Review and export')
14
- const outline = manifest.slides.map((slide, i) => `<li><button type="button" data-review-slide="${i + 1}"><span>${i + 1}</span>${escape(slide.title || label('无标题', 'Untitled'))}</button></li>`).join('')
15
- const rows = issues.map(issue => `<li><button type="button" data-review-slide="${issue.slide}"><strong>${escape(label('第 ' + issue.slide + ' 页', 'Slide ' + issue.slide))} · ${escape(issue.message)}</strong><span>${escape(issue.suggestion)}</span></button></li>`).join('')
16
- const summary = draft ? label('草稿中还有待填写内容,请补齐事实后再交付。', 'This draft still contains placeholders. Complete the facts before delivery.')
17
- : manifest.quality ? label('静态检查发现 ' + issues.length + ' 项提示;请核对实际显示。', 'Static checks found ' + issues.length + ' item(s); review the actual slides.')
18
- : label('此项目没有静态质量报告,请逐页检查。', 'No static quality report is available; review every slide.')
19
- return {
20
- body: `<div id="delivery-bar">
21
- <span id="delivery-status">${escape(draft ? label('待补全草稿', 'Draft: content needed') : label('演示文稿 · 请检查后交付', 'Presentation · review before delivery'))}</span>
22
- ${download('pptx', pptxLabel, 'download-pptx')}
23
- <button id="review-toggle" type="button" aria-haspopup="dialog">${escape(reviewLabel)}</button>
24
- </div>
25
- <dialog id="review-panel" aria-labelledby="review-title">
26
- <div class="review-head"><h2 id="review-title">${escape(reviewLabel)}</h2><button id="review-close" type="button">${escape(label('关闭', 'Close'))}</button></div>
27
- <p id="review-summary">${escape(summary)}</p>
28
- <div class="review-exports">${download('pptx', pptxLabel, 'review-download-pptx')}${download('json', label('保存可编辑项目', 'Save project'), 'download-project')}</div>
29
- <p>${escape(label('用 PowerPoint / WPS 打开 PPTX 可直接修改文字、图片和图表。让 Agent 继续修改时,使用保存的项目文件。', 'Open the PPTX in PowerPoint / WPS to edit text, images and charts. Use the saved project when asking your Agent to revise it.'))}</p>
30
- <h3>${escape(label('检查提示 · 点击定位页面', 'Review items · click to open slide'))}</h3>
31
- ${issues.length ? `<ul id="review-issues">${rows}</ul>` : `<p>${escape(manifest.quality ? label('静态检查未发现问题;仍需核对实际排版和事实。', 'No static issues found; check the actual layout and facts.') : label('暂无报告。', 'No report available.'))}</p>`}
32
- <p>${escape(label('文字尺寸是静态估算;这里不能证明 PowerPoint / WPS 排版已通过验收。', 'Text fit is estimated; this does not verify the rendered PowerPoint / WPS layout.'))}</p>
33
- <h3>${escape(label('页面目录', 'Slides'))}</h3><ol id="review-outline">${outline}</ol>
34
- </dialog>`,
35
- navigation: `<button id="previous-slide" type="button" aria-label="${escape(label('上一页', 'Previous slide'))}">←</button><input id="page-number" type="number" min="1" max="${manifest.slides.length}" value="1" aria-label="${escape(label('跳到第几页', 'Go to slide'))}"><button id="next-slide" type="button" aria-label="${escape(label('下一页', 'Next slide'))}">→</button>`,
36
- css: DELIVERY_CSS,
37
- script: `syncDelivery = (${initDelivery.toString()})(go, setOverview, () => index, total);`,
38
- }
39
- }
40
-
41
- /** This function is embedded in the standalone HTML, with no module dependencies. */
42
- function initDelivery(go, setOverview, current, total) {
43
- const previous = document.getElementById('previous-slide')
44
- const next = document.getElementById('next-slide')
45
- const number = document.getElementById('page-number')
46
- const panel = document.getElementById('review-panel')
47
- const toggle = document.getElementById('review-toggle')
48
- previous.addEventListener('click', () => go(Math.max(0, current() - 1)))
49
- next.addEventListener('click', () => go(Math.min(total - 1, current() + 1)))
50
- const jump = () => {
51
- const value = Number(number.value)
52
- if (Number.isInteger(value) && value >= 1 && value <= total) go(value - 1)
53
- else number.value = String(current() + 1)
54
- }
55
- number.addEventListener('change', jump)
56
- number.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); jump() } })
57
- toggle.addEventListener('click', () => panel.showModal())
58
- document.getElementById('review-close').addEventListener('click', () => panel.close())
59
- panel.querySelectorAll('[data-review-slide]').forEach(button => button.addEventListener('click', () => {
60
- const slide = Number(button.dataset.reviewSlide)
61
- if (Number.isInteger(slide) && slide >= 1 && slide <= total) { go(slide - 1); setOverview(false); panel.close() }
62
- }))
63
- return index => {
64
- number.value = String(index + 1)
65
- previous.disabled = index === 0
66
- next.disabled = index === total - 1
67
- }
68
- }
69
-
70
- const DELIVERY_CSS = `
71
- #hud button:disabled{opacity:.4;cursor:default}
72
- #page-number{width:56px;padding:7px;border:1px solid var(--muted);border-radius:8px;background:var(--panel);color:var(--fg);font:inherit}
73
- #delivery-bar{position:fixed;top:18px;left:22px;right:22px;z-index:45;display:flex;align-items:center;gap:12px;font-size:13px}
74
- #delivery-status{flex:1;color:var(--muted)}
75
- #delivery-bar a,#delivery-bar button,.review-exports a{display:inline-block;text-decoration:none;border:1px solid var(--muted);border-radius:99px;padding:9px 14px;background:var(--panel);color:var(--fg);font:inherit;cursor:pointer}
76
- #delivery-bar a:hover,#delivery-bar button:hover,.review-exports a:hover{border-color:var(--accent);color:var(--accent)}
77
- #delivery-bar #download-pptx{background:var(--accent);color:var(--bg);border-color:var(--accent)}
78
- #review-panel{background:var(--panel);color:var(--fg);border:1px solid var(--muted);border-radius:18px;width:min(620px,calc(100vw - 24px));max-height:85vh;padding:24px;overflow:auto}
79
- #review-panel::backdrop{background:rgba(0,0,0,.65)}
80
- .review-head{display:flex;justify-content:space-between;align-items:center;gap:16px}
81
- .review-head h2{margin:0;font-size:24px}
82
- #review-close{border:1px solid var(--muted);border-radius:10px;background:transparent;color:var(--fg);padding:8px 12px;font:inherit;cursor:pointer}
83
- #review-panel p{line-height:1.65;color:var(--muted)}
84
- #review-panel h3{margin:24px 0 10px;font-size:17px}
85
- #review-panel ul,#review-panel ol{list-style:none;padding:0;margin:0}
86
- #review-panel li+li{margin-top:8px}
87
- #review-panel li button{width:100%;text-align:left;display:flex;gap:12px;align-items:center;background:var(--bg);color:var(--fg);border:1px solid transparent;border-radius:10px;padding:12px;font:inherit;cursor:pointer}
88
- #review-panel li button:hover{border-color:var(--accent)}
89
- #review-issues li button{display:block}
90
- #review-issues strong,#review-issues span{display:block;line-height:1.6}
91
- #review-issues span{color:var(--muted);font-size:13px;margin-top:4px}
92
- .review-exports{display:flex;flex-wrap:wrap;gap:8px}
93
- body.presenting #delivery-bar{display:none}
94
- button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
95
- @media(max-width:640px){
96
- #delivery-bar{left:10px;right:10px;top:10px;gap:8px}
97
- #delivery-status{display:none}
98
- #delivery-bar a,#delivery-bar button{padding:10px 12px;min-height:44px}
99
- #hud button{padding:9px 12px}
100
- #notes-panel,#help-panel{bottom:160px}
101
- #review-panel{padding:18px}
102
- }
103
- @media print{#delivery-bar,#review-panel{display:none!important}}
104
- `
1
+ import { basename } from 'node:path'
2
+
3
+ const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
4
+
5
+ /** Offline, sibling-file links only. No credentials or application server needed. */
6
+ export function renderDelivery(manifest, files, language) {
7
+ const label = (zh, en) => language === 'en' ? en : language === 'bilingual' ? zh + ' · ' + en : zh
8
+ const issues = manifest.quality?.issues ?? []
9
+ const draft = issues.some(issue => issue.code === 'template-unfilled')
10
+ const href = path => './' + encodeURIComponent(basename(path))
11
+ const download = (kind, text, id) => files[kind] ? `<a id="${id}" href="${escape(href(files[kind]))}" download>${escape(text)}</a>` : ''
12
+ const pptxLabel = label('下载 PPTX', 'Download PPTX')
13
+ const reviewLabel = label('检查与导出', 'Review and export')
14
+ const outline = manifest.slides.map((slide, i) => `<li><button type="button" data-review-slide="${i + 1}"><span>${i + 1}</span>${escape(slide.title || label('无标题', 'Untitled'))}</button></li>`).join('')
15
+ const rows = issues.map(issue => `<li><button type="button" data-review-slide="${issue.slide}"><strong>${escape(label('第 ' + issue.slide + ' 页', 'Slide ' + issue.slide))} · ${escape(issue.message)}</strong><span>${escape(issue.suggestion)}</span></button></li>`).join('')
16
+ const summary = draft ? label('草稿中还有待填写内容,请补齐事实后再交付。', 'This draft still contains placeholders. Complete the facts before delivery.')
17
+ : manifest.quality ? label('静态检查发现 ' + issues.length + ' 项提示;请核对实际显示。', 'Static checks found ' + issues.length + ' item(s); review the actual slides.')
18
+ : label('此项目没有静态质量报告,请逐页检查。', 'No static quality report is available; review every slide.')
19
+ return {
20
+ body: `<div id="delivery-bar">
21
+ <span id="delivery-status">${escape(draft ? label('待补全草稿', 'Draft: content needed') : label('演示文稿 · 请检查后交付', 'Presentation · review before delivery'))}</span>
22
+ ${download('pptx', pptxLabel, 'download-pptx')}
23
+ <button id="review-toggle" type="button" aria-haspopup="dialog">${escape(reviewLabel)}</button>
24
+ </div>
25
+ <dialog id="review-panel" aria-labelledby="review-title">
26
+ <div class="review-head"><h2 id="review-title">${escape(reviewLabel)}</h2><button id="review-close" type="button">${escape(label('关闭', 'Close'))}</button></div>
27
+ <p id="review-summary">${escape(summary)}</p>
28
+ <div class="review-exports">${download('pptx', pptxLabel, 'review-download-pptx')}${download('json', label('保存可编辑项目', 'Save project'), 'download-project')}</div>
29
+ <p>${escape(label('用 PowerPoint / WPS 打开 PPTX 可直接修改文字、图片和图表。让 Agent 继续修改时,使用保存的项目文件。', 'Open the PPTX in PowerPoint / WPS to edit text, images and charts. Use the saved project when asking your Agent to revise it.'))}</p>
30
+ <h3>${escape(label('检查提示 · 点击定位页面', 'Review items · click to open slide'))}</h3>
31
+ ${issues.length ? `<ul id="review-issues">${rows}</ul>` : `<p>${escape(manifest.quality ? label('静态检查未发现问题;仍需核对实际排版和事实。', 'No static issues found; check the actual layout and facts.') : label('暂无报告。', 'No report available.'))}</p>`}
32
+ <p>${escape(label('文字尺寸是静态估算;这里不能证明 PowerPoint / WPS 排版已通过验收。', 'Text fit is estimated; this does not verify the rendered PowerPoint / WPS layout.'))}</p>
33
+ <h3>${escape(label('页面目录', 'Slides'))}</h3><ol id="review-outline">${outline}</ol>
34
+ </dialog>`,
35
+ navigation: `<button id="previous-slide" type="button" aria-label="${escape(label('上一页', 'Previous slide'))}">←</button><input id="page-number" type="number" min="1" max="${manifest.slides.length}" value="1" aria-label="${escape(label('跳到第几页', 'Go to slide'))}"><button id="next-slide" type="button" aria-label="${escape(label('下一页', 'Next slide'))}">→</button>`,
36
+ css: DELIVERY_CSS,
37
+ script: `syncDelivery = (${initDelivery.toString()})(go, setOverview, () => index, total);`,
38
+ }
39
+ }
40
+
41
+ /** This function is embedded in the standalone HTML, with no module dependencies. */
42
+ function initDelivery(go, setOverview, current, total) {
43
+ const previous = document.getElementById('previous-slide')
44
+ const next = document.getElementById('next-slide')
45
+ const number = document.getElementById('page-number')
46
+ const panel = document.getElementById('review-panel')
47
+ const toggle = document.getElementById('review-toggle')
48
+ previous.addEventListener('click', () => go(Math.max(0, current() - 1)))
49
+ next.addEventListener('click', () => go(Math.min(total - 1, current() + 1)))
50
+ const jump = () => {
51
+ const value = Number(number.value)
52
+ if (Number.isInteger(value) && value >= 1 && value <= total) go(value - 1)
53
+ else number.value = String(current() + 1)
54
+ }
55
+ number.addEventListener('change', jump)
56
+ number.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); jump() } })
57
+ toggle.addEventListener('click', () => panel.showModal())
58
+ document.getElementById('review-close').addEventListener('click', () => panel.close())
59
+ panel.querySelectorAll('[data-review-slide]').forEach(button => button.addEventListener('click', () => {
60
+ const slide = Number(button.dataset.reviewSlide)
61
+ if (Number.isInteger(slide) && slide >= 1 && slide <= total) { go(slide - 1); setOverview(false); panel.close() }
62
+ }))
63
+ return index => {
64
+ number.value = String(index + 1)
65
+ previous.disabled = index === 0
66
+ next.disabled = index === total - 1
67
+ }
68
+ }
69
+
70
+ const DELIVERY_CSS = `
71
+ #hud button:disabled{opacity:.4;cursor:default}
72
+ #page-number{width:56px;padding:7px;border:1px solid var(--muted);border-radius:8px;background:var(--panel);color:var(--fg);font:inherit}
73
+ #delivery-bar{position:fixed;top:18px;left:22px;right:22px;z-index:45;display:flex;align-items:center;gap:12px;font-size:13px}
74
+ #delivery-status{flex:1;color:var(--muted)}
75
+ #delivery-bar a,#delivery-bar button,.review-exports a{display:inline-block;text-decoration:none;border:1px solid var(--muted);border-radius:99px;padding:9px 14px;background:var(--panel);color:var(--fg);font:inherit;cursor:pointer}
76
+ #delivery-bar a:hover,#delivery-bar button:hover,.review-exports a:hover{border-color:var(--accent);color:var(--accent)}
77
+ #delivery-bar #download-pptx{background:var(--accent);color:var(--bg);border-color:var(--accent)}
78
+ #review-panel{background:var(--panel);color:var(--fg);border:1px solid var(--muted);border-radius:18px;width:min(620px,calc(100vw - 24px));max-height:85vh;padding:24px;overflow:auto}
79
+ #review-panel::backdrop{background:rgba(0,0,0,.65)}
80
+ .review-head{display:flex;justify-content:space-between;align-items:center;gap:16px}
81
+ .review-head h2{margin:0;font-size:24px}
82
+ #review-close{border:1px solid var(--muted);border-radius:10px;background:transparent;color:var(--fg);padding:8px 12px;font:inherit;cursor:pointer}
83
+ #review-panel p{line-height:1.65;color:var(--muted)}
84
+ #review-panel h3{margin:24px 0 10px;font-size:17px}
85
+ #review-panel ul,#review-panel ol{list-style:none;padding:0;margin:0}
86
+ #review-panel li+li{margin-top:8px}
87
+ #review-panel li button{width:100%;text-align:left;display:flex;gap:12px;align-items:center;background:var(--bg);color:var(--fg);border:1px solid transparent;border-radius:10px;padding:12px;font:inherit;cursor:pointer}
88
+ #review-panel li button:hover{border-color:var(--accent)}
89
+ #review-issues li button{display:block}
90
+ #review-issues strong,#review-issues span{display:block;line-height:1.6}
91
+ #review-issues span{color:var(--muted);font-size:13px;margin-top:4px}
92
+ .review-exports{display:flex;flex-wrap:wrap;gap:8px}
93
+ body.presenting #delivery-bar{display:none}
94
+ button:focus-visible,a:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
95
+ @media(max-width:640px){
96
+ #delivery-bar{left:10px;right:10px;top:10px;gap:8px}
97
+ #delivery-status{display:none}
98
+ #delivery-bar a,#delivery-bar button{padding:10px 12px;min-height:44px}
99
+ #hud button{padding:9px 12px}
100
+ #notes-panel,#help-panel{bottom:160px}
101
+ #review-panel{padding:18px}
102
+ }
103
+ @media print{#delivery-bar,#review-panel{display:none!important}}
104
+ `
@@ -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
+ `