dsh-ppt 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -78
- package/LICENSE +21 -21
- package/README.en.md +219 -168
- package/README.md +234 -170
- package/cordis.patch.yml +19 -19
- package/docs/FEATURE-PLAN-2026-10-01.md +50 -0
- package/docs/LIBREOFFICE-INTEGRATION-2026-10-01.md +69 -0
- package/lib/execution.d.ts +8 -0
- package/lib/execution.js +41 -6
- package/lib/index.d.ts +1 -1
- package/lib/index.js +5 -5
- package/lib/tools.js +137 -15
- package/lib/types.d.ts +110 -2
- package/package.json +84 -70
- package/skills/dsh-ppt/SKILL.md +74 -69
- package/skills/dsh-ppt/package.json +11 -0
- package/skills/dsh-ppt/references/advanced.md +39 -0
- package/skills/dsh-ppt/references/copywriting.md +59 -59
- package/skills/dsh-ppt/references/syntax.md +1 -1
- package/skills/dsh-ppt/references/themes.md +61 -61
- package/skills/dsh-ppt/references/troubleshooting.md +19 -13
- package/skills/dsh-ppt/scripts/build-deck.mjs +184 -135
- package/skills/dsh-ppt/scripts/deck-advanced.mjs +462 -0
- package/skills/dsh-ppt/scripts/deck-core.mjs +691 -634
- package/skills/dsh-ppt/scripts/deck-delivery.mjs +104 -0
- package/skills/dsh-ppt/scripts/deck-media.mjs +160 -0
- package/skills/dsh-ppt/scripts/deck-render.mjs +236 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
+
`
|
|
@@ -0,0 +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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''')
|
|
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
|
+
`
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { inflateRawSync } from 'node:zlib'
|
|
5
|
+
|
|
6
|
+
export const RENDERER_PACKAGE = '@deepseek-ai/libreoffice-kit'
|
|
7
|
+
export const RENDERER_VERSION = '0.1.3'
|
|
8
|
+
const INPUT_LIMIT = 64 * 1024 * 1024
|
|
9
|
+
const OUTPUT_LIMIT = 128 * 1024 * 1024
|
|
10
|
+
const PAGE_LIMIT = 120
|
|
11
|
+
const RECEIPT_LIMIT = 1024 * 1024
|
|
12
|
+
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
|
13
|
+
|
|
14
|
+
function fail(message, code = 'invalid-render') { throw Object.assign(new Error(message), { code }) }
|
|
15
|
+
function abort(signal) { if (signal?.aborted) throw signal.reason }
|
|
16
|
+
function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex') }
|
|
17
|
+
function integer(value, fallback, min, max, name) {
|
|
18
|
+
const result = value === undefined ? fallback : value
|
|
19
|
+
if (!Number.isSafeInteger(result) || result < min || result > max) fail(`${name} 需为 ${min}–${max} 的整数`)
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
function absolute(path, name) {
|
|
23
|
+
if (typeof path !== 'string' || !isAbsolute(path)) fail(`${name} 需为绝对路径`)
|
|
24
|
+
return resolve(path)
|
|
25
|
+
}
|
|
26
|
+
function readBounded(path, limit, name) {
|
|
27
|
+
const info = lstatSync(path)
|
|
28
|
+
if (!info.isFile()) fail(`${name} 需为普通文件`)
|
|
29
|
+
if (info.size === 0 || info.size > limit) fail(`${name} 为空或超过 ${limit} 字节`, 'size-limit')
|
|
30
|
+
const bytes = readFileSync(path)
|
|
31
|
+
if (bytes.length !== info.size || bytes.length > limit) fail(`${name} 在读取时发生变化`, 'source-changed')
|
|
32
|
+
return bytes
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Read only the small presentation part to obtain slide geometry. The renderer
|
|
36
|
+
// remains responsible for validating and importing the complete Office file.
|
|
37
|
+
function presentationXml(bytes) {
|
|
38
|
+
let end = -1
|
|
39
|
+
for (let offset = bytes.length - 22; offset >= Math.max(0, bytes.length - 65557); offset--) {
|
|
40
|
+
if (bytes.readUInt32LE(offset) === 0x06054b50 && offset + 22 + bytes.readUInt16LE(offset + 20) === bytes.length) { end = offset; break }
|
|
41
|
+
}
|
|
42
|
+
if (end < 0 || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)) fail('不支持的 PPTX ZIP 容器', 'invalid-document')
|
|
43
|
+
const count = bytes.readUInt16LE(end + 10), length = bytes.readUInt32LE(end + 12), start = bytes.readUInt32LE(end + 16)
|
|
44
|
+
if (count === 65535 || count > 20000 || start + length > end) fail('PPTX ZIP 目录无效或超过限制', 'invalid-document')
|
|
45
|
+
let offset = start
|
|
46
|
+
for (let index = 0; index < count; index++) {
|
|
47
|
+
if (offset + 46 > start + length || bytes.readUInt32LE(offset) !== 0x02014b50) fail('PPTX ZIP 目录损坏', 'invalid-document')
|
|
48
|
+
const flags = bytes.readUInt16LE(offset + 8), method = bytes.readUInt16LE(offset + 10)
|
|
49
|
+
const compressed = bytes.readUInt32LE(offset + 20), expanded = bytes.readUInt32LE(offset + 24)
|
|
50
|
+
const nameLength = bytes.readUInt16LE(offset + 28), extra = bytes.readUInt16LE(offset + 30), comment = bytes.readUInt16LE(offset + 32)
|
|
51
|
+
const next = offset + 46 + nameLength + extra + comment
|
|
52
|
+
if (next > start + length) fail('PPTX ZIP 文件名无效', 'invalid-document')
|
|
53
|
+
const name = bytes.toString('utf8', offset + 46, offset + 46 + nameLength)
|
|
54
|
+
if (name === 'ppt/presentation.xml') {
|
|
55
|
+
if (flags & 1 || expanded > RECEIPT_LIMIT || compressed > RECEIPT_LIMIT) fail('PPTX 页面信息加密或超过限制', 'invalid-document')
|
|
56
|
+
const local = bytes.readUInt32LE(offset + 42)
|
|
57
|
+
if (local + 30 > start || bytes.readUInt32LE(local) !== 0x04034b50) fail('PPTX 页面信息偏移无效', 'invalid-document')
|
|
58
|
+
const dataOffset = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28)
|
|
59
|
+
if (dataOffset + compressed > start) fail('PPTX 页面信息越界', 'invalid-document')
|
|
60
|
+
const data = bytes.subarray(dataOffset, dataOffset + compressed)
|
|
61
|
+
const xml = method === 0 ? data : method === 8 ? inflateRawSync(data, { maxOutputLength: RECEIPT_LIMIT }) : fail('PPTX 压缩格式不支持', 'invalid-document')
|
|
62
|
+
if (xml.length !== expanded) fail('PPTX 页面信息长度不匹配', 'invalid-document')
|
|
63
|
+
return xml.toString('utf8')
|
|
64
|
+
}
|
|
65
|
+
offset = next
|
|
66
|
+
}
|
|
67
|
+
fail('PPTX 缺少 ppt/presentation.xml', 'invalid-document')
|
|
68
|
+
}
|
|
69
|
+
function geometry(bytes, width) {
|
|
70
|
+
const xml = presentationXml(bytes), tag = xml.match(/<(?:[\w.-]+:)?sldSz\b[^>]*>/)?.[0]
|
|
71
|
+
const cx = Number(tag?.match(/\bcx\s*=\s*["'](\d+)["']/)?.[1])
|
|
72
|
+
const cy = Number(tag?.match(/\bcy\s*=\s*["'](\d+)["']/)?.[1])
|
|
73
|
+
const count = (xml.match(/<(?:[\w.-]+:)?sldId\b/g) ?? []).length
|
|
74
|
+
if (!Number.isSafeInteger(cx) || !Number.isSafeInteger(cy) || cx <= 0 || cy <= 0 || !count || count > PAGE_LIMIT) fail('PPTX 页面尺寸或页数无效(最多120页)', 'invalid-document')
|
|
75
|
+
const dpi = width * 914400 / cx
|
|
76
|
+
if (dpi < 24 || dpi > 600 || width * cy / cx > 8192) fail('目标宽度超出此 PPTX 页面尺寸的渲染限制', 'size-limit')
|
|
77
|
+
return { dpi, slideCount: count }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function unavailable(error) {
|
|
81
|
+
return { ok: false, status: 'unavailable', backend: 'libreoffice-kit',
|
|
82
|
+
message: `PPTX 渲染引擎不可用:${error?.message ?? String(error)}`,
|
|
83
|
+
installationHint: `在 dsh-ppt 所在项目或独立技能目录安装:npm install --ignore-scripts ${RENDERER_PACKAGE}@${RENDERER_VERSION}。Windows 还需与 Node 架构匹配的 Microsoft Visual C++ v14 运行库。` }
|
|
84
|
+
}
|
|
85
|
+
function missingDependency(error) { return ['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND', 'unavailable'].includes(error?.code) }
|
|
86
|
+
function reserveDirectory(base) {
|
|
87
|
+
mkdirSync(dirname(base), { recursive: true })
|
|
88
|
+
for (let index = 0; index < 10000; index++) {
|
|
89
|
+
const candidate = index ? `${base}-${index}` : base
|
|
90
|
+
try { mkdirSync(candidate); return candidate } catch (error) { if (error.code !== 'EEXIST') throw error }
|
|
91
|
+
}
|
|
92
|
+
fail('渲染目录重名过多,请指定新的 outputDir')
|
|
93
|
+
}
|
|
94
|
+
function cleanupOwned(root, owned) {
|
|
95
|
+
// Only delete the freshly created staging/artifacts subtree. Never recursively
|
|
96
|
+
// remove a requested directory or pre-existing user output.
|
|
97
|
+
if (root && owned && dirname(resolve(owned)) === resolve(root) && (basename(owned).startsWith('.partial-') || basename(owned) === 'artifacts')) rmSync(owned, { recursive: true, force: true })
|
|
98
|
+
if (root) { try { rmdirSync(root) } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error } }
|
|
99
|
+
}
|
|
100
|
+
function relativeFile(root, path) {
|
|
101
|
+
const value = relative(root, absolute(path, '引擎输出路径'))
|
|
102
|
+
if (!value || value === '..' || value.startsWith(`..${sep}`) || isAbsolute(value)) fail('引擎输出路径不在本次目录内')
|
|
103
|
+
return value.split(sep).join('/')
|
|
104
|
+
}
|
|
105
|
+
function fileRecord(root, path, budget) {
|
|
106
|
+
const file = relativeFile(root, path), bytes = readBounded(path, OUTPUT_LIMIT, '渲染输出')
|
|
107
|
+
budget.bytes += bytes.length
|
|
108
|
+
if (budget.bytes > OUTPUT_LIMIT) fail('本次渲染输出超过128MiB', 'size-limit')
|
|
109
|
+
return { record: { file, byteLength: bytes.length, sha256: sha256(bytes) }, bytes }
|
|
110
|
+
}
|
|
111
|
+
function fontNames(values) { return [...new Set(values.flat().filter(v => typeof v === 'string'))].sort() }
|
|
112
|
+
|
|
113
|
+
/** Optional renderer; dependency/engine absence is a result, other errors throw.
|
|
114
|
+
* The second argument is an internal loader seam for testing and isolated hosts.
|
|
115
|
+
*/
|
|
116
|
+
export async function renderDeck(options = {}, loadKit = () => import('@deepseek-ai/libreoffice-kit')) {
|
|
117
|
+
abort(options.signal)
|
|
118
|
+
const pptxPath = absolute(options.pptxPath, 'pptxPath')
|
|
119
|
+
if (extname(pptxPath).toLowerCase() !== '.pptx') fail('pptxPath 需为 .pptx 文件')
|
|
120
|
+
const format = options.format ?? 'png'
|
|
121
|
+
if (!['png', 'pdf', 'both'].includes(format)) fail('format 需为 png / pdf / both')
|
|
122
|
+
const width = integer(options.width, 1440, 320, 3840, 'width')
|
|
123
|
+
const timeoutMs = integer(options.timeoutMs, 120000, 1000, 300000, 'timeoutMs')
|
|
124
|
+
const base = options.outputDir === undefined ? join(dirname(pptxPath), `${basename(pptxPath, extname(pptxPath))}-render`) : absolute(options.outputDir, 'outputDir')
|
|
125
|
+
let kit
|
|
126
|
+
try { kit = await loadKit() } catch (error) { abort(options.signal); if (missingDependency(error)) return unavailable(error); throw error }
|
|
127
|
+
abort(options.signal)
|
|
128
|
+
if (kit.ENGINE_VERSION !== RENDERER_VERSION || typeof kit.createConverter !== 'function') return unavailable(new Error(`需要 ${RENDERER_PACKAGE}@${RENDERER_VERSION}`))
|
|
129
|
+
const input = readBounded(pptxPath, INPUT_LIMIT, 'PPTX'), sourceSha256 = sha256(input), shape = geometry(input, width)
|
|
130
|
+
let converter
|
|
131
|
+
try { converter = await kit.createConverter({ timeoutMs, maxInputBytes: INPUT_LIMIT, maxOutputBytes: OUTPUT_LIMIT, maxArchiveEntries: 20000, maxUncompressedBytes: 512 * 1024 * 1024, fontMetadataCacheDirectory: false }) }
|
|
132
|
+
catch (error) { abort(options.signal); if (missingDependency(error)) return unavailable(error); throw error }
|
|
133
|
+
let root, staging, committed = false
|
|
134
|
+
const deadline = new AbortController()
|
|
135
|
+
const timer = setTimeout(() => deadline.abort(Object.assign(new Error(`PPTX 渲染超过 ${timeoutMs}ms`), { code: 'timeout' })), timeoutMs)
|
|
136
|
+
timer.unref()
|
|
137
|
+
const operationSignal = options.signal ? AbortSignal.any([options.signal, deadline.signal]) : deadline.signal
|
|
138
|
+
try {
|
|
139
|
+
abort(operationSignal)
|
|
140
|
+
root = reserveDirectory(base)
|
|
141
|
+
staging = join(root, `.partial-${randomUUID()}`)
|
|
142
|
+
mkdirSync(staging)
|
|
143
|
+
const snapshot = join(staging, 'source.pptx')
|
|
144
|
+
writeFileSync(snapshot, input, { flag: 'wx' })
|
|
145
|
+
const destination = join(root, 'artifacts'), budget = { bytes: 0 }, fonts = []
|
|
146
|
+
const receipt = { schemaVersion: 1, producer: 'dsh-ppt-render', status: 'rendered', visual: 'not-verified', createdAt: new Date().toISOString(),
|
|
147
|
+
renderer: { package: RENDERER_PACKAGE, version: RENDERER_VERSION, backend: converter.backend },
|
|
148
|
+
source: { sha256: sourceSha256, byteLength: input.length }, format, slideCount: shape.slideCount, missingFonts: [], pngs: [] }
|
|
149
|
+
if (!['native', 'wasm'].includes(converter.backend)) fail('引擎未返回有效 backend')
|
|
150
|
+
if (format !== 'pdf') {
|
|
151
|
+
const images = await converter.renderImages({ inputPath: snapshot, outputDir: join(staging, 'pages'), pages: 'all', dpi: shape.dpi, maxPages: PAGE_LIMIT, maxPixels: 16777216, maxDimension: 8192 }, operationSignal)
|
|
152
|
+
abort(operationSignal)
|
|
153
|
+
if (images.sourceSha256 !== sourceSha256 || !Number.isSafeInteger(images.pageCount) || images.pageCount < 1 || images.pageCount > PAGE_LIMIT || images.images?.length !== images.pageCount) fail('引擎未返回完整且匹配输入的逐页图片')
|
|
154
|
+
receipt.slideCount = images.pageCount
|
|
155
|
+
fonts.push(images.missingFonts ?? [])
|
|
156
|
+
const seen = new Set()
|
|
157
|
+
for (const item of images.images) {
|
|
158
|
+
const { record, bytes } = fileRecord(staging, item.path, budget)
|
|
159
|
+
if (!Number.isSafeInteger(item.page) || item.page < 1 || item.page > images.pageCount || seen.has(item.page)) fail('引擎返回重复或无效页码')
|
|
160
|
+
seen.add(item.page)
|
|
161
|
+
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || bytes.readUInt32BE(16) !== item.width || bytes.readUInt32BE(20) !== item.height || item.width * item.height > 16777216) fail('引擎PNG格式或尺寸不匹配')
|
|
162
|
+
receipt.pngs.push({ page: item.page, ...record, width: item.width, height: item.height })
|
|
163
|
+
}
|
|
164
|
+
receipt.pngs.sort((a, b) => a.page - b.page)
|
|
165
|
+
const manifest = { ...images, inputPath: pptxPath, images: images.images.map(item => ({ ...item, path: join(destination, relativeFile(staging, item.path)) })) }
|
|
166
|
+
const manifestPath = join(staging, 'pages', 'manifest.json')
|
|
167
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
168
|
+
receipt.manifest = fileRecord(staging, manifestPath, budget).record
|
|
169
|
+
}
|
|
170
|
+
if (format !== 'png') {
|
|
171
|
+
const pdfPath = join(staging, 'document.pdf'), exported = await converter.convert({ inputPath: snapshot, outputPath: pdfPath }, operationSignal)
|
|
172
|
+
abort(operationSignal)
|
|
173
|
+
const { record, bytes } = fileRecord(staging, pdfPath, budget)
|
|
174
|
+
if (!bytes.subarray(0, 5).equals(Buffer.from('%PDF-'))) fail('引擎未生成有效PDF')
|
|
175
|
+
receipt.pdf = record
|
|
176
|
+
fonts.push(exported.missingFonts ?? [])
|
|
177
|
+
}
|
|
178
|
+
receipt.missingFonts = fontNames(fonts)
|
|
179
|
+
abort(operationSignal)
|
|
180
|
+
unlinkSync(snapshot)
|
|
181
|
+
writeFileSync(join(staging, 'render-receipt.json'), JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' })
|
|
182
|
+
// Await native cleanup before publishing the output directory.
|
|
183
|
+
await converter.dispose()
|
|
184
|
+
converter = undefined
|
|
185
|
+
abort(operationSignal)
|
|
186
|
+
if (sha256(readBounded(pptxPath, INPUT_LIMIT, 'PPTX')) !== sourceSha256) fail('PPTX 在渲染期间已修改,请重新渲染', 'source-changed')
|
|
187
|
+
if (existsSync(destination)) fail('本次渲染提交目录发生冲突')
|
|
188
|
+
renameSync(staging, destination)
|
|
189
|
+
staging = undefined
|
|
190
|
+
committed = true
|
|
191
|
+
return resultFromReceipt(receipt, join(destination, 'render-receipt.json'))
|
|
192
|
+
} finally {
|
|
193
|
+
if (converter) { try { await converter.dispose() } catch { /* Preserve the original operation/cancellation error. */ } }
|
|
194
|
+
clearTimeout(timer)
|
|
195
|
+
if (!committed) cleanupOwned(root, staging)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function resultFromReceipt(receipt, receiptPath) {
|
|
200
|
+
const root = dirname(receiptPath)
|
|
201
|
+
return { ok: true, status: 'rendered', backend: receipt.renderer.backend, rendererVersion: receipt.renderer.version,
|
|
202
|
+
slideCount: receipt.slideCount, sourceSha256: receipt.source.sha256, missingFonts: receipt.missingFonts,
|
|
203
|
+
...(receipt.pngs.length ? { pngPaths: receipt.pngs.map(image => resolve(root, image.file)) } : {}),
|
|
204
|
+
...(receipt.pdf ? { pdfPath: resolve(root, receipt.pdf.file) } : {}), receiptPath, outputDir: dirname(root), visual: 'not-verified' }
|
|
205
|
+
}
|
|
206
|
+
function unverified(message, reason) { return { ok: false, status: 'not-verified', message, reason, visual: 'not-verified' } }
|
|
207
|
+
function receiptFile(root, record) {
|
|
208
|
+
if (!record || typeof record.file !== 'string' || isAbsolute(record.file) || record.file.includes('\\') || record.file.split('/').some(part => !part || part === '.' || part === '..') || !/^[0-9a-f]{64}$/.test(record.sha256) || !Number.isSafeInteger(record.byteLength) || record.byteLength < 1 || record.byteLength > OUTPUT_LIMIT) fail('渲染收据文件记录无效')
|
|
209
|
+
const path = resolve(root, record.file)
|
|
210
|
+
relativeFile(root, path)
|
|
211
|
+
const bytes = readBounded(path, OUTPUT_LIMIT, '渲染收据输出')
|
|
212
|
+
if (bytes.length !== record.byteLength || sha256(bytes) !== record.sha256) fail('渲染输出内容已变化')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Revalidate provenance and output integrity; this never certifies visual quality. */
|
|
216
|
+
export function readRenderReceipt(pptxPath, receiptPath) {
|
|
217
|
+
try {
|
|
218
|
+
const inputPath = absolute(pptxPath, 'pptxPath'), path = absolute(receiptPath, 'receiptPath')
|
|
219
|
+
const receipt = JSON.parse(readBounded(path, RECEIPT_LIMIT, '渲染收据').toString('utf8'))
|
|
220
|
+
if (receipt.schemaVersion !== 1 || receipt.producer !== 'dsh-ppt-render' || receipt.status !== 'rendered' || receipt.renderer?.package !== RENDERER_PACKAGE || receipt.renderer?.version !== RENDERER_VERSION || !['native', 'wasm'].includes(receipt.renderer?.backend) || !['png', 'pdf', 'both'].includes(receipt.format) || !Number.isSafeInteger(receipt.slideCount) || receipt.slideCount < 1 || receipt.slideCount > PAGE_LIMIT || !Array.isArray(receipt.pngs) || !Array.isArray(receipt.missingFonts) || receipt.missingFonts.some(name => typeof name !== 'string') || !/^[0-9a-f]{64}$/.test(receipt.source?.sha256)) return unverified('收据格式或渲染版本不匹配', 'invalid-receipt')
|
|
221
|
+
const input = readBounded(inputPath, INPUT_LIMIT, 'PPTX')
|
|
222
|
+
if (input.length !== receipt.source.byteLength || sha256(input) !== receipt.source.sha256) return unverified('PPTX 与渲染时的内容不同', 'source-changed')
|
|
223
|
+
if ((receipt.format !== 'pdf' && (receipt.pngs.length !== receipt.slideCount || !receipt.manifest)) || (receipt.format !== 'png' && !receipt.pdf) || (receipt.format === 'pdf' && receipt.pngs.length) || (receipt.format === 'png' && receipt.pdf)) return unverified('收据缺少完整渲染输出', 'incomplete-output')
|
|
224
|
+
const pages = new Set()
|
|
225
|
+
const records = [...receipt.pngs, ...(receipt.pdf ? [receipt.pdf] : []), ...(receipt.manifest ? [receipt.manifest] : [])]
|
|
226
|
+
if (records.reduce((sum, record) => sum + (Number.isSafeInteger(record?.byteLength) ? record.byteLength : OUTPUT_LIMIT + 1), 0) > OUTPUT_LIMIT) return unverified('收据输出总量超过128MiB', 'invalid-receipt')
|
|
227
|
+
for (const image of receipt.pngs) {
|
|
228
|
+
if (!Number.isSafeInteger(image.page) || image.page < 1 || image.page > receipt.slideCount || pages.has(image.page) || !Number.isSafeInteger(image.width) || !Number.isSafeInteger(image.height) || image.width < 1 || image.height < 1 || image.width * image.height > 16777216) return unverified('收据页码或图片尺寸无效', 'invalid-receipt')
|
|
229
|
+
pages.add(image.page)
|
|
230
|
+
receiptFile(dirname(path), image)
|
|
231
|
+
}
|
|
232
|
+
if (receipt.pdf) receiptFile(dirname(path), receipt.pdf)
|
|
233
|
+
if (receipt.manifest) receiptFile(dirname(path), receipt.manifest)
|
|
234
|
+
return resultFromReceipt(receipt, path)
|
|
235
|
+
} catch (error) { return unverified(`收据验证失败:${error.message}`, 'invalid-or-changed-output') }
|
|
236
|
+
}
|