dsh-ppt-studio 1.1.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.
Files changed (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +480 -0
  3. package/agent-presets/ppt/agent.cordis.yml +308 -0
  4. package/agent-presets/ppt/preset.yml +3 -0
  5. package/cordis.patch.yml +29 -0
  6. package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
  7. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
  8. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
  9. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
  10. package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
  11. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
  12. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
  13. package/docs/fidelity-report.md +225 -0
  14. package/examples/fx/fx-mini/deck.yaml +39 -0
  15. package/examples/fx/fx-mini/pages/01.yaml +6 -0
  16. package/examples/fx/fx-mini/pages/02.yaml +2 -0
  17. package/examples/fx/fx-mini/pages/03.yaml +6 -0
  18. package/examples/fx/fx-pro/deck.yaml +48 -0
  19. package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
  20. package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
  21. package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
  22. package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
  23. package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
  24. package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
  25. package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
  26. package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
  27. package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
  28. package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
  29. package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
  30. package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
  31. package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
  32. package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
  33. package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
  34. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
  35. package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
  36. package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
  37. package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
  38. package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
  39. package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
  40. package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
  41. package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
  42. package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
  43. package/examples/fx/fx-pro/preview/deck.html +10 -0
  44. package/examples/fx/fx-pro/preview/layout.json +2325 -0
  45. package/examples/fx/fx-pro/preview/measured.json +814 -0
  46. package/examples/fx/fx-pro.pptx +0 -0
  47. package/examples/fx/ren-single/01.png +0 -0
  48. package/examples/fx/ren-spliced/01.png +0 -0
  49. package/examples/fx/ren-spliced/03.png +0 -0
  50. package/examples/fx/seed.pptx +0 -0
  51. package/examples/fx/single.pptx +0 -0
  52. package/examples/fx/spliced.pptx +0 -0
  53. package/examples/smoke/deck.yaml +26 -0
  54. package/examples/smoke/imported/deck.yaml +30 -0
  55. package/examples/smoke/imported/import-styles.json +126 -0
  56. package/examples/smoke/imported/pages/slide_01.yaml +31 -0
  57. package/examples/smoke/imported/pages/slide_02.yaml +38 -0
  58. package/examples/smoke/imported/pages/slide_03.yaml +55 -0
  59. package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
  60. package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
  61. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
  62. package/examples/smoke/imported/preview-rt/deck.html +1 -0
  63. package/examples/smoke/imported/preview-rt/layout.json +434 -0
  64. package/examples/smoke/imported/reference/previews/01.png +0 -0
  65. package/examples/smoke/imported/reference/previews/02.png +0 -0
  66. package/examples/smoke/imported/reference/previews/03.png +0 -0
  67. package/examples/smoke/imported/source.pptx +0 -0
  68. package/examples/smoke/media-test.png +0 -0
  69. package/examples/smoke/out-smoke.pptx +0 -0
  70. package/examples/smoke/pages/01_cover.yaml +27 -0
  71. package/examples/smoke/pages/02_content.yaml +42 -0
  72. package/examples/smoke/pages/03_chart.yaml +35 -0
  73. package/examples/smoke/preview/01_pages_01_cover.html +1 -0
  74. package/examples/smoke/preview/02_pages_02_content.html +1 -0
  75. package/examples/smoke/preview/03_pages_03_chart.html +1 -0
  76. package/examples/smoke/preview/deck.html +1 -0
  77. package/examples/smoke/preview/layout.json +378 -0
  78. package/lib/autodeclare.js +40 -0
  79. package/lib/commands.js +161 -0
  80. package/lib/crosscheck.js +300 -0
  81. package/lib/imgmeta.js +55 -0
  82. package/lib/index.js +239 -0
  83. package/lib/measurement.js +209 -0
  84. package/lib/msrender.js +123 -0
  85. package/lib/pptd/export-pptx.js +686 -0
  86. package/lib/pptd/import-pptx.js +774 -0
  87. package/lib/pptd/layout.js +209 -0
  88. package/lib/pptd/render-html.js +254 -0
  89. package/lib/pptd/schema.js +518 -0
  90. package/lib/pptd/svgCharts.js +147 -0
  91. package/lib/pptxPy.js +205 -0
  92. package/lib/preset-delivery.js +195 -0
  93. package/lib/preview-server.js +164 -0
  94. package/lib/router.js +165 -0
  95. package/lib/scaffold.js +315 -0
  96. package/lib/skill.js +174 -0
  97. package/lib/splice.js +219 -0
  98. package/lib/state.js +88 -0
  99. package/lib/surgical.js +369 -0
  100. package/lib/templates.js +400 -0
  101. package/lib/tools.js +955 -0
  102. package/lib/verify.js +828 -0
  103. package/lib/xmljs.js +112 -0
  104. package/lib/zips.js +117 -0
  105. package/package.json +81 -0
  106. package/scripts/audit-manual-facts.mjs +294 -0
  107. package/scripts/audit-template-fidelity.mjs +162 -0
  108. package/scripts/bench-m2.mjs +48 -0
  109. package/scripts/build-templates.mjs +63 -0
  110. package/scripts/build.mjs +14 -0
  111. package/scripts/build.sh +7 -0
  112. package/scripts/check-lib-freshness.mjs +70 -0
  113. package/scripts/check-preset.mjs +189 -0
  114. package/scripts/check-tracked-lf.mjs +84 -0
  115. package/scripts/dbg-import.mjs +13 -0
  116. package/scripts/dbg-media.mjs +10 -0
  117. package/scripts/dbg-tpl-inventory.mjs +23 -0
  118. package/scripts/dump-workflow-baseline.mjs +38 -0
  119. package/scripts/e2e-1.0.mjs +129 -0
  120. package/scripts/eval-skills-blind.mjs +91 -0
  121. package/scripts/eval-skills-score.mjs +129 -0
  122. package/scripts/export-orig-templates.ps1 +17 -0
  123. package/scripts/fixtures/workflow-baseline.json +300 -0
  124. package/scripts/fixtures.mjs +501 -0
  125. package/scripts/import-user-templates.mjs +60 -0
  126. package/scripts/install-desktop.mjs +312 -0
  127. package/scripts/install.mjs +209 -0
  128. package/scripts/migrate-templates-dualtrack.mjs +71 -0
  129. package/scripts/preflight-1.0.mjs +198 -0
  130. package/scripts/probe-asset-freshness.mjs +57 -0
  131. package/scripts/probe-effect-semantics.mjs +69 -0
  132. package/scripts/probe-reparse-safety.mjs +32 -0
  133. package/scripts/rebuild-templates-c11.mjs +69 -0
  134. package/scripts/regression-real.mjs +84 -0
  135. package/scripts/release-sync.mjs +425 -0
  136. package/scripts/shot-templates.mjs +49 -0
  137. package/scripts/smoke.mjs +2512 -0
  138. package/scripts/verify-bundle-install.mjs +281 -0
  139. package/scripts/verify-fresh-install.mjs +260 -0
  140. package/skills/ppt-studio-copy/SKILL.md +139 -0
  141. package/skills/ppt-studio-craft/SKILL.md +171 -0
  142. package/skills/ppt-studio-data/SKILL.md +173 -0
  143. package/skills/ppt-studio-manual/SKILL.md +104 -0
  144. package/templates/academic-white/deck.yaml +24 -0
  145. package/templates/academic-white/pages/_chart.yaml +43 -0
  146. package/templates/academic-white/pages/_compare.yaml +37 -0
  147. package/templates/academic-white/pages/_content.yaml +35 -0
  148. package/templates/academic-white/pages/_cover.yaml +28 -0
  149. package/templates/academic-white/pages/_ending.yaml +28 -0
  150. package/templates/academic-white/pages/_flow.yaml +61 -0
  151. package/templates/academic-white/preview.png +0 -0
  152. package/templates/academic-white/template.yaml +14 -0
  153. package/templates/business-blue/deck.yaml +28 -0
  154. package/templates/business-blue/pages/_chart.yaml +36 -0
  155. package/templates/business-blue/pages/_compare.yaml +45 -0
  156. package/templates/business-blue/pages/_content.yaml +80 -0
  157. package/templates/business-blue/pages/_cover.yaml +39 -0
  158. package/templates/business-blue/pages/_ending.yaml +32 -0
  159. package/templates/business-blue/pages/_flow.yaml +87 -0
  160. package/templates/business-blue/preview.png +0 -0
  161. package/templates/business-blue/template.yaml +14 -0
  162. package/templates/pitch-bold/deck.yaml +23 -0
  163. package/templates/pitch-bold/pages/_chart.yaml +32 -0
  164. package/templates/pitch-bold/pages/_compare.yaml +36 -0
  165. package/templates/pitch-bold/pages/_content.yaml +58 -0
  166. package/templates/pitch-bold/pages/_cover.yaml +31 -0
  167. package/templates/pitch-bold/pages/_ending.yaml +27 -0
  168. package/templates/pitch-bold/pages/_flow.yaml +46 -0
  169. package/templates/pitch-bold/preview.png +0 -0
  170. package/templates/pitch-bold/template.yaml +14 -0
  171. package/templates/tech-dark/deck.yaml +24 -0
  172. package/templates/tech-dark/pages/_chart.yaml +27 -0
  173. package/templates/tech-dark/pages/_compare.yaml +38 -0
  174. package/templates/tech-dark/pages/_content.yaml +51 -0
  175. package/templates/tech-dark/pages/_cover.yaml +30 -0
  176. package/templates/tech-dark/pages/_ending.yaml +25 -0
  177. package/templates/tech-dark/pages/_flow.yaml +80 -0
  178. package/templates/tech-dark/preview.png +0 -0
  179. package/templates/tech-dark/template.yaml +14 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * M2 文本实测档(渲染回读精确测量,D3 落地):
3
+ * 浏览器真测量通道——Edge headless 加载预览 HTML,页面内 JS 采集每个元素的实际
4
+ * 排版几何(getBoundingClientRect / Range.getClientRects 行盒 / scrollHeight),
5
+ * JSON 回传(<script id="__ppt_measured"> 注入 DOM,--dump-dom 提取)。
6
+ * 输出 preview/measured.json(measured 档)供 ppt_verify measured=true 消费。
7
+ *
8
+ * 与估算的关系(D3):实测=终审、估算=预检——估算报错必须处理(保持门禁);
9
+ * 实测报错(估算没报)= M2 的核心捕获(估算漏报 → 新增 error);
10
+ * 无浏览器/路径失败 → 降级估算 + 标注"未实测"(绝不静默)。
11
+ *
12
+ * 诚实声明:测量 = Chromium 排版真值(系统字库)——仍非 PowerPoint 引擎;
13
+ * 与 ppt_shot 同一渲染链(HTML 预览),因此字体/尺寸差异同源。
14
+ */
15
+ import { readFile, writeFile, mkdir, rm, cp, readdir } from 'node:fs/promises'
16
+ import { existsSync } from 'node:fs'
17
+ import { join, dirname } from 'node:path'
18
+ import { spawn } from 'node:child_process'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ export function findEdge() {
22
+ const candidates = [
23
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
24
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
25
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
26
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
27
+ ]
28
+ for (const c of candidates) if (existsSync(c)) return c
29
+ return null
30
+ }
31
+
32
+ /**
33
+ * 测量脚本(注入每个页面 HTML 尾部):
34
+ * 文本元素 → 行盒(Range.getClientRects)行数/内容高/溢出;其余元素 → 实际几何对照。
35
+ * 结果写入 <script id="__ppt_measured" type="application/json">,dump-dom 可提取。
36
+ */
37
+ const MEASURE_JS = `(function(){
38
+ function collect(){
39
+ var out=[];
40
+ var els=document.querySelectorAll('.el');
41
+ for(var i=0;i<els.length;i++){
42
+ var el=els[i]; var r=el.getBoundingClientRect();
43
+ var rec={id:el.id,kind:el.dataset.kind||el.dataset.shape||'',x:Math.round(r.left*100)/100,y:Math.round(r.top*100)/100,w:Math.round(r.width*100)/100,h:Math.round(r.height*100)/100};
44
+ if(el.dataset&&el.dataset.kind==='text'){
45
+ var rects=[];
46
+ var walker=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);
47
+ var n;
48
+ while((n=walker.nextNode())){
49
+ if(!n.textContent||!n.textContent.trim())continue;
50
+ var rng=document.createRange();
51
+ rng.selectNodeContents(n);
52
+ var qs=rng.getClientRects();
53
+ for(var j=0;j<qs.length;j++)rects.push(qs[j]);
54
+ }
55
+ var tops=[];
56
+ for(var k=0;k<rects.length;k++){var t=Math.round(rects[k].top*10)/10;if(tops.indexOf(t)<0)tops.push(t);}
57
+ rec.lines=tops.length;
58
+ rec.clientHeight=el.clientHeight;
59
+ rec.contentHeight=el.scrollHeight;
60
+ rec.overflowY=Math.round((el.scrollHeight-el.clientHeight)*10)/10;
61
+ }
62
+ out.push(rec);
63
+ }
64
+ return out;
65
+ }
66
+ window.addEventListener('load',function(){setTimeout(function(){
67
+ var s=document.createElement('script');
68
+ s.id='__ppt_measured';s.type='application/json';
69
+ try{s.textContent=JSON.stringify(collect());}catch(e){s.textContent='{"__error":'+JSON.stringify(String(e))+'}'}
70
+ document.head.appendChild(s);
71
+ },250)});
72
+ })();`
73
+
74
+ const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
75
+
76
+ /** 注入测量脚本到 HTML(返回新内容)。script 是 raw text:内容原样嵌入(不实体转义——JS 里的 < 会被转坏)。 */
77
+ export function injectMeasureScript(html) {
78
+ const tag = `<script>\n${MEASURE_JS}\n</script>`
79
+ if (!html.includes('</body>')) return html + tag
80
+ return html.replace('</body>', tag + '\n</body>')
81
+ }
82
+
83
+ /** 从 dump-dom 输出提取 __ppt_measured JSON。 */
84
+ export function extractMeasured(dom) {
85
+ const m = /<script id="__ppt_measured"[^>]*>([\s\S]*?)<\/script>/.exec(dom)
86
+ if (!m) return null
87
+ try {
88
+ const data = JSON.parse(m[1])
89
+ return data && typeof data === 'object' ? data : null
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 测量一个 deck 项目:render → 每页注入脚本 → Edge dump-dom → 采集 → preview/measured.json。
97
+ * @param dir deck 目录
98
+ * @returns { measured, outDir, browser, pages, notes }(measured.json 已写入 preview/)
99
+ */
100
+ export async function measureLayout(dir, { edge } = {}) {
101
+ const { resolveDeck } = await import('./pptd/schema.js')
102
+ const { renderDeck } = await import('./pptd/render-html.js')
103
+ const ctx = await resolveDeck(dir)
104
+ const r = await renderDeck(ctx, {})
105
+ const browser = edge ?? findEdge()
106
+ const notes = []
107
+ if (!browser) {
108
+ notes.push('⚠ 未检测到 Edge/Chrome:实测档不可用(降级估算 + 标注"未实测")——请用 ppt_verify 保持估算门禁')
109
+ return { measured: null, outDir: r.outDir, browser: null, pages: 0, notes }
110
+ }
111
+ // 每次运行用**唯一**临时目录:上一轮 Edge 退出慢会短暂锁住 profile(EBUSY),
112
+ // 用固定目录名会让"连续两次 ppt_measure"直接崩(2026-09-14 自检实测)。
113
+ const tmp = join(dir, 'preview', `_measure-tmp-${process.pid}-${Date.now().toString(36)}`)
114
+ // 先清理历史遗留(跳过本次目录,尽力而为:锁住就留到下次),再建自己的目录
115
+ await sweepStaleMeasureTmp(join(dir, 'preview'), tmp)
116
+ await mkdir(tmp, { recursive: true })
117
+ const pages = []
118
+ const t0 = Date.now()
119
+ try {
120
+ // 并发测量(D5 基准:顺序 3.2s/页 → 并发 4 ≈ 0.9s/页;Edge 每页独立实例无共享状态)
121
+ const CONCURRENCY = 4
122
+ const jobs = r.htmlFiles.map((f, i) => ({ f, i }))
123
+ let cursor = 0
124
+ const results = new Array(r.htmlFiles.length).fill(null)
125
+ const errors = new Array(r.htmlFiles.length).fill(null)
126
+ const worker = async () => {
127
+ while (cursor < jobs.length) {
128
+ const idx = cursor++
129
+ const { f, i } = jobs[idx]
130
+ try {
131
+ const src = join(r.outDir, f)
132
+ const html = injectMeasureScript(await readFile(src, 'utf8'))
133
+ const probe = join(tmp, `p${i + 1}.html`)
134
+ await writeFile(probe, html, 'utf8')
135
+ // 独立 user-data-dir:Edge 多实例共用默认 profile → Singleton 锁串行化 + 变慢(shotOne 同款修复)
136
+ const profile = join(tmp, `profile-${i + 1}`)
137
+ await mkdir(profile, { recursive: true })
138
+ const url = 'file:///' + probe.replace(/\\/g, '/')
139
+ const dom = await new Promise((resolve, reject) => {
140
+ const child = spawn(browser, ['--headless=new', '--disable-gpu', '--no-first-run', '--hide-scrollbars',
141
+ '--dump-dom', '--virtual-time-budget=2500', `--user-data-dir=${profile}`,
142
+ `--window-size=${ctx.size.width},${ctx.size.height}`, url], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
143
+ let outBuf = ''
144
+ child.stdout.on('data', (d) => { outBuf += d })
145
+ child.on('error', reject)
146
+ child.on('exit', (code) => code === 0 ? resolve(outBuf) : reject(new Error(`Edge 退出码 ${code}`)))
147
+ })
148
+ const data = extractMeasured(dom)
149
+ if (!data) { errors[idx] = `第 ${i + 1} 页测量回传失败(DOM 提取空)`; continue }
150
+ if (data.__error) { errors[idx] = `第 ${i + 1} 页测量脚本异常:${data.__error}`; continue }
151
+ // pageNo = 1 基页号(**跨档配对键**,与 layout.json 同名字段);index = 内部数组下标(0 基)。
152
+ // 【2026-09-18 修】此前这里只有 `index: i + 1`(1 基),而 layout.json 的 index 是 0 基,
153
+ // 两者被 measuredCrossCheck 按 `index` 相等配对 ⇒ **永远配不上**、M2 通道恒返空结果(假绿灯)。
154
+ // 语义分工写死在这里:index = 数组下标,pageNo = 页号。
155
+ results[idx] = { pageNo: i + 1, index: i, file: f, elements: data }
156
+ } catch (e) {
157
+ errors[idx] = `第 ${i + 1} 页测量失败:${String(e)}`
158
+ }
159
+ }
160
+ }
161
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
162
+ for (let i = 0; i < results.length; i++) {
163
+ if (results[i]) pages.push(results[i])
164
+ else if (errors[i]) notes.push(`⚠ ${errors[i]};该页无实测数据`)
165
+ }
166
+ } finally {
167
+ // 尽力而为 + 短重试:Edge 句柄释放有延迟,EBUSY/EPERM 不该让测量失败
168
+ await rmWithRetry(tmp)
169
+ }
170
+ const measured = {
171
+ browser: 'Edge/Chrome headless',
172
+ ts: new Date().toISOString(),
173
+ size: { width: ctx.size.width, height: ctx.size.height },
174
+ pages,
175
+ }
176
+ await writeFile(join(r.outDir, 'measured.json'), JSON.stringify(measured, null, 2))
177
+ return { measured, outDir: r.outDir, browser, pages: pages.length, notes, elapsedMs: Date.now() - t0 }
178
+
179
+ /**
180
+ * 删除目录:对 EBUSY/EPERM 做短重试后放弃(临时目录清理不该让主流程失败)。
181
+ * Edge 退出后句柄释放有延迟,Windows 上尤其明显。
182
+ */
183
+ async function rmWithRetry(target, attempts = 5) {
184
+ for (let i = 0; i < attempts; i++) {
185
+ try {
186
+ await rm(target, { recursive: true, force: true })
187
+ return true
188
+ } catch (error) {
189
+ const code = error?.code
190
+ if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') return false
191
+ await new Promise((r) => setTimeout(r, 120 * (i + 1)))
192
+ }
193
+ }
194
+ return false
195
+ }
196
+
197
+ /** 清理历史遗留的 `_measure-tmp*`(跳过 keep=本次目录;被锁住就留到下次)。 */
198
+ async function sweepStaleMeasureTmp(previewDir, keep) {
199
+ try {
200
+ const entries = await readdir(previewDir, { withFileTypes: true })
201
+ for (const e of entries) {
202
+ if (!e.isDirectory() || !e.name.startsWith('_measure-tmp')) continue
203
+ const full = join(previewDir, e.name)
204
+ if (keep !== undefined && full === keep) continue
205
+ await rmWithRetry(full, 2)
206
+ }
207
+ } catch { /* 目录不存在或不给读:忽略 */ }
208
+ }
209
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Office 真渲染通道(v0.8.0,问题 2 落地):
3
+ * 有 Microsoft Office 时,用 PowerShell + PowerPoint COM 把任意 .pptx 逐页渲染成 PNG——
4
+ * ① 理解用户原稿(先看后做)② 成品视觉审核(P2-2 的 Office 答案)③ 原版 vs 导入版对照。
5
+ * 无 Office 时 findPowerPoint() 返回 null,整条通道自动隐藏(不影响既有工作流)。
6
+ * 安全:只读打开 + finally Quit + 超时 kill 残留 POWERPNT 进程。
7
+ */
8
+ import { writeFile, mkdir, rm } from 'node:fs/promises'
9
+ import { existsSync, readdirSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import { spawn } from 'node:child_process'
13
+ import { tmpdir } from 'node:os'
14
+
15
+ const PS1 = `param(
16
+ [string]$Pptx,
17
+ [string]$Out,
18
+ [int]$W = 1920,
19
+ [int]$H = 1080,
20
+ [string]$PagesJson = '',
21
+ [switch]$NoWatermark
22
+ )
23
+ $ErrorActionPreference = 'Stop'
24
+ $pp = $null
25
+ try {
26
+ $pp = New-Object -ComObject PowerPoint.Application
27
+ $pp.Visible = -1
28
+ $pres = $pp.Presentations.Open($Pptx, -1, 0, 0)
29
+ $n = $pres.Slides.Count
30
+ New-Item -ItemType Directory -Force -Path $Out | Out-Null
31
+ # P5 修复:多页解析("4,1" 按逗号拆——此前 -Pages 4,1 被绑定成 41)
32
+ $list = if ($PagesJson -ne '') { ($PagesJson -split ',') | ForEach-Object { [int]$_ } | Sort-Object -Unique } else { 1..$n }
33
+ foreach ($i in $list) {
34
+ $pres.Slides.Item($i).Export((Join-Path $Out ('{0:D2}.png' -f $i)), 'PNG', $W, $H)
35
+ }
36
+ $pres.Close()
37
+ # P9:真渲染图叠加轻量比例标注(1px=1pt · 960×540)+ 细边框——降低人工读图坐标误判
38
+ if (-not $NoWatermark) {
39
+ Add-Type -AssemblyName System.Drawing
40
+ foreach ($f in Get-ChildItem $Out -Filter '*.png') {
41
+ try {
42
+ $img = [System.Drawing.Image]::FromFile($f.FullName)
43
+ $g = [System.Drawing.Graphics]::FromImage($img)
44
+ $g.SmoothingMode = 'AntiAlias'
45
+ $font = New-Object System.Drawing.Font('Microsoft YaHei', 12, [System.Drawing.FontStyle]::Regular)
46
+ $brush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(110, 60, 60, 60))
47
+ $g.DrawString('1px=1pt · 960×540(比例参考)', $font, $brush, 8, ($img.Height - 34))
48
+ $pen = New-Object System.Drawing.Pen([System.Drawing.Color]::FromArgb(80, 90, 90, 90), 1)
49
+ $g.DrawRectangle($pen, 0, 0, ($img.Width - 1), ($img.Height - 1))
50
+ $g.Dispose(); $img.Dispose(); $font.Dispose(); $brush.Dispose(); $pen.Dispose()
51
+ } catch { }
52
+ }
53
+ }
54
+ Write-Output ("OK:" + $list.Count)
55
+ } finally {
56
+ if ($pp) { try { $pp.Quit() } catch { } }
57
+ }
58
+ `
59
+
60
+ const EDGE_CANDIDATES = [
61
+ 'C:\\Program Files (x86)\\Microsoft Office\\root\\Office16\\POWERPNT.EXE',
62
+ 'C:\\Program Files\\Microsoft Office\\root\\Office16\\POWERPNT.EXE',
63
+ 'C:\\Program Files\\Microsoft Office\\root\\Office16\\POWERPNT.EXE',
64
+ 'C:\\Program Files (x86)\\Microsoft Office\\root\\Office15\\POWERPNT.EXE',
65
+ 'C:\\Program Files\\Microsoft Office\\root\\Office15\\POWERPNT.EXE',
66
+ ]
67
+
68
+ /** 能力探测:本机是否有 PowerPoint(后续真用 COM 时再验证,失败视为不可用)。 */
69
+ export function findPowerPoint() {
70
+ for (const p of EDGE_CANDIDATES) if (existsSync(p)) return p
71
+ const base = ['C:\\Program Files\\Microsoft Office', 'C:\\Program Files (x86)\\Microsoft Office']
72
+ for (const b of base) {
73
+ try {
74
+ const found = readdirSync(b).filter((d) => /^Office\d+$/.test(d)).map((d) => join(b, d, 'POWERPNT.EXE')).find((p) => existsSync(p))
75
+ if (found) return found
76
+ } catch { /* 目录不存在 */ }
77
+ }
78
+ return null
79
+ }
80
+
81
+ /**
82
+ * 渲染 pptx → 逐页 PNG。
83
+ * @returns { pages, outDir, files };COM 不可用/渲染失败抛错(调用方负责降级)。
84
+ */
85
+ export async function renderPptxToPng(pptx, outDir, { width = 1920, height = 1080, timeoutMs = 240000, pages, noWatermark = false } = {}) {
86
+ if (!existsSync(pptx)) throw new Error(`pptx 不存在:${pptx}`)
87
+ await mkdir(outDir, { recursive: true })
88
+ const ps1 = join(tmpdir(), `dsh-ppt-render-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ps1`)
89
+ await writeFile(ps1, '\ufeff' + PS1, 'utf8') // BOM:Windows PowerShell 5.1 按 GBK 读无 BOM 文件 → 中文路径乱码
90
+ try {
91
+ const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1,
92
+ '-Pptx', pptx, '-Out', outDir, '-W', String(width), '-H', String(height)]
93
+ // A5/P5:按页渲染(单参数 JSON 串,避免 PS 数组绑定把 "4,1" 解析成 41);水印默认开(P9)
94
+ if (pages && pages.length) args.push('-PagesJson', pages.join(','))
95
+ if (noWatermark) args.push('-NoWatermark')
96
+ const stdout = await new Promise((resolve, reject) => {
97
+ const child = spawn('powershell.exe', args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
98
+ let out = ''
99
+ let err = ''
100
+ child.stdout.on('data', (d) => { out += d })
101
+ child.stderr.on('data', (d) => { err += d })
102
+ const killer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error(`PowerPoint 渲染超时(${timeoutMs}ms)`)) }, timeoutMs)
103
+ child.on('exit', (code) => {
104
+ clearTimeout(killer)
105
+ code === 0 ? resolve(out) : reject(new Error(`PowerPoint COM 渲染失败(code ${code}):${(err || out).slice(0, 500)}`))
106
+ })
107
+ child.on('error', (e) => { clearTimeout(killer); reject(e) })
108
+ })
109
+ const m = stdout.match(/OK:(\d+)/)
110
+ const files = []
111
+ if (m) {
112
+ // 文件名 = 原页号(两位数补零);按参数/全量存在者收集
113
+ const find = pages && pages.length ? pages : Array.from({ length: Number(m[1]) }, (_, i) => i + 1)
114
+ for (const i of find) {
115
+ const f = join(outDir, `${String(i).padStart(2, '0')}.png`)
116
+ if (existsSync(f)) files.push(f)
117
+ }
118
+ }
119
+ return { pages: Number(m?.[1] ?? 0), outDir, files }
120
+ } finally {
121
+ await rm(ps1, { force: true }).catch(() => {})
122
+ }
123
+ }