dsh-ppt 0.6.0 → 0.8.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.
@@ -0,0 +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,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
+ `
@@ -2,9 +2,11 @@ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
4
4
  import { inflateRawSync } from 'node:zlib'
5
+ import { createRequire } from 'node:module'
6
+ import { pathToFileURL } from 'node:url'
5
7
 
6
8
  export const RENDERER_PACKAGE = '@deepseek-ai/libreoffice-kit'
7
- export const RENDERER_VERSION = '0.1.3'
9
+ export const RENDERER_VERSION = '0.1.5'
8
10
  const INPUT_LIMIT = 64 * 1024 * 1024
9
11
  const OUTPUT_LIMIT = 128 * 1024 * 1024
10
12
  const PAGE_LIMIT = 120
@@ -32,6 +34,17 @@ function readBounded(path, limit, name) {
32
34
  return bytes
33
35
  }
34
36
 
37
+ function classicExtra(bytes, offset, size) {
38
+ const end = offset + size
39
+ while (offset < end) {
40
+ if (offset + 4 > end) fail('PPTX ZIP 扩展字段损坏', 'invalid-document')
41
+ const id = bytes.readUInt16LE(offset), length = bytes.readUInt16LE(offset + 2)
42
+ if (id === 1) fail('PPTX 渲染不支持 ZIP64,请用标准 PPTX 重新保存', 'invalid-document')
43
+ offset += 4 + length
44
+ if (offset > end) fail('PPTX ZIP 扩展字段越界', 'invalid-document')
45
+ }
46
+ }
47
+
35
48
  // Read only the small presentation part to obtain slide geometry. The renderer
36
49
  // remains responsible for validating and importing the complete Office file.
37
50
  function presentationXml(bytes) {
@@ -42,7 +55,7 @@ function presentationXml(bytes) {
42
55
  if (end < 0 || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) fail('不支持的 PPTX ZIP 容器', 'invalid-document')
43
56
  const count = bytes.readUInt16LE(end + 10), length = bytes.readUInt32LE(end + 12), start = bytes.readUInt32LE(end + 16)
44
57
  if (count === 65535 || count > 20000 || start + length > end) fail('PPTX ZIP 目录无效或超过限制', 'invalid-document')
45
- let offset = start
58
+ let offset = start, presentation
46
59
  for (let index = 0; index < count; index++) {
47
60
  if (offset + 46 > start + length || bytes.readUInt32LE(offset) !== 0x02014b50) fail('PPTX ZIP 目录损坏', 'invalid-document')
48
61
  const flags = bytes.readUInt16LE(offset + 8), method = bytes.readUInt16LE(offset + 10)
@@ -51,20 +64,26 @@ function presentationXml(bytes) {
51
64
  const next = offset + 46 + nameLength + extra + comment
52
65
  if (next > start + length) fail('PPTX ZIP 文件名无效', 'invalid-document')
53
66
  const name = bytes.toString('utf8', offset + 46, offset + 46 + nameLength)
67
+ const local = bytes.readUInt32LE(offset + 42)
68
+ if (compressed === 0xffffffff || expanded === 0xffffffff || local === 0xffffffff || local + 30 > start || bytes.readUInt32LE(local) !== 0x04034b50) fail('PPTX ZIP64 或文件偏移无效', 'invalid-document')
69
+ classicExtra(bytes, offset + 46 + nameLength, extra)
70
+ const localExtra = local + 30 + bytes.readUInt16LE(local + 26), localExtraSize = bytes.readUInt16LE(local + 28)
71
+ if (localExtra + localExtraSize + compressed > start) fail('PPTX ZIP 文件越界', 'invalid-document')
72
+ classicExtra(bytes, localExtra, localExtraSize)
54
73
  if (name === 'ppt/presentation.xml') {
55
74
  if (flags & 1 || expanded > RECEIPT_LIMIT || compressed > RECEIPT_LIMIT) fail('PPTX 页面信息加密或超过限制', 'invalid-document')
56
- const local = bytes.readUInt32LE(offset + 42)
57
75
  if (local + 30 > start || bytes.readUInt32LE(local) !== 0x04034b50) fail('PPTX 页面信息偏移无效', 'invalid-document')
58
76
  const dataOffset = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28)
59
77
  if (dataOffset + compressed > start) fail('PPTX 页面信息越界', 'invalid-document')
60
78
  const data = bytes.subarray(dataOffset, dataOffset + compressed)
61
79
  const xml = method === 0 ? data : method === 8 ? inflateRawSync(data, { maxOutputLength: RECEIPT_LIMIT }) : fail('PPTX 压缩格式不支持', 'invalid-document')
62
80
  if (xml.length !== expanded) fail('PPTX 页面信息长度不匹配', 'invalid-document')
63
- return xml.toString('utf8')
81
+ presentation = xml.toString('utf8')
64
82
  }
65
83
  offset = next
66
84
  }
67
- fail('PPTX 缺少 ppt/presentation.xml', 'invalid-document')
85
+ if (presentation === undefined) fail('PPTX 缺少 ppt/presentation.xml', 'invalid-document')
86
+ return presentation
68
87
  }
69
88
  function geometry(bytes, width) {
70
89
  const xml = presentationXml(bytes), tag = xml.match(/<(?:[\w.-]+:)?sldSz\b[^>]*>/)?.[0]
@@ -83,6 +102,21 @@ function unavailable(error) {
83
102
  installationHint: `在 dsh-ppt 所在项目或独立技能目录安装:npm install --ignore-scripts ${RENDERER_PACKAGE}@${RENDERER_VERSION}。Windows 还需与 Node 架构匹配的 Microsoft Visual C++ v14 运行库。` }
84
103
  }
85
104
  function missingDependency(error) { return ['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND', 'unavailable'].includes(error?.code) }
105
+ async function loadRenderer() {
106
+ const require = createRequire(import.meta.url)
107
+ // DSH redirects linked-package peer requests to its bundled version. Prefer
108
+ // an explicitly installed matching SDK using its physical entry; this Office
109
+ // helper has no Cordis services and must not share a stale host engine.
110
+ for (const directory of require.resolve.paths(RENDERER_PACKAGE) ?? []) {
111
+ const candidate = join(directory, RENDERER_PACKAGE, 'package.json')
112
+ let manifest
113
+ try { manifest = JSON.parse(readFileSync(candidate, 'utf8')) } catch (error) { if (error.code === 'ENOENT') continue; throw error }
114
+ if (manifest.name === RENDERER_PACKAGE && manifest.version === RENDERER_VERSION && typeof manifest.main === 'string') {
115
+ return import(pathToFileURL(require.resolve(join(dirname(candidate), manifest.main))).href)
116
+ }
117
+ }
118
+ return import(pathToFileURL(require.resolve(RENDERER_PACKAGE)).href)
119
+ }
86
120
  function reserveDirectory(base) {
87
121
  mkdirSync(dirname(base), { recursive: true })
88
122
  for (let index = 0; index < 10000; index++) {
@@ -113,7 +147,7 @@ function fontNames(values) { return [...new Set(values.flat().filter(v => typeof
113
147
  /** Optional renderer; dependency/engine absence is a result, other errors throw.
114
148
  * The second argument is an internal loader seam for testing and isolated hosts.
115
149
  */
116
- export async function renderDeck(options = {}, loadKit = () => import('@deepseek-ai/libreoffice-kit')) {
150
+ export async function renderDeck(options = {}, loadKit = loadRenderer) {
117
151
  abort(options.signal)
118
152
  const pptxPath = absolute(options.pptxPath, 'pptxPath')
119
153
  if (extname(pptxPath).toLowerCase() !== '.pptx') fail('pptxPath 需为 .pptx 文件')
@@ -125,7 +159,7 @@ export async function renderDeck(options = {}, loadKit = () => import('@deepseek
125
159
  let kit
126
160
  try { kit = await loadKit() } catch (error) { abort(options.signal); if (missingDependency(error)) return unavailable(error); throw error }
127
161
  abort(options.signal)
128
- if (kit.ENGINE_VERSION !== RENDERER_VERSION || typeof kit.createConverter !== 'function') return unavailable(new Error(`需要 ${RENDERER_PACKAGE}@${RENDERER_VERSION}`))
162
+ if (kit.ENGINE_VERSION !== RENDERER_VERSION || typeof kit.createConverter !== 'function') return unavailable(new Error(`需要 ${RENDERER_PACKAGE}@${RENDERER_VERSION},当前模块版本为 ${String(kit.ENGINE_VERSION ?? '未知')}`))
129
163
  const input = readBounded(pptxPath, INPUT_LIMIT, 'PPTX'), sourceSha256 = sha256(input), shape = geometry(input, width)
130
164
  let converter
131
165
  try { converter = await kit.createConverter({ timeoutMs, maxInputBytes: INPUT_LIMIT, maxOutputBytes: OUTPUT_LIMIT, maxArchiveEntries: 20000, maxUncompressedBytes: 512 * 1024 * 1024, fontMetadataCacheDirectory: false }) }