dsh-ppt-studio 1.1.2 → 1.1.4

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 (55) hide show
  1. package/README.md +38 -18
  2. package/agent-presets/ppt/agent.cordis.yml +22 -15
  3. package/cordis.patch.yml +19 -14
  4. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
  5. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
  6. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -0
  7. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
  8. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
  9. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
  10. package/examples/fx/fx-pro/preview/deck.html +1 -1
  11. package/examples/fx/fx-pro/preview/measured.json +1 -1
  12. package/examples/fx/fx-pro.pptx +0 -0
  13. package/examples/fx/seed.pptx +0 -0
  14. package/examples/fx/single.pptx +0 -0
  15. package/examples/fx/spliced.pptx +0 -0
  16. package/examples/smoke/imported/deck.yaml +6 -6
  17. package/examples/smoke/imported/import-styles.json +1 -33
  18. package/examples/smoke/imported/loss-audit.json +99 -0
  19. package/examples/smoke/imported/pages/slide_03.yaml +5 -36
  20. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
  21. package/examples/smoke/imported/preview-rt/deck.html +1 -1
  22. package/examples/smoke/imported/preview-rt/layout.json +26 -78
  23. package/examples/smoke/imported/reference/previews/03.png +0 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/03_pages_03_chart.html +1 -1
  27. package/examples/smoke/preview/deck.html +1 -1
  28. package/lib/capabilities.js +189 -0
  29. package/lib/home.js +32 -0
  30. package/lib/index.js +278 -112
  31. package/lib/office-qa.js +111 -0
  32. package/lib/pptd/export-pptx.js +159 -10
  33. package/lib/pptd/import-pptx.js +12 -0
  34. package/lib/pptd/loss-audit.js +363 -0
  35. package/lib/pptd/nativeChart.js +454 -0
  36. package/lib/pptd/schema.js +48 -1
  37. package/lib/pptd/svgCharts.js +180 -37
  38. package/lib/pptxPy.js +96 -11
  39. package/lib/preset-delivery.js +107 -9
  40. package/lib/preview-server.js +7 -5
  41. package/lib/router.js +3 -2
  42. package/lib/state.js +8 -6
  43. package/lib/templates.js +4 -4
  44. package/lib/tools.js +81 -21
  45. package/package.json +2 -1
  46. package/scripts/audit-manual-facts.mjs +4 -2
  47. package/scripts/e2e-1.0.mjs +2 -2
  48. package/scripts/fixtures/workflow-baseline.json +10 -10
  49. package/scripts/install.mjs +13 -11
  50. package/scripts/smoke.mjs +390 -66
  51. package/scripts/verify-loss-audit.mjs +245 -0
  52. package/scripts/verify-native-charts.mjs +529 -0
  53. package/scripts/verify-preset-scope.mjs +593 -0
  54. package/skills/ppt-studio-data/SKILL.md +5 -7
  55. package/skills/ppt-studio-manual/SKILL.md +2 -2
@@ -0,0 +1,529 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 原生可编辑图表验证(第三轮 / 共享任务 task-3)——**可复跑、退出码 0/1**。
4
+ *
5
+ * 为什么需要:`chart` 第三轮起默认产出**原生可编辑图表**(PowerPoint 里能"编辑数据"、能换类型、
6
+ * 数据随文件走)。这类交付"看起来对"太容易——图表帧写进 slide 但 PowerPoint 静默丢弃、
7
+ * 内嵌工作簿是空壳、缓存数据与工作簿不一致,都会让入口预览全绿而成品不可用。所以本脚本**只认真实证据**:
8
+ *
9
+ * ① 真 PowerPoint COM 渲染(无 Office 时退 LibreOffice kit)→ 逐页 PNG + **像素级**确认图形真的画出来了
10
+ * (不是"能打开"就算过:按系列色统计渲染图里的命中像素数);
11
+ * ② 独立阅读器 python-pptx 反读 → `has_chart === true`、`chart_type` 与 DSL 一致、`series.values` 与 DSL 一致;
12
+ * ③ 内嵌工作簿用**另一个独立阅读器** openpyxl 反读 → 网格数据与 deck 数据逐格一致(数据随文件走);
13
+ * ④ 包内部件清单(ppt/charts/*.xml、ppt/embeddings/*.xlsx、slide rels、Content_Types)+ 官方
14
+ * `check_office.py` 结构自证 pass;
15
+ * ⑤ 预览对齐(SVG):图例/坐标轴/网格线/数据标签该画就画,矢量降级路径不画(预览 == 成品);
16
+ * ⑥ **负面对照**:篡改内嵌工作簿数值 / 删掉工作簿部件 / 删掉一个 chart 部件 —— parity 判据必须报红。
17
+ * (不做负对照的判据等于没判据:它可能永远为绿。)
18
+ *
19
+ * 用法:
20
+ * node scripts/verify-native-charts.mjs
21
+ * PPT_STUDIO_RESOURCES_DIR=<install>/resources node scripts/verify-native-charts.mjs # 指到官方 office 资产
22
+ * PPT_STUDIO_ALLOW_SKIP_OFFICE=1 … # 无 COM/官方资产时降级为 SKIP(不计 FAIL)
23
+ *
24
+ * 临时产物全部在系统 TEMP 下的 `pptd-native-chart-verify/`(不往仓库里塞东西)。
25
+ */
26
+ import { spawnSync } from 'node:child_process'
27
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
28
+ import { dirname, join } from 'node:path'
29
+ import { fileURLToPath, pathToFileURL } from 'node:url'
30
+ import { tmpdir } from 'node:os'
31
+
32
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
33
+ const allowSkip = process.env.PPT_STUDIO_ALLOW_SKIP_OFFICE === '1'
34
+
35
+ // 官方 office 资产的定位缝(capabilities.js 的 PPT_STUDIO_RESOURCES_DIR):脚本自己先探一遍桌面端常见位置,
36
+ // 免得每次都要手写环境变量(探不到就如实报"不可用",绝不假装跑过)。
37
+ function guessResources() {
38
+ const cands = [
39
+ process.env.PPT_STUDIO_RESOURCES_DIR,
40
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'Programs', 'DeepSeek Harness', 'resources') : null,
41
+ process.env.ProgramFiles ? join(process.env.ProgramFiles, 'DeepSeek Harness', 'resources') : null,
42
+ 'C:\\Program Files\\DeepSeek Harness\\resources',
43
+ ].filter(Boolean)
44
+ for (const c of cands) if (existsSync(join(c, 'runtime', 'office-skills'))) return c
45
+ return null
46
+ }
47
+ const res = guessResources()
48
+ if (res) process.env.PPT_STUDIO_RESOURCES_DIR = res
49
+
50
+ const { resolveDeck } = await import('../lib/pptd/schema.js')
51
+ const { validatePage, chartOptions } = await import('../lib/pptd/schema.js')
52
+ const { exportPptx } = await import('../lib/pptd/export-pptx.js')
53
+ const { renderDeck } = await import('../lib/pptd/render-html.js')
54
+ const { normalizePage } = await import('../lib/pptd/layout.js')
55
+ const { resolveChart } = await import('../lib/pptd/svgCharts.js')
56
+ const { auditNativeCharts, chartPartNames, sheetGridFromXlsx } = await import('../lib/pptd/nativeChart.js')
57
+ const { zipRead, zipWrite } = await import('../lib/zips.js')
58
+ const { renderPptxToPng, findPowerPoint } = await import('../lib/msrender.js')
59
+ const { runOfficeStructureCheck, runLibreOfficeRender } = await import('../lib/office-qa.js')
60
+ const { findAnyPython } = await import('../lib/capabilities.js')
61
+
62
+ // ── 断言脚手架 ─────────────────────────────────────────────────────────────
63
+ let pass = 0
64
+ let fail = 0
65
+ const check = (name, ok, detail = '') => {
66
+ if (ok) pass++
67
+ else fail++
68
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n └─ ${detail}` : ''}`)
69
+ }
70
+ const h = (t) => console.log(`\n=== ${t} ===`)
71
+ const trunc = (s, n = 900) => {
72
+ const t = String(s).replace(/\s+/g, ' ').trim()
73
+ return t.length > n ? t.slice(0, n) + ' …' : t
74
+ }
75
+
76
+ // ── 测试 deck(数据唯一真相:YAML 由这里的常量生成,避免"手写 YAML vs 断言常量"漂移)────
77
+ const WORK = join(tmpdir(), 'pptd-native-chart-verify')
78
+ const DECK = join(WORK, 'deck')
79
+
80
+ // 图表 0:多系列柱状(默认 native;legend auto → 多系列显示;labels auto → 柱状不显示)
81
+ // 图表 1:折线(显式 legend: right + labels: true)
82
+ // 图表 2:饼图(legend auto → 显示;labels auto → 显示百分比+分类名)
83
+ // 图表 3:render: vector 降级(仍走矢量拼绘,不产图表部件)
84
+ const CHARTS = [
85
+ {
86
+ id: 'bar2', page: 1, bounds: [40, 60, 500, 300],
87
+ type: 'bar', colors: ['#2563EB', '#F59E0B'],
88
+ cols: ['季度', '营收', '成本'],
89
+ rows: [['Q1', 12.5, 8], ['Q2', 30, 15], ['Q3', 18, 11], ['Q4', 42, 22]],
90
+ series: [{ name: '营收', x: '季度', y: '营收' }, { name: '成本', x: '季度', y: '成本' }],
91
+ chartType: 'COLUMN_CLUSTERED',
92
+ values: [[12.5, 30, 18, 42], [8, 15, 11, 22]],
93
+ cats: ['Q1', 'Q2', 'Q3', 'Q4'],
94
+ grid: [['季度', '营收', '成本'], ['Q1', 12.5, 8], ['Q2', 30, 15], ['Q3', 18, 11], ['Q4', 42, 22]],
95
+ native: true,
96
+ },
97
+ {
98
+ id: 'line1', page: 2, bounds: [40, 60, 500, 300],
99
+ type: 'line', legend: 'right', labels: true,
100
+ cols: ['月份', '值'],
101
+ rows: [['1月', 92], ['2月', 94], ['3月', 95]],
102
+ chartType: 'LINE_MARKERS',
103
+ values: [[92, 94, 95]],
104
+ cats: ['1月', '2月', '3月'],
105
+ grid: [['月份', '值'], ['1月', 92], ['2月', 94], ['3月', 95]],
106
+ native: true,
107
+ },
108
+ {
109
+ id: 'pie1', page: 3, bounds: [40, 60, 400, 300],
110
+ type: 'pie',
111
+ cols: ['份额', '值'],
112
+ rows: [['甲', 60], ['乙', 25], ['丙', 15]],
113
+ chartType: 'PIE',
114
+ values: [[60, 25, 15]],
115
+ cats: ['甲', '乙', '丙'],
116
+ grid: [['份额', '值'], ['甲', 60], ['乙', 25], ['丙', 15]],
117
+ palette: ['#2563EB', '#F59E0B', '#10B981'],
118
+ native: true,
119
+ },
120
+ {
121
+ id: 'vec1', page: 4, bounds: [40, 60, 400, 300],
122
+ type: 'bar', render: 'vector',
123
+ cols: ['季度', '值'],
124
+ rows: [['Q1', 12], ['Q2', 18]],
125
+ native: false,
126
+ shapeCount: 2,
127
+ },
128
+ ]
129
+
130
+ function chartYaml(c) {
131
+ const lines = [
132
+ 'pageType: content',
133
+ 'elements:',
134
+ ` - elementId: ${c.id}`,
135
+ ' elementType: chart',
136
+ ` bounds: ${JSON.stringify(c.bounds)}`,
137
+ ' chart:',
138
+ ` type: ${c.type}`,
139
+ ]
140
+ if (c.render) lines.push(` render: ${c.render}`)
141
+ if (c.legend) lines.push(` legend: ${c.legend}`)
142
+ if (c.labels !== undefined) lines.push(` labels: ${c.labels}`)
143
+ if (c.colors) lines.push(` colors: ${JSON.stringify(c.colors)}`)
144
+ lines.push(' data:', ` cols: ${JSON.stringify(c.cols)}`, ' rows:')
145
+ for (const r of c.rows) lines.push(` - ${JSON.stringify(r)}`)
146
+ if (c.series) {
147
+ lines.push(' series:')
148
+ for (const s of c.series) lines.push(` - {name: ${s.name}, x: ${s.x}, y: ${s.y}}`)
149
+ }
150
+ lines.push('')
151
+ return lines.join('\n')
152
+ }
153
+
154
+ console.log('=== pptd 原生可编辑图表验证(第三轮 / task-3)===')
155
+ console.log(`仓库:${root}`)
156
+ console.log(`临时工作区:${WORK}`)
157
+ console.log(`官方 office 资产:${res ?? '(未探到)'}|Python:${findAnyPython()?.path ?? '(无)'}|PowerPoint:${findPowerPoint() ?? '(无)'}`)
158
+
159
+ rmSync(WORK, { recursive: true, force: true })
160
+ mkdirSync(join(DECK, 'pages'), { recursive: true })
161
+ writeFileSync(join(DECK, 'deck.yaml'), [
162
+ 'version: 1', 'title: native-chart-verify', 'size: [960, 540]', 'theme:',
163
+ ' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:',
164
+ ' body: {fontSize: 16, color: "$text"}', 'pages:',
165
+ ...CHARTS.map((c) => ` - pages/${String(c.page).padStart(2, '0')}.yaml`), '',
166
+ ].join('\n'), 'utf8')
167
+ for (const c of CHARTS) writeFileSync(join(DECK, 'pages', `${String(c.page).padStart(2, '0')}.yaml`), chartYaml(c), 'utf8')
168
+
169
+ // ── 1. 导出 + parity ──────────────────────────────────────────────────────
170
+ h('1. 导出与 parity(声明的 chart 元素数 / 图表部件数 / 内嵌工作簿数据)')
171
+ const ctx = await resolveDeck(DECK)
172
+ const outPptx = join(WORK, 'out.pptx')
173
+ const report = await exportPptx(ctx, { out: outPptx })
174
+ const p = report.parity ?? {}
175
+ console.log(`parity 原始输出:\n${JSON.stringify(p, null, 1)}`)
176
+
177
+ const nativeCharts = CHARTS.filter((c) => c.native)
178
+ check('parity.ok 全绿(计数 + 内容级 + 图表部件/数据判据)', p.ok === true, `ok=${p.ok}`)
179
+ check('chartsExp == 原生图表元素数(3 个:bar/line/pie)', p.chartsExp === nativeCharts.length, `chartsExp=${p.chartsExp}`)
180
+ check('chartsOut == 包内 ppt/charts/*.xml 数', p.chartsOut === nativeCharts.length, `chartsOut=${p.chartsOut}`)
181
+ check('内嵌工作簿数 == 原生图表数', p.chartEmbedsOut === nativeCharts.length, `chartEmbedsOut=${p.chartEmbedsOut}`)
182
+ check('图表数据逐格自证通过(chartDataMismatch 为空)', (p.chartDataMismatch ?? ['x']).length === 0, `mismatch=${JSON.stringify(p.chartDataMismatch)}`)
183
+ check('图表关系链自证通过(帧 r:id → slide rels → chartN.xml → externalData → 工作簿)', (p.chartRelsMissing ?? ['x']).length === 0 && (p.chartFramesMissing ?? ['x']).length === 0, `relsMissing=${JSON.stringify(p.chartRelsMissing)} framesMissing=${JSON.stringify(p.chartFramesMissing)}`)
184
+ check('矢量降级路径仍走形状计数自证(chartShapesExp == chartShapesOut == 2)', p.chartShapesExp === 2 && p.chartShapesOut === 2, `exp=${p.chartShapesExp} out=${p.chartShapesOut}`)
185
+ check('report.nativeCharts 清单可观测(id/page/chartNo/part/embed)', Array.isArray(report.nativeCharts) && report.nativeCharts.length === 3 && report.nativeCharts[0].part === 'ppt/charts/chart1.xml' && report.nativeCharts[2].embed === 'ppt/embeddings/Microsoft_Excel_Sheet3.xlsx', JSON.stringify(report.nativeCharts))
186
+
187
+ // ── 1b. DSL 扩展:校验 + 默认值(旧 deck 不写 render ⇒ native)────────────────
188
+ h('1b. DSL 扩展(render/legend/labels/axes)的校验与默认值')
189
+ {
190
+ const pageOf = (chart) => ({ pageType: 'content', elements: [{ elementId: 'c', elementType: 'chart', bounds: [0, 0, 100, 100], chart }] })
191
+ const errsOf = (chart) => validatePage(pageOf(chart), 'p.yaml')
192
+ const data = { cols: ['季度', '值'], rows: [['Q1', 12], ['Q2', 18]] }
193
+ const base = { type: 'bar', data }
194
+ check('旧写法(无 render/legend/labels/axes)校验通过 —— 向后兼容', errsOf(base) === null, JSON.stringify(errsOf(base)))
195
+ check('render: native|vector 都合法', errsOf({ ...base, render: 'native' }) === null && errsOf({ ...base, render: 'vector' }) === null, '')
196
+ const bad = (chart) => JSON.stringify(errsOf(chart) ?? '')
197
+ check('render 非法值被拒(并指向 native/vector)', bad({ ...base, render: 'svg' }).includes('chart.render'), bad({ ...base, render: 'svg' }))
198
+ check('legend 非法值被拒(top/bottom/left/right 或 bool)', bad({ ...base, legend: 'middle' }).includes('chart.legend'), bad({ ...base, legend: 'middle' }))
199
+ check('labels 非 bool 被拒', bad({ ...base, labels: 'yes' }).includes('chart.labels'), bad({ ...base, labels: 'yes' }))
200
+ check('axes 非 bool 被拒', bad({ ...base, axes: 1 }).includes('chart.axes'), bad({ ...base, axes: 1 }))
201
+ check('render: vector + legend/labels/axes 显式值 → **报错**而不是静默忽略', bad({ ...base, render: 'vector', legend: true }).includes('仅 render=native'), bad({ ...base, render: 'vector', legend: true }))
202
+ const o = chartOptions({ type: 'bar', data })
203
+ check('chartOptions 默认值:render=native / legend=auto / labels=auto / axes=true', o.render === 'native' && o.legend === 'auto' && o.labels === 'auto' && o.axes === true, JSON.stringify(o))
204
+ const single = resolveChart({ type: 'bar', data })
205
+ const multi = resolveChart({ type: 'bar', data: { cols: ['季度', 'A', 'B'], rows: [['Q1', 1, 2]] }, series: [{ name: 'A', x: '季度', y: 'A' }, { name: 'B', x: '季度', y: 'B' }] })
206
+ const pie = resolveChart({ type: 'pie', data: { cols: ['份额', '值'], rows: [['甲', 1], ['乙', 2]] } })
207
+ const vec = resolveChart({ type: 'bar', data, render: 'vector' })
208
+ check('auto 落地:单系列不画图例 / 多系列画图例 / 饼图画图例+数据标签 / 矢量路径都不画(预览==成品)', single.opts.legend === false && single.opts.labels === false && multi.opts.legend === 'bottom' && pie.opts.legend === 'bottom' && pie.opts.labels === true && vec.opts.legend === false && vec.opts.labels === false && vec.opts.axes === false, JSON.stringify({ single: single.opts, multi: multi.opts, pie: pie.opts, vec: vec.opts }))
209
+ }
210
+
211
+ // ── 2. 包内部件清单 + 结构 ────────────────────────────────────────────────
212
+ h('2. 包内部件清单与结构(pkg 级反读,不读中间层)')
213
+ const pkgBytes = readFileSync(outPptx)
214
+ const pkg = zipRead(pkgBytes)
215
+ const keys = [...pkg.keys()]
216
+ const chartParts = keys.filter((k) => /^ppt\/charts\/chart\d+\.xml$/.test(k))
217
+ const chartRels = keys.filter((k) => /^ppt\/charts\/_rels\/chart\d+\.xml\.rels$/.test(k))
218
+ const embeds = keys.filter((k) => /^ppt\/embeddings\/.+\.xlsx$/.test(k))
219
+ check('部件清单:chartN.xml / chartN.xml.rels / Microsoft_Excel_SheetN.xlsx 各 3 份', chartParts.length === 3 && chartRels.length === 3 && embeds.length === 3, `charts=${chartParts.join(',')} rels=${chartRels.length} embeds=${embeds.length}`)
220
+ const ct = pkg.get('[Content_Types].xml').toString('utf8')
221
+ check('Content_Types:chart 部件 Override + xlsx Default 都在', chartParts.every((k) => ct.includes(`PartName="/${k}"`) && ct.includes('drawingml.chart+xml')) && ct.includes('Extension="xlsx"'), trunc(ct, 400))
222
+ const EXTERNAL = /<c:externalData[^>]*r:id="([^"]+)"/.exec(pkg.get('ppt/charts/chart1.xml').toString('utf8'))?.[1]
223
+ const chart1Rels = pkg.get('ppt/charts/_rels/chart1.xml.rels').toString('utf8')
224
+ check('chart1.xml 的 c:externalData 关系真的指向内嵌工作簿(type=.../package)', EXTERNAL === 'rId1' && chart1Rels.includes('Id="rId1"') && chart1Rels.includes('../embeddings/Microsoft_Excel_Sheet1.xlsx') && chart1Rels.includes('/package'), `r:id=${EXTERNAL}|${trunc(chart1Rels, 260)}`)
225
+ const slide1Rels = pkg.get('ppt/slides/_rels/slide1.xml.rels').toString('utf8')
226
+ check('slide1 有 chart 关系(../charts/chart1.xml)', slide1Rels.includes('relationships/chart') && slide1Rels.includes('../charts/chart1.xml'), trunc(slide1Rels, 300))
227
+ const slide1 = pkg.get('ppt/slides/slide1.xml').toString('utf8')
228
+ check('slide1 图表帧结构合法:graphicFrame + graphicData uri=.../chart + 无嵌套 <p:xfrm><a:xfrm>', slide1.includes('<p:graphicFrame>') && slide1.includes('drawingml/2006/chart') && slide1.includes('r:id="rId2"') && !slide1.includes('<p:xfrm><a:xfrm>'), trunc(slide1.match(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/)?.[0] ?? '', 420))
229
+ const slot2 = zipRead(pkg.get('ppt/embeddings/Microsoft_Excel_Sheet1.xlsx'))
230
+ check('内嵌 xlsx 自带完整最小结构([Content_Types]/_rels/workbook/sheet1/styles)', ['[Content_Types].xml', '_rels/.rels', 'xl/workbook.xml', 'xl/_rels/workbook.xml.rels', 'xl/worksheets/sheet1.xml', 'xl/styles.xml'].every((k) => slot2.has(k)), [...slot2.keys()].join(' '))
231
+ const grid1 = sheetGridFromXlsx(pkg.get('ppt/embeddings/Microsoft_Excel_Sheet1.xlsx'))
232
+ check('自研读取器解 sheet1 网格 == deck 数据(bar2:A1:C5)', JSON.stringify(grid1) === JSON.stringify(CHARTS[0].grid), `got=${JSON.stringify(grid1)}`)
233
+
234
+ // ── 2b. 同一页混排:图片 + 图表 + 讲稿(rId 编号不串号)─────────────────────
235
+ h('2b. 同一页混排(图片 + 原生图表 + 讲稿):rId 分配不串号')
236
+ {
237
+ const mix = join(WORK, 'mixed')
238
+ mkdirSync(join(mix, 'pages'), { recursive: true })
239
+ mkdirSync(join(mix, 'media'), { recursive: true })
240
+ writeFileSync(join(mix, 'media', 'px.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'))
241
+ writeFileSync(join(mix, 'deck.yaml'), ['version: 1', 'title: mixed', 'size: [960, 540]', 'theme:',
242
+ ' colors: {primary: "#2563EB"}', ' textStyles:', ' body: {fontSize: 16, color: "$primary"}',
243
+ 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
244
+ writeFileSync(join(mix, 'pages', '01.yaml'), [
245
+ 'pageType: content', 'notes: 混排页讲稿', 'elements:',
246
+ ' - elementId: im', ' elementType: image', ' bounds: [40, 40, 100, 60]', ' src: media/px.png',
247
+ ' - elementId: ch', ' elementType: chart', ' bounds: [200, 60, 400, 300]',
248
+ ' chart:', ' type: bar', ' data:', ' cols: [季度, 值]', ' rows: [[Q1, 12], [Q2, 18]]',
249
+ '', ''].join('\n'), 'utf8')
250
+ const mixOut = join(mix, 'out.pptx')
251
+ const mr = await exportPptx(await resolveDeck(mix), { out: mixOut })
252
+ const mp = zipRead(readFileSync(mixOut))
253
+ const rels = mp.get('ppt/slides/_rels/slide1.xml.rels').toString('utf8')
254
+ const s1 = mp.get('ppt/slides/slide1.xml').toString('utf8')
255
+ const okOrder = /Id="rId2"[^>]*relationships\/image[^>]*media\/px\.png/.test(rels) &&
256
+ /Id="rId3"[^>]*relationships\/notesSlide[^>]*notesSlide1\.xml/.test(rels) &&
257
+ /Id="rId4"[^>]*relationships\/chart[^>]*charts\/chart1\.xml/.test(rels) &&
258
+ s1.includes('r:embed="rId2"') && s1.includes('r:id="rId4"') && mp.has('ppt/notesSlides/notesSlide1.xml') && mr.parity.ok === true
259
+ check('图片=rId2 / 讲稿=rId3 / 图表=rId4 三条关系各就各位,帧引用正确(rId 串号就是"图变错图/图表丢帧"这类事故)', okOrder, `rels=${trunc(rels.replace(/^.*<Relationships[^>]*>/, ''), 380)}`)
260
+ }
261
+
262
+ // ── 3. python-pptx / openpyxl 独立反读 ────────────────────────────────────
263
+ h('3. 独立阅读器反读:python-pptx(has_chart / chart_type / series values)+ openpyxl(内嵌工作簿)')
264
+ const py = findAnyPython()
265
+ const READBACK = join(WORK, 'readback.py')
266
+ writeFileSync(READBACK, `import json, sys, zipfile, io
267
+ from pptx import Presentation
268
+ import openpyxl
269
+
270
+ prs = Presentation(sys.argv[1])
271
+ out = {"slides": len(prs.slides._sldIdLst), "charts": [], "embeds": []}
272
+ for i, slide in enumerate(prs.slides, 1):
273
+ for sh in slide.shapes:
274
+ if getattr(sh, "has_chart", False):
275
+ ch = sh.chart
276
+ out["charts"].append({
277
+ "slide": i,
278
+ "has_chart": True,
279
+ "chart_type": str(ch.chart_type).split(" ")[0],
280
+ "has_legend": bool(ch.has_legend),
281
+ "series": [{"name": s.name, "values": [float(v) for v in s.values]} for s in ch.series],
282
+ "cats": [str(c) for c in ch.plots[0].categories],
283
+ })
284
+ z = zipfile.ZipFile(sys.argv[1])
285
+ for n in sorted(x for x in z.namelist() if x.startswith("ppt/embeddings/")):
286
+ wb = openpyxl.load_workbook(io.BytesIO(z.read(n)), data_only=True)
287
+ ws = wb.active
288
+ grid = [[c.value for c in row] for row in ws.iter_rows()]
289
+ out["embeds"].append({"part": n, "sheet": ws.title, "dims": ws.dimensions, "grid": grid})
290
+ print(json.dumps(out, ensure_ascii=False))
291
+ `, 'utf8')
292
+
293
+ let rb = null
294
+ let rbErr = ''
295
+ if (py === null) {
296
+ rbErr = '没有任何可用的 Python(python-pptx 反读是必需证据)'
297
+ } else {
298
+ const r = spawnSync(py.path, ['-X', 'utf8', READBACK, outPptx], { encoding: 'utf8', env: { ...process.env, PYTHONIOENCODING: 'utf-8' }, timeout: 120000 })
299
+ if (r.status !== 0) rbErr = `exit ${r.status}:${trunc(r.stderr || r.stdout || '', 400)}`
300
+ else {
301
+ try { rb = JSON.parse(String(r.stdout).trim().split('\n').pop()) } catch (e) { rbErr = `JSON 解析失败:${e.message}|${trunc(r.stdout, 300)}` }
302
+ }
303
+ }
304
+ check('python-pptx 反读可用(独立阅读器,非自研代码)', rb !== null, rbErr)
305
+ if (rb) {
306
+ console.log(`python-pptx/openpyxl 原始输出:\n${trunc(JSON.stringify(rb), 1400)}`)
307
+ check('python-pptx:页数 == 4(3 张原生图表 + 1 张矢量降级)', rb.slides === 4, `slides=${rb.slides}`)
308
+ const bySlide = new Map(rb.charts.map((c) => [c.slide, c]))
309
+ check('has_chart === true(第 1/2/3 页各一个原生图表对象)', [1, 2, 3].every((s) => bySlide.get(s)?.has_chart === true), JSON.stringify(rb.charts.map((c) => [c.slide, c.has_chart])))
310
+ check('矢量降级页(第 4 页)**不是**图表对象(render: vector 语义未漂移)', !bySlide.has(4), `图表对象出现在页:${[...bySlide.keys()].join(',')}`)
311
+ const typeOk = nativeCharts.every((c, i) => bySlide.get(c.page)?.chart_type === c.chartType)
312
+ check('chart_type 与 DSL 一致(COLUMN_CLUSTERED / LINE_MARKERS / PIE)', typeOk, JSON.stringify(nativeCharts.map((c) => [c.id, bySlide.get(c.page)?.chart_type, c.chartType])))
313
+ const seriesOk = nativeCharts.every((c) => {
314
+ const got = bySlide.get(c.page)?.series ?? []
315
+ return got.length === c.values.length && c.values.every((vals, si) => vals.every((v, i) => Math.abs((got[si]?.values ?? [])[i] - v) < 1e-9))
316
+ })
317
+ check('series.values 与 deck 数值一致(逐系列逐点)', seriesOk, JSON.stringify(nativeCharts.map((c) => [c.id, bySlide.get(c.page)?.series.map((s) => s.values)])))
318
+ check('categories(分类轴)与 deck 分类一致', nativeCharts.every((c) => JSON.stringify(bySlide.get(c.page)?.cats) === JSON.stringify(c.cats)), JSON.stringify(nativeCharts.map((c) => [c.id, bySlide.get(c.page)?.cats])))
319
+ check('数据标签/图例声明的落地:3 个原生图表都有图例(多系列 auto、显式 right、饼图 auto)', nativeCharts.every((c) => bySlide.get(c.page)?.has_legend === true), JSON.stringify(rb.charts.map((c) => [c.slide, c.has_legend])))
320
+ const sheetMatch = nativeCharts.every((c, i) => JSON.stringify(rb.embeds[i]?.grid) === JSON.stringify(c.grid))
321
+ check('openpyxl 反读内嵌工作簿 == deck 数据(数据随文件走,independent reader)', rb.embeds.length === 3 && sheetMatch, JSON.stringify(rb.embeds.map((e) => [e.part, e.dims, e.grid])))
322
+ }
323
+
324
+ // ── 4. 官方 check_office.py 结构自证 ──────────────────────────────────────
325
+ h('4. 官方 check_office.py 结构自证(ZIP/XML/rels 完整性 + 页数)')
326
+ const qa = runOfficeStructureCheck(outPptx, { contains: [], count: 4 })
327
+ if (!qa.available) {
328
+ check('官方 check_office.py 结构自证 pass', allowSkip, `不可用:${qa.reason}${allowSkip ? '(已按 PPT_STUDIO_ALLOW_SKIP_OFFICE=1 降级)' : ''}`)
329
+ } else {
330
+ check('官方 check_office.py 结构自证 pass(verdict=pass + count=4)', qa.ok === true && qa.verdict === 'pass', `verdict=${qa.verdict} summary=${JSON.stringify(qa.summary)} reason=${qa.reason}`)
331
+ }
332
+
333
+ // ── 5. 真渲染(PowerPoint COM,无 Office 退 LibreOffice kit)+ 像素级确认 ────
334
+ h('5. 真渲染出图(PowerPoint COM 优先)+ 像素级确认图形真的画出来')
335
+ const shots = join(WORK, 'shots')
336
+ let renderEngine = null
337
+ let renderInfo = ''
338
+ let pngFiles = []
339
+ if (findPowerPoint() !== null) {
340
+ // COM 偶尔会因上一条渲染残留的 PowerPoint 实例而失败 ⇒ 重试一次(真渲染是必需证据,不能一次失败就放弃)
341
+ for (let attempt = 1; attempt <= 2 && pngFiles.length === 0; attempt++) {
342
+ try {
343
+ const r = await renderPptxToPng(outPptx, shots, { width: 1920, height: 1080, timeoutMs: 300000 })
344
+ pngFiles = r.files
345
+ renderEngine = 'PowerPoint COM'
346
+ renderInfo = `pages=${r.pages} png=${r.files.length}${attempt > 1 ? `(第 ${attempt} 次尝试成功)` : ''}`
347
+ } catch (e) { renderInfo = `COM 失败(第 ${attempt} 次):${e?.message ?? e}` }
348
+ }
349
+ }
350
+ if (pngFiles.length === 0) {
351
+ const lo = runLibreOfficeRender(outPptx, shots, { dpi: 144 })
352
+ if (lo.available && lo.ok) {
353
+ pngFiles = lo.files
354
+ renderEngine = 'LibreOffice kit'
355
+ renderInfo = `png=${lo.files.length}(无 Office 时的替代真渲染;排版引擎与 PowerPoint 不同,不能当逐像素判据)`
356
+ } else {
357
+ renderInfo = `${renderInfo}|LibreOffice:${lo.reason}`
358
+ }
359
+ }
360
+ if (pngFiles.length === 0) {
361
+ check('真渲染逐页出图(PowerPoint COM 或 LibreOffice kit)', allowSkip, renderInfo)
362
+ } else {
363
+ check(`真渲染逐页出图(引擎:${renderEngine})`, pngFiles.length === 4, renderInfo)
364
+ // 像素级确认:按系列色统计渲染图命中像素 —— 挡住"文件能打开但图表是空白/被丢弃"
365
+ // 区域按**实际 PNG 宽度/页面宽度**换算(COM 1920/960 = 2×;LibreOffice 的 dpi 可能不同,别写死 2×)。
366
+ const PNGCHECK = join(WORK, 'pngcheck.py')
367
+ writeFileSync(PNGCHECK, `import json, sys
368
+ from PIL import Image
369
+ img = Image.open(sys.argv[1]).convert("RGB")
370
+ deck_w = float(sys.argv[2])
371
+ colors = json.loads(sys.argv[3])
372
+ tol = int(sys.argv[4])
373
+ box = json.loads(sys.argv[5])
374
+ scale = img.size[0] / deck_w
375
+ crop = img.crop(tuple(int(v * scale) for v in box))
376
+ px = list(crop.getdata())
377
+ out = {}
378
+ for name, hexs in colors.items():
379
+ want = tuple(int(hexs[i:i + 2], 16) for i in (1, 3, 5))
380
+ out[name] = sum(1 for c in px if abs(c[0] - want[0]) <= tol and abs(c[1] - want[1]) <= tol and abs(c[2] - want[2]) <= tol)
381
+ print(json.dumps({"size": list(img.size), "scale": round(scale, 3), "box": list(box), "counts": out}))
382
+ `, 'utf8')
383
+ const pixelCheck = (png, box, palette) => {
384
+ if (py === null) return { err: '没有可用的 Python(PIL 像素统计需要它)' }
385
+ const r = spawnSync(py.path, ['-X', 'utf8', PNGCHECK, png, String(ctx.size.width), JSON.stringify(Object.fromEntries(palette.map((c, i) => [`c${i}`, c]))), '40', JSON.stringify(box)], { encoding: 'utf8', timeout: 120000 })
386
+ if (r.status !== 0) return { err: `exit ${r.status}:${trunc(r.stderr, 200)}` }
387
+ try { return JSON.parse(String(r.stdout).trim().split('\n').pop()) } catch (e) { return { err: `解析失败:${e.message}` } }
388
+ }
389
+ const png1 = pngFiles[0]
390
+ const bar = pixelCheck(png1, CHARTS[0].bounds, CHARTS[0].colors)
391
+ const cmp = pixelCheck(pngFiles[2], CHARTS[2].bounds, CHARTS[2].palette)
392
+ const vec = pixelCheck(pngFiles[3], CHARTS[3].bounds, ['#2563EB'])
393
+ console.log(`像素统计(区域按实际 PNG 宽度换算,颜色容差 ±40):bar=${JSON.stringify(bar.counts ?? bar)}|pie=${JSON.stringify(cmp.counts ?? cmp)}|vector=${JSON.stringify(vec.counts ?? vec)}`)
394
+ check('渲染图里真的有两根系列色的柱(#2563EB + #F59E0B 各 ≥ 5000 px)', (bar.counts?.c0 ?? 0) >= 5000 && (bar.counts?.c1 ?? 0) >= 5000, JSON.stringify(bar.counts ?? bar.err))
395
+ check('渲染图里饼图三个扇区色都在(≥ 500 px 各)', ['c0', 'c1', 'c2'].every((k) => (cmp.counts?.[k] ?? 0) >= 500), JSON.stringify(cmp.counts ?? cmp.err))
396
+ check('渲染图里矢量降级路径仍有柱(≥ 5000 px)', (vec.counts?.c0 ?? 0) >= 5000, JSON.stringify(vec.counts ?? vec.err))
397
+ }
398
+
399
+ // ── 5b. LibreOffice 兼容(过渡 OOXML 的第二个消费端)────────────────────────
400
+ h('5b. LibreOffice 兼容性(同一份产物,第二个消费端也能开)')
401
+ {
402
+ const loDir = join(WORK, 'shots-lo')
403
+ const lo = runLibreOfficeRender(outPptx, loDir, { dpi: 96 })
404
+ if (!lo.available) check('LibreOffice kit 渲染(本部署不可用 ⇒ 跳过,不计 FAIL)', true, lo.reason)
405
+ else check('LibreOffice kit 能打开并渲染出 4 页(原生图表部件不破坏兼容性)', lo.ok === true && lo.files.length === 4, `${lo.reason}${lo.missingFonts?.length ? `|缺字 ${JSON.stringify(lo.missingFonts.slice(0, 3))}` : ''}`)
406
+ }
407
+
408
+ // ── 6. 预览对齐(SVG 画了成品也有的图例/轴/标签;降级路径不画)──────────────
409
+ h('6. 预览对齐:SVG 与成品画同样的图表家具')
410
+ const rd = await renderDeck(ctx, {})
411
+ const html = (i) => readFileSync(join(rd.outDir, rd.htmlFiles[i - 1]), 'utf8')
412
+ const svgOf = (i) => html(i).match(/<svg[\s\S]*?<\/svg>/)?.[0] ?? ''
413
+ const s1 = svgOf(1)
414
+ const s2 = svgOf(2)
415
+ const s3 = svgOf(3)
416
+ const s4 = svgOf(4)
417
+ check('柱状(多系列)预览画了图例色块 + 两个系列名', (s1.match(/rx="1"/g) ?? []).length >= 2 && s1.includes('营收') && s1.includes('成本'), `swatch=${(s1.match(/rx="1"/g) ?? []).length}`)
418
+ check('柱状预览画了网格线 + 坐标轴轴线(原生图表自带,预览必须对齐)', s1.includes('#e2e8f0') && s1.includes('#cbd5e1'), `grid=${s1.includes('#e2e8f0')} axis=${s1.includes('#cbd5e1')}`)
419
+ check('折线(labels: true)预览画了数据标签 92/94/95', ['92', '94', '95'].every((v) => new RegExp(`>${v}<`).test(s2)), '')
420
+ check('饼图预览画了百分比标签 + 分类图例(甲乙丙)', s3.includes('%<') && ['甲', '乙', '丙'].every((v) => s3.includes(v)), '')
421
+ check('render: vector 预览**不画**网格线/轴线/图例(与矢量成品一致,不误导)', !s4.includes('#e2e8f0') && !s4.includes('#cbd5e1') && !s4.includes('rx="1"'), `grid=${s4.includes('#e2e8f0')} axis=${s4.includes('#cbd5e1')} swatch=${(s4.match(/rx="1"/g) ?? []).length}`)
422
+
423
+ // ── 7. 负面对照:判据必须真的会红 ──────────────────────────────────────────
424
+ h('7. 负面对照(不做负对照的判据等于没判据)')
425
+ const expected = []
426
+ {
427
+ let k = 0
428
+ for (const [i, page] of ctx.pages.entries()) {
429
+ for (const el of normalizePage(page, ctx)) {
430
+ if (el.type !== 'chart') continue
431
+ if (resolveChart(el.chart).opts.render === 'vector') continue
432
+ k++
433
+ expected.push({ id: el.id, page: i + 1, chart: el.chart, chartPart: chartPartNames(k).chart })
434
+ }
435
+ }
436
+ }
437
+ check('负对照基线:未篡改的包 auditNativeCharts 必须绿', auditNativeCharts(pkg, expected).ok === true, JSON.stringify(auditNativeCharts(pkg, expected)))
438
+ const entriesFromMap = (m, overrides = {}, drops = []) => {
439
+ const out = {}
440
+ for (const [k, v] of m) {
441
+ if (drops.includes(k)) continue
442
+ out[k] = Object.prototype.hasOwnProperty.call(overrides, k) ? overrides[k] : v
443
+ }
444
+ return out
445
+ }
446
+ // (a) 篡改内嵌工作簿里的一个数值 → 数据判据必须红
447
+ const embedName = chartPartNames(1).embed
448
+ const inner = zipRead(pkg.get(embedName))
449
+ const sheetRaw = inner.get('xl/worksheets/sheet1.xml').toString('utf8')
450
+ const tamperedSheet = sheetRaw.replace('<v>12.5</v>', '<v>99</v>')
451
+ check('负对照(a) 构造成功:内嵌工作簿里的 12.5 被改成 99', tamperedSheet !== sheetRaw, '')
452
+ const tamperedInner = zipWrite(entriesFromMap(inner, { 'xl/worksheets/sheet1.xml': Buffer.from(tamperedSheet, 'utf8') }))
453
+ const auditA = auditNativeCharts(entriesFromMap(pkg, { [embedName]: tamperedInner }), expected)
454
+ check('负对照(a) 数据不一致 ⇒ parity 必须报红(并指出是哪个图的哪格)', auditA.ok === false && auditA.dataMismatch.some((m) => m.includes('bar2')), `ok=${auditA.ok} mismatch=${JSON.stringify(auditA.dataMismatch)}`)
455
+ // (b) 删掉内嵌工作簿部件 → 关系断链必须红
456
+ const auditB = auditNativeCharts(entriesFromMap(pkg, {}, [embedName]), expected)
457
+ check('负对照(b) 内嵌工作簿缺失 ⇒ parity 必须报红(关系目标不存在)', auditB.ok === false && auditB.relsMissing.some((m) => m.includes(embedName)), `ok=${auditB.ok} relsMissing=${JSON.stringify(auditB.relsMissing)}`)
458
+ // (c) 删掉一个 chart 部件 → 计数不符必须红
459
+ const auditC = auditNativeCharts(entriesFromMap(pkg, {}, ['ppt/charts/chart3.xml']), expected)
460
+ check('负对照(c) 图表部件缺失 ⇒ 计数判据必须红(chartsOut < chartsExp)', auditC.ok === false && auditC.chartsOut === 2 && auditC.chartsExp === 3, `chartsExp=${auditC.chartsExp} chartsOut=${auditC.chartsOut}`)
461
+
462
+ // ── 8. 回归:不含图表的工程产物**零变化** ────────────────────────────────────
463
+ // 用户拍板的取舍里有一条:**其他绘图(text/shape/line/image/table/custGeom)保持不变**。
464
+ // 判据最硬的一档 = 拿 HEAD 版本的导出器跑同一个(无图表)工程,产物**逐部件**比对(只有 core.xml 的时间戳除外)。
465
+ // 关系编号(媒体/讲稿/图表)是我这轮动过的地方,正需要这条挡住"加图表把 rId 排错"的回归。
466
+ h('8. 回归:不含图表的工程产物逐部件零变化(对照 HEAD 版本导出器)')
467
+ {
468
+ const oldDir = join(WORK, 'old-src')
469
+ const need = ['pptd/export-pptx.js', 'pptd/layout.js', 'pptd/svgCharts.js', 'zips.js']
470
+ let extracted = true
471
+ for (const f of need) {
472
+ const r = spawnSync('git', ['-C', root, 'show', `HEAD:src/${f}`], { maxBuffer: 20 * 1024 * 1024 })
473
+ if (r.status !== 0) { extracted = false; break }
474
+ mkdirSync(join(oldDir, dirname(f)), { recursive: true })
475
+ writeFileSync(join(oldDir, f), r.stdout)
476
+ }
477
+ if (!extracted) {
478
+ check('回归对照:HEAD 版本的 pptd 导出器可取得(无 git/无 HEAD ⇒ 跳过,不计 FAIL)', true, 'git show HEAD:src/pptd/export-pptx.js 失败')
479
+ } else {
480
+ // 无图表的工程:文本 / 表格 / 形状 / 连线 / 图片 / 讲稿(覆盖媒体 rId + 讲稿 rId 两条编号路径)
481
+ const noChart = join(WORK, 'no-chart')
482
+ mkdirSync(join(noChart, 'pages'), { recursive: true })
483
+ mkdirSync(join(noChart, 'media'), { recursive: true })
484
+ writeFileSync(join(noChart, 'media', 'px.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'))
485
+ writeFileSync(join(noChart, 'deck.yaml'), ['version: 1', 'title: no-chart', 'size: [960, 540]', 'theme:',
486
+ ' colors: {primary: "#2563EB", text: "#1F2937"}', ' textStyles:', ' body: {fontSize: 16, color: "$text"}',
487
+ 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
488
+ writeFileSync(join(noChart, 'pages', '01.yaml'), [
489
+ 'pageType: content',
490
+ 'notes: 讲稿一行',
491
+ 'elements:',
492
+ ' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 400, 40]', ' content: {text: "标题 <a&b>", style: "$body"}',
493
+ ' - elementId: tb', ' elementType: table', ' bounds: [40, 100, 300, 80]', ' cols: [指标, 值]', ' rows: [["甲", "1"], ["乙", "2"]]',
494
+ ' - elementId: sh1', ' elementType: shape', ' bounds: [400, 100, 120, 80]', ' kind: roundRect', ' fill: "$primary"',
495
+ ' - elementId: ln1', ' elementType: line', ' points: [[560, 120], [700, 80]]', ' line: {color: "#1F2937", width: 2}', ' arrow: true',
496
+ ' - elementId: im1', ' elementType: image', ' bounds: [560, 160, 120, 80]', ' src: media/px.png',
497
+ '', ''].join('\n'), 'utf8')
498
+ const ncCtx = await resolveDeck(noChart)
499
+ const newOut = join(noChart, 'new.pptx')
500
+ const oldOut = join(noChart, 'old.pptx')
501
+ await exportPptx(ncCtx, { out: newOut })
502
+ const oldMod = await import(pathToFileURL(join(oldDir, 'pptd', 'export-pptx.js')).href)
503
+ await oldMod.exportPptx(ncCtx, { out: oldOut })
504
+ const a = zipRead(readFileSync(oldOut))
505
+ const b = zipRead(readFileSync(newOut))
506
+ const skip = new Set(['docProps/core.xml']) // 时间戳必然不同
507
+ const keysAll = new Set([...a.keys(), ...b.keys()])
508
+ const diffs = []
509
+ for (const k of keysAll) {
510
+ if (skip.has(k)) continue
511
+ const x = a.get(k)
512
+ const y = b.get(k)
513
+ if (!x || !y) { diffs.push(`${k}: 只在${x ? '旧' : '新'}包里`); continue }
514
+ if (!x.equals(y)) diffs.push(`${k}: 内容不同(旧 ${x.length}B / 新 ${y.length}B)`)
515
+ }
516
+ check('不含图表的工程:新旧产物逐部件一致(media/notes 的 rId 编号未被图表改动影响)', diffs.length === 0, diffs.length ? diffs.slice(0, 6).join('|') : `${keysAll.size - skip.size} 个部件全部逐字节一致`)
517
+ }
518
+ }
519
+
520
+ // ── 汇总 ──────────────────────────────────────────────────────────────────
521
+ h('汇总')
522
+ console.log(`PASS ${pass} / FAIL ${fail}`)
523
+ console.log(`临时产物:${WORK}(out.pptx 与逐页渲染图可人工复核)`)
524
+ if (fail > 0) {
525
+ console.log('结论:FAIL(有判据未通过——详见上面的 FAIL 行)')
526
+ process.exitCode = 1
527
+ } else {
528
+ console.log('结论:PASS(原生可编辑图表:部件结构 + 数据一致性 + 独立反读 + 真渲染出图 + 负对照全部通过)')
529
+ }