dsh-ppt 0.5.1 → 0.6.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,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
+ }