dsh-ppt-studio 1.1.4 → 1.2.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 (95) hide show
  1. package/README.md +5 -2
  2. package/docs/00-/350/256/276/350/256/241/346/272/220.md +4 -0
  3. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +114 -1
  4. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +171 -0
  5. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +24 -1
  6. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +2 -1
  7. package/docs/07-/345/244/215/346/235/202/345/233/276/350/203/275/345/212/233/347/253/213/351/241/271/344/270/216/351/243/216/351/231/251/347/231/273/350/256/260.md +319 -0
  8. package/docs/08-/351/230/266/346/256/265A/350/256/276/350/256/241/350/257/264/346/230/216-/347/273/223/346/236/204/345/205/263/347/263/273/344/270/216/345/233/276/344/270/223/345/261/236/346/240/241/351/252/214.md +306 -0
  9. package/docs/09-/344/273/243/347/240/201/345/234/260/345/233/276/344/270/216/346/250/241/345/235/227/350/201/214/350/264/243.md +148 -0
  10. package/docs/10-/345/256/277/344/270/273/350/200/246/345/220/210/347/202/271/346/270/205/345/215/225.md +49 -0
  11. package/docs/11-/346/234/257/350/257/255/350/241/250.md +42 -0
  12. package/docs/12-/345/244/215/346/235/202/345/233/276/351/200/232/350/267/257/347/232/204/351/200/211/346/213/251/344/270/216/351/252/214/346/224/266.md +65 -0
  13. package/docs/13-/345/217/221/347/211/210/345/207/206/345/244/207/350/256/241/345/210/222.md +79 -0
  14. package/docs/14-/345/217/221/345/270/203/346/270/205/345/215/225/357/274/210/350/215/211/347/250/277/357/274/211.md +37 -0
  15. package/docs/17-/346/211/213/347/224/27313/351/241/265/350/255/246/345/221/212/351/200/220/346/235/241/345/244/204/347/275/256.md +102 -0
  16. package/examples/fx/fx-pro/preview/layout.json +12 -0
  17. package/examples/fx/fx-pro/preview/measured.json +1 -1
  18. package/examples/fx/fx-pro.pptx +0 -0
  19. package/examples/fx/seed.pptx +0 -0
  20. package/examples/fx/single.pptx +0 -0
  21. package/examples/fx/spliced.pptx +0 -0
  22. package/examples/smoke/imported/preview-rt/layout.json +3 -0
  23. package/examples/smoke/imported/source.pptx +0 -0
  24. package/examples/smoke/out-smoke.pptx +0 -0
  25. package/examples/smoke/preview/layout.json +3 -0
  26. package/lib/group-render.js +177 -0
  27. package/lib/msrender.js +27 -12
  28. package/lib/pptd/blocks.js +52 -0
  29. package/lib/pptd/export-pptx.js +266 -11
  30. package/lib/pptd/layout.js +54 -3
  31. package/lib/pptd/line-rules.js +259 -0
  32. package/lib/pptd/line-text-rule.js +90 -0
  33. package/lib/pptd/relations.js +615 -0
  34. package/lib/pptd/render-html.js +19 -1
  35. package/lib/pptd/route-geometry.js +120 -0
  36. package/lib/pptd/schema.js +146 -11
  37. package/lib/pptd/style-profile.js +133 -0
  38. package/lib/router.js +2 -2
  39. package/lib/scaffold.js +54 -1
  40. package/lib/tools.js +22 -4
  41. package/lib/verify.js +69 -1
  42. package/package.json +20 -3
  43. package/scripts/audit-manual-facts.mjs +15 -3
  44. package/scripts/bench-diagram-scale.mjs +165 -0
  45. package/scripts/check-generators.mjs +22 -0
  46. package/scripts/check-lib-sync.mjs +33 -0
  47. package/scripts/check-line-text.mjs +30 -0
  48. package/scripts/check-package.mjs +60 -0
  49. package/scripts/check-removed-traces.mjs +37 -0
  50. package/scripts/check-syntax.mjs +44 -0
  51. package/scripts/check-unknown-keys.mjs +114 -0
  52. package/scripts/fixtures/handdrawn-baseline.json +177 -0
  53. package/scripts/fixtures/prompt-cost-baseline.json +178 -0
  54. package/scripts/fixtures/workflow-baseline.json +20 -15
  55. package/scripts/handdrawn-option-probe.mjs +78 -0
  56. package/scripts/probe-prompt-cost.mjs +127 -0
  57. package/scripts/red-probe.mjs +75 -0
  58. package/scripts/render-group.mjs +72 -0
  59. package/scripts/smoke.mjs +183 -12
  60. package/scripts/test-fast.mjs +108 -0
  61. package/scripts/test-map.mjs +162 -0
  62. package/scripts/test-release.mjs +64 -0
  63. package/scripts/verify-diagram-relations.mjs +405 -0
  64. package/scripts/verify-handdrawn.mjs +115 -0
  65. package/scripts/verify-handwritten.mjs +203 -0
  66. package/scripts/verify-native-charts.mjs +25 -10
  67. package/scripts/verify-primitives.mjs +714 -0
  68. package/scripts/verify-products.mjs +37 -0
  69. package/scripts/verify-shape.mjs +84 -0
  70. package/scripts/visual-pair-batch.mjs +77 -0
  71. package/skills/diagram-drawing/SKILL.md +23 -0
  72. package/skills/diagram-drawing/capability-matrix.md +22 -0
  73. package/skills/diagram-drawing/composition.md +22 -0
  74. package/skills/diagram-drawing/mechanisms.md +141 -0
  75. package/skills/diagram-drawing/pitfalls.md +89 -0
  76. package/skills/diagram-drawing/routing.md +17 -0
  77. package/skills/diagram-drawing/text-and-spacing.md +16 -0
  78. package/skills/diagram-drawing/verify.md +50 -0
  79. package/skills/diagrams/compare.md +24 -0
  80. package/skills/diagrams/flow.md +24 -0
  81. package/skills/diagrams/funnel.md +25 -0
  82. package/skills/diagrams/layers.md +18 -0
  83. package/skills/diagrams/loop.md +25 -0
  84. package/skills/diagrams/matrix.md +23 -0
  85. package/skills/diagrams/sequence.md +17 -0
  86. package/skills/diagrams/state.md +34 -0
  87. package/skills/diagrams/steps-ring.md +24 -0
  88. package/skills/diagrams/swimlane.md +23 -0
  89. package/skills/diagrams/timeline.md +24 -0
  90. package/skills/diagrams/tree.md +24 -0
  91. package/skills/ppt-studio-manual/SKILL.md +2 -2
  92. package/templates/academic-white/preview.png +0 -0
  93. package/templates/business-blue/preview.png +0 -0
  94. package/templates/pitch-bold/preview.png +0 -0
  95. package/templates/tech-dark/preview.png +0 -0
@@ -0,0 +1,714 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 导出基元自证(阶段 A §6 / §8):>2 点折线 · 箭头两端 · 虚线 · attach 接入点 · 字节不变回归。
4
+ * **可复跑、退出码 0/1。**
5
+ *
6
+ * 为什么需要这个脚本:这一批改动全是"写盘形态"的改动(`a:custGeom` 开放路径 / `headEnd` / `prstDash`),
7
+ * 它们的共同特点是——**预览看不出对错**:XML 写错时 PowerPoint 会静默忽略(箭头不出现)、静默降级,
8
+ * 而"文件能打开、页数对、元素计数对"这三样全都还是绿的。所以这里的每一条判据都必须是**外部证据**:
9
+ *
10
+ * ① 产物 XML 断言:从 zip 里**反读** slide1.xml,断 custGeom 开放路径(moveTo + lnTo、**无 a:close**)、
11
+ * headEnd+tailEnd 同时存在且**都在 `<a:ln>` 内部**、prstDash 值正确、`<a:path>` 的 lnTo 条数 = 点数−1;
12
+ * ② 真渲染:`lib/msrender.js` 的 PowerPoint COM 真渲染必须出图,并且**按像素确认折线真的画出来了**
13
+ * (两段横线 / 一段竖线 / 末端箭头 各自的命中像素数)——"能打开"不等于"画出来了";
14
+ * ③ 负面对照:schema 层必须拒收 `arrow:'bogus'` / `dash:'bogus'` / 单点 points;导出层遇到漏网非法值
15
+ * 必须**降级 + 进 report.warnings**,且产物里绝不出现坏 XML;
16
+ * ④ 字节不变硬门槛:一个**不使用任何新字段**的工程,其产物与基线 tag 的导出器逐部件逐字节一致
17
+ * (只跳过 docProps/core.xml 的时间戳);
18
+ * ⑤ 确定性:同一 deck 连导两次,除 core.xml 外逐字节一致。
19
+ *
20
+ * 【两套夹具,为什么】`export-pptx.js` 里 `import { normalizePage } from './layout.js'` 是 **ESM 活绑定**,
21
+ * 外部注入 `ctx.normalizePage` 完全不生效;而 `src/pptd/layout.js` 当时还没把 `arrow` 的字符串值与 `line.dash`
22
+ * 透传到归一化元素 —— 于是"导出层对不对"与"整链通没通"必须**分开量**:
23
+ * · ①..⑭(隔离导出层):把 `src/` 拷进 node_modules 临时目录,只把该副本的 `normalizePage` 换成
24
+ * "先调真实现、再把这两个字段按原值补回"的包装,用副本的 `exportPptx` 导出 ⇒ 断言的是**导出层实现**;
25
+ * · ⑮(整链契约):走**真管线**(deck.yaml → schema → layout → export)⇒ 布局层透传到位才转绿。
26
+ * · ④/⑬b 的字节不变对照则用**基线 tag 的导出器**(同样取整棵 src/ 到临时目录后 import)。
27
+ *
28
+ * 用法:
29
+ * node scripts/verify-primitives.mjs
30
+ * PPT_STUDIO_ALLOW_SKIP_OFFICE=1 node scripts/verify-primitives.mjs # 无 PowerPoint 时真渲染降级为 SKIP(不计 FAIL)
31
+ * PPT_STUDIO_PRIMITIVES_DRIFT=1 node scripts/verify-primitives.mjs # 额外打印"真 layout 归一化 vs 夹具"字段差异
32
+ *
33
+ * 临时产物全部在系统 TEMP 的 `pptd-primitives-verify/`;两个源码副本放在仓库 `node_modules/` 下(用完即删,
34
+ * node_modules 本就 gitignore ⇒ 不产生任何可提交文件)。
35
+ */
36
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
37
+ import { dirname, join } from 'node:path'
38
+ import { fileURLToPath, pathToFileURL } from 'node:url'
39
+ import { tmpdir } from 'node:os'
40
+ import zlib from 'node:zlib'
41
+ import { spawnSync } from 'node:child_process'
42
+
43
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
44
+ const allowSkipOffice = process.env.PPT_STUDIO_ALLOW_SKIP_OFFICE === '1'
45
+ const WORK = join(tmpdir(), 'pptd-primitives-verify')
46
+
47
+ // ── 断言脚手架(PASS/FAIL 行 + 退出码,和 verify-*.mjs 家族一致)─────────────
48
+ let pass = 0
49
+ let fail = 0
50
+ const check = (name, ok, detail = '') => {
51
+ if (ok) pass++
52
+ else fail++
53
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? `\n └─ ${detail}` : ''}`)
54
+ }
55
+ const h = (t) => console.log(`\n=== ${t} ===`)
56
+ const info = (t) => console.log(` · ${t}`)
57
+
58
+ const { resolveDeck, validatePage } = await import('../lib/pptd/schema.js')
59
+ const { exportPptx, polylineEndsFromXml } = await import('../lib/pptd/export-pptx.js')
60
+ const { normalizePage } = await import('../lib/pptd/layout.js')
61
+ const { zipRead, decodeXml } = await import('../lib/zips.js')
62
+ const { renderPptxToPng, findPowerPoint } = await import('../lib/msrender.js')
63
+
64
+ console.log('=== pptd 导出基元自证(阶段 A:折线 / 箭头两端 / 虚线 / 字节不变)===')
65
+ console.log(`仓库:${root}`)
66
+ console.log(`临时工作区:${WORK}`)
67
+ console.log(`PowerPoint:${findPowerPoint() ?? '(未探到)'}|Office 渲染降级开关:${allowSkipOffice ? '开' : '关'}`)
68
+
69
+ rmSync(WORK, { recursive: true, force: true })
70
+ mkdirSync(WORK, { recursive: true })
71
+
72
+ // ══ 夹具 1:基元页(4 点折线 both+dot / 2 点直线 arrow:true 回归)════════════
73
+ // 坐标全部写常量(YAML 由常量生成)——避免"手写 YAML vs 断言常量"两侧漂移。
74
+ const POLY = { id: 'poly1', pts: [[80, 120], [320, 120], [320, 300], [560, 300]], color: '#1F2937', width: 3, arrow: 'both', dash: 'dot' }
75
+ const REG = { id: 'line2', pts: [[560, 420], [860, 420]], color: '#1F2937', width: 3, arrow: true }
76
+
77
+ const DECK = join(WORK, 'deck')
78
+ mkdirSync(join(DECK, 'pages'), { recursive: true })
79
+ writeFileSync(join(DECK, 'deck.yaml'), [
80
+ 'version: 1',
81
+ 'title: primitives-verify',
82
+ 'size: [960, 540]',
83
+ 'theme:',
84
+ ' colors: {primary: "#2563EB", text: "#1F2937"}',
85
+ ' textStyles:',
86
+ ' body: {fontSize: 16, color: "$text"}',
87
+ 'pages:',
88
+ ' - pages/01_primitives.yaml',
89
+ '',
90
+ ].join('\n'), 'utf8')
91
+ writeFileSync(join(DECK, 'pages', '01_primitives.yaml'), [
92
+ 'pageType: content',
93
+ 'elements:',
94
+ ` - elementId: ${POLY.id}`,
95
+ ' elementType: line',
96
+ ` points: ${JSON.stringify(POLY.pts)}`,
97
+ ` arrow: ${POLY.arrow}`,
98
+ ` line: {color: "${POLY.color}", width: ${POLY.width}, dash: ${POLY.dash}}`,
99
+ ` - elementId: ${REG.id}`,
100
+ ' elementType: line',
101
+ ` points: ${JSON.stringify(REG.pts)}`,
102
+ ` arrow: ${REG.arrow}`,
103
+ ` line: {color: "${REG.color}", width: ${REG.width}}`,
104
+ '',
105
+ ].join('\n'), 'utf8')
106
+
107
+ // 老工程夹具(**一个字段都不新**):文本 / 表格 / 形状(含渐变) / 2 点线(arrow: true) / 图片 / 讲稿。
108
+ // 用它同时跑:字节不变(vs 基线 tag)+ 确定性 + 2 点线回归。
109
+ const legacy = join(WORK, 'legacy')
110
+ mkdirSync(join(legacy, 'pages'), { recursive: true })
111
+ mkdirSync(join(legacy, 'media'), { recursive: true })
112
+ writeFileSync(join(legacy, 'media', 'px.png'), Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'))
113
+ writeFileSync(join(legacy, 'deck.yaml'), [
114
+ 'version: 1', 'title: legacy-bytes', 'size: [960, 540]', 'theme:',
115
+ ' colors: {primary: "#2563EB", text: "#1F2937", accent: "#F59E0B"}', ' textStyles:',
116
+ ' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', '',
117
+ ].join('\n'), 'utf8')
118
+ writeFileSync(join(legacy, 'pages', '01.yaml'), [
119
+ 'pageType: content',
120
+ 'notes: 讲稿一行',
121
+ 'elements:',
122
+ ' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 400, 40]', ' content: {text: "标题 <a&b>", style: "$body"}',
123
+ ' - elementId: tb', ' elementType: table', ' bounds: [40, 100, 300, 80]', ' cols: [指标, 值]', ' rows: [["甲", "1"], ["乙", "2"]]',
124
+ ' - elementId: sh1', ' elementType: shape', ' bounds: [400, 100, 120, 80]', ' kind: roundRect', ' fill: "$primary"',
125
+ ' - elementId: sh2', ' elementType: shape', ' bounds: [400, 200, 120, 80]', ' kind: ellipse', ' fill: {type: gradient, stops: [{pos: 0, color: "#2563EB"}, {pos: 100, color: "#F59E0B"}], angle: 45}',
126
+ ' - elementId: ln1', ' elementType: line', ' points: [[560, 120], [700, 80]]', ' line: {color: "#1F2937", width: 2}', ' arrow: true',
127
+ ' - elementId: im1', ' elementType: image', ' bounds: [560, 160, 120, 80]', ' src: media/px.png',
128
+ '',
129
+ ].join('\n'), 'utf8')
130
+
131
+ // 老工程上下文可解析(既有字段)——构建导出上下文与老夹具共用。
132
+ const legacyCtx = await resolveDeck(legacy)
133
+
134
+ /**
135
+ * 夹具导出器(隔离导出层的唯一诚实做法):
136
+ * `src/pptd/export-pptx.js` 里是 `import { normalizePage } from './layout.js'` —— **ESM 具名导入是活绑定**,
137
+ * 调用点直接引用导入进来的那个函数,`ctx.normalizePage` 之类的外部注入**完全不生效**(实测:注入了也没用)。
138
+ * 所以在"layout.js 尚未透传新字段"的当下,要单独验证导出层,只能做一个**字面源码副本**:
139
+ * ① 把整棵 `src/` 拷进 node_modules 下的临时目录(相对 import 与裸包 `yaml` 都能解析;跑完即删);
140
+ * ② 只改 `pptd/layout.js` 的 `normalizePage` 导出名 → `normalizePageReal`,并追加一个包装:
141
+ * 调用真实现之前,把**布局层还没实现透传**的两个字段(`arrow` 原值、`line.dash`)按原值保住;
142
+ * ③ 用副本里的 `exportPptx` 导出同一份 deck(颜色解析等仍走真实 layout/schema 逻辑,不另写一套)。
143
+ * 这样"产物 XML 断言"证明的是**导出层的实现**,而"整链是否接通"由 ⑮ 单独判(真管线)。二者不混淆。
144
+ */
145
+ const FIXTURE_DIR = join(root, 'node_modules', '.pptd-fixture-src')
146
+ {
147
+ const { cpSync } = await import('node:fs')
148
+ rmSync(FIXTURE_DIR, { recursive: true, force: true })
149
+ cpSync(join(root, 'src'), FIXTURE_DIR, { recursive: true })
150
+ const layoutPath = join(FIXTURE_DIR, 'pptd', 'layout.js')
151
+ let layoutSrc = readFileSync(layoutPath, 'utf8')
152
+ layoutSrc = layoutSrc.replace('export function normalizePage(page, ctx) {', 'export function normalizePageReal(page, ctx) {')
153
+ layoutSrc += `
154
+ // ── 夹具包装(仅存在于 node_modules 下的临时副本;不是仓库源码)──
155
+ // 目的:把"布局层还没透传"的新字段按原值保住,从而单独验证**导出层**。
156
+ export function normalizePage(page, ctx) {
157
+ const prev = (page.page?.elements ?? []).filter((e) => e.elementType === 'line')
158
+ const els = normalizePageReal(page, ctx)
159
+ for (const el of els) {
160
+ if (el.type !== 'line') continue
161
+ const raw = prev.find((r) => r.elementId === el.id)
162
+ if (!raw) continue
163
+ el.arrow = raw.arrow
164
+ if (raw.line?.dash !== undefined) el.line = { ...el.line, dash: raw.line.dash }
165
+ }
166
+ return els
167
+ }
168
+ `
169
+ writeFileSync(layoutPath, layoutSrc)
170
+ // 换掉绝对路径依赖(schema.js / svgCharts.js 里的 ../zips.js)——复制成同目录裸名模块
171
+ const shim = { 'schema.js': '../zips.js', 'svgCharts.js': '../zips.js' }
172
+ for (const [file, spec] of Object.entries(shim)) {
173
+ const p = join(FIXTURE_DIR, 'pptd', file)
174
+ if (!existsSync(p)) continue
175
+ writeFileSync(p, readFileSync(p, 'utf8').split(`'${spec}'`).join(`'./zips.js'`))
176
+ }
177
+ cpSync(join(FIXTURE_DIR, 'zips.js'), join(FIXTURE_DIR, 'pptd', 'zips.js'))
178
+ }
179
+ const fixtureMod = await import(pathToFileURL(join(FIXTURE_DIR, 'pptd', 'export-pptx.js')).href)
180
+
181
+ /** 夹具上下文:原始元素直接挂到 `page.elements`(导出器读的就是这里)。 */
182
+ function ctxWithElements(baseCtx, elements, pageMeta = {}) {
183
+ return {
184
+ ...baseCtx,
185
+ size: baseCtx.size ?? { width: 960, height: 540 },
186
+ pages: [{ file: join(WORK, 'fixture.yaml'), ref: 'fixture.yaml', name: 'fixture', index: 0, page: { pageType: 'content', elements, ...pageMeta } }],
187
+ }
188
+ }
189
+
190
+ const exportFixture = (baseCtx, rawElements, out, pageMeta) =>
191
+ fixtureMod.exportPptx(ctxWithElements(baseCtx, rawElements, pageMeta), { out })
192
+
193
+ /** 多页夹具:rows = [[elements, pageMeta?], …] ⇒ 第 1 页/第 2 页…(用于"折线页 + 回归页"分别真渲染)。 */
194
+ const exportFixturePages = (baseCtx, rows, out) =>
195
+ fixtureMod.exportPptx({
196
+ ...baseCtx,
197
+ size: baseCtx.size ?? { width: 960, height: 540 },
198
+ pages: rows.map(([elements, pageMeta], i) => ({
199
+ file: join(WORK, `fixture${i + 1}.yaml`), ref: `fixture${i + 1}.yaml`, name: `fixture${i + 1}`, index: i,
200
+ page: { pageType: 'content', elements, ...(pageMeta ?? {}) },
201
+ })),
202
+ }, { out })
203
+
204
+ /**
205
+ * 隔离导出层的夹具构造(**为什么要这么做**):
206
+ * `export-pptx.js` 里写的是 `import { normalizePage } from './layout.js'` —— ESM 的具名导入是**活绑定**,
207
+ * 不看 `ctx.normalizePage`(我试过:注入 `ctx.normalizePage` 完全无效,产物里 'both'/dash 依旧丢失)。
208
+ * 所以在"layout.js 还没透传新字段"的当下,要单独验证导出层只有一条路:
209
+ * 自己产出**与真实 (layout.js) 归一化结果同构**的归一化元素,再把新字段按原值补上,直接喂给导出器。
210
+ * 颜色解析等仍走 `resolveColor`(契约不另写一套);`arrow`/`dash` 是布局层**尚未实现**的透传(⑮ 判定的缺口)。
211
+ * @param raw 原始元素(归一化前,含 arrow 原值 / line.dash)
212
+ */
213
+ function normRaw(baseCtx, raw) {
214
+ return {
215
+ id: raw.elementId,
216
+ type: raw.elementType,
217
+ bounds: (() => {
218
+ if (Array.isArray(raw.bounds)) return { x: raw.bounds[0], y: raw.bounds[1], w: raw.bounds[2], h: raw.bounds[3] }
219
+ const pts = raw.points ?? []
220
+ const xs = pts.map((p) => p[0])
221
+ const ys = pts.map((p) => p[1])
222
+ const x = Math.min(...xs)
223
+ const y = Math.min(...ys)
224
+ return { x, y, w: Math.max(1, Math.max(...xs) - x), h: Math.max(1, Math.max(...ys) - y) }
225
+ })(),
226
+ ...(raw.role ? { role: raw.role } : {}),
227
+ ...(raw.elementType === 'line'
228
+ ? {
229
+ points: (raw.points ?? [[raw.x1, raw.y1], [raw.x2, raw.y2]]).map((p) => [...p]),
230
+ arrow: raw.arrow, // ← layout.js 现在写的是 `!!el.arrow`('both' 会被压成 true)
231
+ line: { color: baseCtx.resolveColor(raw.line?.color ?? '#000'), width: raw.line?.width ?? 1, ...(raw.line?.dash !== undefined ? { dash: raw.line.dash } : {}) }, // ← dash 现在被丢
232
+ }
233
+ : {}),
234
+ ...(raw.elementType === 'shape'
235
+ ? {
236
+ kind: raw.kind ?? 'rect',
237
+ fill: raw.fill !== undefined ? baseCtx.resolveColor(raw.fill) : undefined,
238
+ ...(raw.path ? { path: raw.path } : {}),
239
+ rotation: raw.rotation ?? 0,
240
+ }
241
+ : {}),
242
+ }
243
+ }
244
+
245
+ // 夹具漂移体检:真 `layout.js` 的归一化产物 vs 夹具构造的归一化产物。
246
+ // 差异必须**只有**那两个待接线的字段(arrow 原值 / line.dash)——多一处就说明夹具构造漂移了。
247
+ if (process.env.PPT_STUDIO_PRIMITIVES_DRIFT === '1') {
248
+ const { normalizePage } = await import('../lib/pptd/layout.js')
249
+ const stdPage = ctxWithElements(legacyCtx, PRIM_ELEMENTS)
250
+ const std = normalizePage(stdPage.pages[0], stdPage)
251
+ const iso = PRIM_ELEMENTS.map((e) => normRaw(legacyCtx, e))
252
+ console.log('\n=== 夹具漂移体检(PPT_STUDIO_PRIMITIVES_DRIFT=1)===')
253
+ for (let i = 0; i < std.length; i++) {
254
+ const keys = new Set([...Object.keys(std[i]), ...Object.keys(iso[i])])
255
+ for (const k of keys) {
256
+ const a = JSON.stringify(std[i][k])
257
+ const b = JSON.stringify(iso[i][k])
258
+ if (a !== b) console.log(` 差异 ${std[i].id}.${k}: layout=${a} ↔ 夹具=${b}`)
259
+ }
260
+ }
261
+ console.log('=== 漂移体检结束 ===\n')
262
+ }
263
+
264
+ const PRIM_ELEMENTS = [
265
+ { elementId: POLY.id, elementType: 'line', points: POLY.pts, arrow: POLY.arrow, line: { color: POLY.color, width: POLY.width, dash: POLY.dash } },
266
+ { elementId: REG.id, elementType: 'line', points: REG.pts, arrow: REG.arrow, line: { color: REG.color, width: REG.width } },
267
+ ]
268
+ const REG_ELEMENTS = [
269
+ { elementId: 'plain1', elementType: 'line', points: [[80, 200], [400, 380]], line: { color: '#2563EB', width: 2 } },
270
+ { elementId: 'both2', elementType: 'line', points: [[500, 200], [860, 200]], arrow: true, line: { color: '#1F2937', width: 2 } },
271
+ ]
272
+ // 回归专项:一个**新字段都不用**的页面(2 点 + arrow: true + 无 dash + 一个纯形状)
273
+ const PLAIN_ELEMENTS = [
274
+ ...REG_ELEMENTS,
275
+ { elementId: 'card', elementType: 'shape', kind: 'roundRect', bounds: [80, 60, 200, 80], fill: '#2563EB' },
276
+ ]
277
+
278
+ // ── 基线导出器(回滚锚点 tag;只读取,绝不改动它)────────────────────────────
279
+ // 取整棵 baseline `src/` 到**仓库内 node_modules 下的临时目录**:① 它的相对 import(./layout.js 等)才能解析;
280
+ // ② `yaml` 这类裸包说明符要靠 node_modules 逐级上溯才找得到(放到系统 TEMP 下会 ERR_MODULE_NOT_FOUND)。
281
+ // node_modules 是 gitignore 的 ⇒ 不产生任何可提交文件;跑完即删。
282
+ const BASE_REF = 'v1.1.5-baseline-before-diagram'
283
+ const baseDir = join(root, 'node_modules', '.pptd-baseline-src')
284
+ rmSync(baseDir, { recursive: true, force: true })
285
+ const baseProbe = spawnSync('git', ['-C', root, 'cat-file', '-e', `${BASE_REF}^{commit}`], { encoding: 'utf8' })
286
+ let baseReady = baseProbe.status === 0
287
+ let baseNote = baseReady ? `基线 = ${BASE_REF}` : `git 取不到 ${BASE_REF}(无 git / 无该 tag)`
288
+ if (baseReady) {
289
+ const list = spawnSync('git', ['-C', root, 'ls-tree', '-r', '--name-only', BASE_REF, 'src'], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
290
+ const files = String(list.stdout ?? '').trim().split('\n').filter(Boolean)
291
+ baseReady = list.status === 0 && files.length > 0
292
+ for (const f of files) {
293
+ const r = spawnSync('git', ['-C', root, 'show', `${BASE_REF}:${f}`], { maxBuffer: 32 * 1024 * 1024 })
294
+ if (r.status !== 0) { baseReady = false; break }
295
+ const rel = f.replace(/^src\//, '')
296
+ mkdirSync(join(baseDir, dirname(rel)), { recursive: true })
297
+ writeFileSync(join(baseDir, rel), r.stdout)
298
+ }
299
+ if (!baseReady) baseNote = `基线 ${BASE_REF} 的 src/ 取出失败`
300
+ }
301
+ const baselineMod = baseReady ? await import(pathToFileURL(join(baseDir, 'pptd', 'export-pptx.js')).href) : null
302
+
303
+ /**
304
+ * 同一 deck 分别用**基线导出器**与**当前导出器**导出,逐部件比对(只跳过 core.xml 时间戳)。
305
+ * @returns {boolean|null} true=逐字节一致;false=有差异(明细已打印);null=基线不可用
306
+ */
307
+ async function compareWithBaseline(useCtx, newOut, oldOut, label = '') {
308
+ if (!baseReady || !baselineMod) return null
309
+ await exportPptx(useCtx, { out: newOut })
310
+ await baselineMod.exportPptx(useCtx, { out: oldOut })
311
+ const A = zipRead(readFileSync(oldOut))
312
+ const B = zipRead(readFileSync(newOut))
313
+ const skip = new Set(['docProps/core.xml']) // 时间戳必然不同
314
+ const keys = new Set([...A.keys(), ...B.keys()])
315
+ const diffs = []
316
+ for (const k of keys) {
317
+ if (skip.has(k)) continue
318
+ const x = A.get(k)
319
+ const y = B.get(k)
320
+ if (!x || !y) { diffs.push(`${k}: 只在${x ? '旧' : '新'}包里`); continue }
321
+ if (!x.equals(y)) diffs.push(`${k}: 内容不同(旧 ${x.length}B / 新 ${y.length}B)`)
322
+ }
323
+ if (diffs.length) console.log(` └─ ${label}差异 ${diffs.length} 处:${diffs.slice(0, 8).join(' | ')}`)
324
+ else console.log(` └─ ${label}${keys.size - skip.size} 个部件逐字节一致`)
325
+ return diffs.length === 0
326
+ }
327
+
328
+ // ══ 1. 导出 + 产物 XML 断言 ═════════════════════════════════════════════════
329
+ h('1. 导出与产物 XML 断言(从 zip 反读 slide1.xml)')
330
+ const outPptx = join(WORK, 'out.pptx')
331
+ // 两页:第 1 页 = 折线(both + dot)+ 同页 2 点直线(arrow: true);第 2 页 = 纯既有字段回归页。
332
+ // 真渲染只渲第 1 页(折线证据),回归页单独出一张图 —— 一页里塞太多会让像素判据互相污染。
333
+ const report = await exportFixturePages(legacyCtx, [[PRIM_ELEMENTS], [PLAIN_ELEMENTS]], outPptx)
334
+ const parts = zipRead(readFileSync(outPptx))
335
+ const s1 = decodeXml(parts.get('ppt/slides/slide1.xml'))
336
+ const s2 = decodeXml(parts.get('ppt/slides/slide2.xml'))
337
+ info(`parity = ${JSON.stringify(report.parity)}`)
338
+
339
+ // ── 4 点折线:custGeom 开放路径 ──────────────────────────────────────────
340
+ check('① >2 点折线导出为 a:custGeom 开放路径(moveTo + 3×lnTo,同一节内)',
341
+ /<a:custGeom>/.test(s1) && /<a:moveTo><a:pt /.test(s1) && (s1.match(/<a:lnTo>/g) ?? []).length === 3,
342
+ (s1.match(/<a:path\b[^>]*>[\s\S]*?<\/a:path>/) ?? ['(无 a:path)'])[0])
343
+ check('② 开放路径**不 close**(有 a:close 会把折线连成封闭多边形 + 多一条"回程"边)',
344
+ !/<a:close\/>/.test(s1))
345
+ check('③ 折线无填充(a:noFill 在 custGeom 之后、a:ln 之前;子元素顺序 geom → fill → ln)',
346
+ /<\/a:custGeom><a:noFill\/><a:ln\b/.test(s1))
347
+ check('④ a:path/@fill="none"(路径自身不带填充)', /<a:path w="100000" h="100000" fill="none">/.test(s1))
348
+ check('⑤ 箭头两端都在:<a:headEnd> 与 <a:tailEnd> 同时存在(arrow: both)',
349
+ /<a:headEnd type="triangle"/.test(s1) && /<a:tailEnd type="triangle"/.test(s1))
350
+ check('⑥ 子元素顺序严格:solidFill → prstDash → headEnd → tailEnd(都在同一段 <a:ln> 内)',
351
+ /<a:ln w="\d+"><a:solidFill>[\s\S]*?<\/a:solidFill><a:prstDash val="dot"\/><a:headEnd [^>]*\/><a:tailEnd [^>]*\/><\/a:ln>/.test(s1))
352
+ /**
353
+ * 从 slide XML 切出某个元素的完整片段(含它的开标签)。分元素断言的共用工具:
354
+ * 一页里有多条线时,"整页统计"会互相污染(实测踩过:折线段被切到了隔壁 cxnSp 的收尾)。
355
+ */
356
+ function elementSeg(xml, id) {
357
+ const nameAt = xml.indexOf(`name="${id}"`)
358
+ if (nameAt < 0) return ''
359
+ const spStart = xml.lastIndexOf('<p:sp>', nameAt)
360
+ const cxnStart = xml.lastIndexOf('<p:cxnSp>', nameAt)
361
+ if (spStart < 0 && cxnStart < 0) return ''
362
+ const isSp = spStart > cxnStart
363
+ const start = isSp ? spStart : cxnStart
364
+ const end = xml.indexOf(isSp ? '</p:sp>' : '</p:cxnSp>', nameAt)
365
+ return xml.slice(start, end < 0 ? xml.length : end + (isSp ? 8 : 11))
366
+ }
367
+
368
+ check('⑦ prstDash 值正确:dash: dot → <a:prstDash val="dot"/>', /<a:prstDash val="dot"\/>/.test(s1))
369
+ // 注意:本页有 **2 个 line 元素**(折线 + 回归直线)⇒ 断言必须分元素段做,否则会被隔壁元素污染。
370
+ const polySeg = elementSeg(s1, POLY.id)
371
+ const regSeg = elementSeg(s1, REG.id)
372
+ check('⑧ 折线是一帧 p:sp(不是 N 条 cxnSp)——整页 p:sp=1 / p:cxnSp=1,且折线段是 p:sp 且含 custGeom',
373
+ (s1.match(/<p:sp>/g) ?? []).length === 1 && (s1.match(/<p:cxnSp>/g) ?? []).length === 1 &&
374
+ polySeg.startsWith('<p:sp>') && polySeg.includes('<a:custGeom>'),
375
+ `p:sp=${(s1.match(/<p:sp>/g) ?? []).length} p:cxnSp=${(s1.match(/<p:cxnSp>/g) ?? []).length}|折线段=${polySeg.slice(0, 80)}…`)
376
+
377
+ // ── 折线几何反推(写出的 XML → 页面坐标,必须与源 points 一致)─────────────
378
+ const backPoly = polylineEndsFromXml(polySeg)
379
+ check('⑨ 折线几何反推:custGeom 回读的 4 个顶点 == 源 points(容差 0.01px)',
380
+ backPoly !== null && backPoly.length === 4 && backPoly.every((p, i) => Math.abs(p[0] - POLY.pts[i][0]) < 0.01 && Math.abs(p[1] - POLY.pts[i][1]) < 0.01),
381
+ `源 ${JSON.stringify(POLY.pts)} → 回读 ${JSON.stringify(backPoly?.map((p) => p.map((v) => Math.round(v * 100) / 100)))}`)
382
+
383
+ // ── 2 点直线(回归):仍是 straightConnector1,且 arrow: true 语义不变 ────────
384
+ check('⑩ 同页 2 点直线未被折线改动波及:该元素段内是 straightConnector1,**没有** custGeom',
385
+ /<a:prstGeom prst="straightConnector1">/.test(regSeg) && !/<a:custGeom>/.test(regSeg),
386
+ regSeg.slice(0, 300))
387
+ check('⑪ arrow: true 语义不变:该元素段内只有 tailEnd、**没有** headEnd',
388
+ /<a:tailEnd type="triangle"/.test(regSeg) && !/<a:headEnd/.test(regSeg),
389
+ `tailEnd=${/<a:tailEnd/.test(regSeg)} headEnd=${/<a:headEnd/.test(regSeg)}`)
390
+ check('⑫ 没用 dash 的线不写 <a:prstDash>(缺省 solid ⇒ 元素都不出现)',
391
+ !/<a:prstDash/.test(regSeg))
392
+ // 回归专项夹具(**独立文件名**:legacyCtx 自带的页面是"老工程 deck",复用同一路径会把两者互相覆盖——
393
+ // 本轮实测踩过:plain-out.pptx 先被回归夹具写、又被老工程 deck 覆写,导致像素判据量的是另一张图)。
394
+ const PLAIN_FIXTURE_OUT = join(WORK, 'plainfixture-out.pptx')
395
+ await exportFixture(legacyCtx, PLAIN_ELEMENTS, PLAIN_FIXTURE_OUT)
396
+ const plainS = decodeXml(zipRead(readFileSync(PLAIN_FIXTURE_OUT)).get('ppt/slides/slide1.xml'))
397
+ check('⑬ 回归页(纯既有字段)产物里一个 custGeom / headEnd / prstDash 都没有',
398
+ !/<a:custGeom>/.test(plainS) && !/<a:headEnd/.test(plainS) && !/<a:prstDash/.test(plainS),
399
+ `元素=${(plainS.match(/name="[^"]*"/g) ?? []).join(' ')}`)
400
+ check('⑬b 回归专项:纯既有字段页面(2 点线 arrow:true + 形状)与**基线导出器**产物逐部件逐字节一致',
401
+ await compareWithBaseline(legacyCtx, PLAIN_FIXTURE_OUT, join(WORK, 'plainfixture-base.pptx')) === true,
402
+ '差异见上方 ⑬b 的逐部件明细(基线 = v1.1.5-baseline-before-diagram)')
403
+ // 同页第二页(out.pptx 的 slide2)也是纯既有字段 ⇒ 同样断"没有新形态"
404
+ check('⑬c 同一夹具的第 2 页(纯既有字段)产物里同样没有 custGeom / headEnd / prstDash',
405
+ !/<a:custGeom>/.test(s2) && !/<a:headEnd/.test(s2) && !/<a:prstDash/.test(s2),
406
+ `元素=${(s2.match(/name="[^"]*"/g) ?? []).join(' ')}`)
407
+
408
+ // ── parity 自证 ───────────────────────────────────────────────────────────
409
+ // 两页合计:折线 1(页1)+ 2 点直线 3(页1 一条、页2 两条)⇒ poly 1/1、lines 3/3、'both' 头端 1/1。
410
+ const p = report.parity ?? {}
411
+ check('⑭ parity 自证全绿(含新增 polyLinesExp/Out、arrowEndsExp/Out)',
412
+ p.ok === true && p.polyLinesExp === 1 && p.polyLinesOut === 1 && p.polyLinesWrong === 0 &&
413
+ p.arrowEndsExp === 1 && p.arrowEndsOut === 1 && p.linesExp === 3 && p.linesOut === 3 && p.linesWrong === 0,
414
+ `ok=${p.ok} poly=${p.polyLinesExp}/${p.polyLinesOut}/wrong${p.polyLinesWrong} arrowHeads=${p.arrowEndsExp}/${p.arrowEndsOut} lines=${p.linesExp}/${p.linesOut}/wrong${p.linesWrong}`)
415
+
416
+ // ── 整链是否接通(真 DSL 路径:resolveDeck → validatePage → normalizePage → export)────────
417
+ // 这是**独立**于上面各条的一条判据:上面的产物断言证明"导出层的实现是对的",
418
+ // 这一条证明"新字段真的能从 deck.yaml 一路走到产物里"(schema 放行 + 归一层原样透传)。
419
+ {
420
+ let dslCtx = null
421
+ let dslErr = null
422
+ try { dslCtx = await resolveDeck(DECK) } catch (e) { dslErr = e }
423
+ let wired = false
424
+ let detail = ''
425
+ if (!dslCtx) {
426
+ const msgs = (dslErr?.errors ?? [dslErr?.message ?? String(dslErr)]).join(' | ')
427
+ detail = `deck.yaml 未被 schema 接受 ⇒ schema 侧尚未放行新字段(本脚本写域外,Lead 负责):${msgs}`
428
+ } else {
429
+ const el = normalizePage(dslCtx.pages[0], dslCtx).find((e) => e.id === POLY.id)
430
+ const polyOk = Array.isArray(el?.points) && el.points.length === 4
431
+ const arrowOk = el?.arrow === 'both'
432
+ const dashOk = el?.line?.dash === 'dot'
433
+ wired = polyOk && arrowOk && dashOk
434
+ const pOut = join(WORK, 'pipeline.pptx')
435
+ await exportPptx(dslCtx, { out: pOut })
436
+ const ps = decodeXml(zipRead(readFileSync(pOut)).get('ppt/slides/slide1.xml'))
437
+ wired = wired && /<a:headEnd/.test(ps) && /<a:prstDash val="dot"\/>/.test(ps) && (ps.match(/<a:lnTo>/g) ?? []).length === 3
438
+ detail = `归一化后:points=${JSON.stringify(el?.points)} arrow=${JSON.stringify(el?.arrow)} line=${JSON.stringify(el?.line)} ⇒ 产物 headEnd=${/<a:headEnd/.test(ps)} prstDash=${/<a:prstDash val="dot"\/>/.test(ps)}`
439
+ if (!wired) {
440
+ detail += '\n └─ 缺口:src/pptd/layout.js 的 case \'line\' 现在是 `arrow: !!el.arrow`(\'both\' 被压成 true)'
441
+ + '且 `line` 只留 {color,width}(dash 被丢)⇒ 导出层无法区分 both/solid。'
442
+ + 'schema 侧放行后,归一层还需把 arrow 原值(含字符串)与 line.dash 一起透传。'
443
+ }
444
+ }
445
+ check('⑮ 整链接通(真 DSL 路径 ⇒ 本脚本 exit 0 的前提):deck.yaml → schema → normalizePage → 产物 全链把新字段带到位',
446
+ wired, detail)
447
+ }
448
+
449
+ // 未接通时的响亮提示:不要让"16 条 PASS 但因为整链没通"看起来像全绿
450
+ check('⑯ 打样接口说明:本脚本的①..⑭条走的是"夹具直喂归一化元素"(隔离导出层),⑮才是整链契约',
451
+ true, '若 ⑮ FAIL,请先让 schema/layout 放行新字段,再重跑本脚本——那时 ⑮ 与 ①..⑭ 会同时全绿')
452
+
453
+ // ══ 2. 真渲染(PowerPoint COM)══════════════════════════════════════════════
454
+ h('2. 真渲染:PowerPoint COM 逐页出 PNG + 像素级确认折线真的画出来了')
455
+ const shots = join(WORK, 'shots')
456
+ let pngFiles = []
457
+ let renderNote = ''
458
+ // COM 偶发 `Presentations.Open : Failed`:实测是**上一条渲染残留的 POWERPNT 实例**在作怪
459
+ // (同一份 XML 在 3 轮 ×7 变体里出图 21/21,失败只在实例未回收时出现)⇒ 渲染前先清残留。
460
+ const killPowerPoint = () => {
461
+ try { spawnSync('powershell', ['-NoProfile', '-Command', 'Get-Process POWERPNT -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue'], { windowsHide: true, timeout: 30000 }) } catch { /* 忽略 */ }
462
+ }
463
+ if (findPowerPoint() !== null) {
464
+ for (let attempt = 1; attempt <= 3 && pngFiles.length === 0; attempt++) {
465
+ if (attempt > 1) killPowerPoint()
466
+ try {
467
+ const r = await renderPptxToPng(outPptx, shots, { width: 1920, height: 1080, timeoutMs: 300000, pages: [1, 2] })
468
+ pngFiles = r.files
469
+ renderNote = `pages=${r.pages} png=${r.files.length}${attempt > 1 ? `(第 ${attempt} 次尝试成功;前次为 COM 残留实例)` : ''}`
470
+ } catch (e) {
471
+ renderNote = `COM 失败(第 ${attempt} 次):${String(e?.message ?? e).split('\n')[0]}`
472
+ }
473
+ }
474
+ } else {
475
+ renderNote = '未探到 PowerPoint(findPowerPoint() === null)'
476
+ }
477
+ if (pngFiles.length === 0) {
478
+ check('真渲染出图(PowerPoint COM)', allowSkipOffice, `${renderNote}${allowSkipOffice ? '(已按 PPT_STUDIO_ALLOW_SKIP_OFFICE=1 降级为 SKIP)' : '——真渲染是本批改动的必需证据(custGeom 开放路径只有 PowerPoint 认才算数)'}`)
479
+ } else {
480
+ check('真渲染出图(PowerPoint COM):两页都出图(页1=折线 both+dot,页2=纯既有字段回归)', pngFiles.length === 2, renderNote)
481
+ for (const f of pngFiles) console.log(` PNG(绝对路径,Lead 读图核对):${f}`)
482
+
483
+ // ── 像素级确认:折线真的画出来了(不是"文件能打开")──────────────────────
484
+ // PNG 自己解(零依赖):IHDR 取尺寸、IDAT inflate、反过滤。
485
+ const png1 = pngFiles.find((f) => /01\.png$/.test(f)) ?? pngFiles[0]
486
+ const img = decodePng(readFileSync(png1))
487
+ check('真渲染 PNG 可解码(IHDR/IDAT 自洽)', !!img && img.width > 800, img ? `${img.width}×${img.height} colorType=${img.colorType}` : '解码失败')
488
+ if (img) {
489
+ const scale = img.width / 960 // COM 1920/960 = 2×(按实际宽度换算,不写死)
490
+ const dark = (r) => countInk(img, r, [31, 41, 55], scale, 60) // #1F2937 ± 60
491
+ // 折线三段:两条横线 + 一条竖线 + 两个折点 + 两端箭头区域
492
+ const segTop = dark([120, 100, 480, 140])
493
+ const segBottom = dark([340, 280, 540, 320])
494
+ const segVert = dark([300, 150, 340, 270])
495
+ const headEnd = dark([62, 104, 100, 136])
496
+ const tailEnd = dark([520, 284, 560, 316])
497
+ const empty = dark([700, 60, 900, 200])
498
+ info(`深色命中像素:上横段 ${segTop}|下横段 ${segBottom}|竖段 ${segVert}|起点箭头区 ${headEnd}|终点箭头区 ${tailEnd}|空白对照区 ${empty}`)
499
+ check('像素级:折线三段都被 PowerPoint 真的画出来了(上横 / 竖 / 下横)',
500
+ segTop > 40 && segBottom > 40 && segVert > 40,
501
+ `上=${segTop} 竖=${segVert} 下=${segBottom}(空白对照=${empty})`)
502
+ check('像素级:空白对照区无同色像素(说明上面的命中不是"整页涂色")', empty < 20, `empty=${empty}`)
503
+
504
+ // ── 第 1 页:同页 2 点直线 + arrow: true(回归)也画出来了 ────────────
505
+ const bothSeg = dark([600, 400, 840, 440]) // line2 = (560,420)→(860,420)
506
+ const bothArrow = dark([820, 404, 872, 436]) // 末端三角头
507
+ info(`页 1 回归线命中:arrow:true 水平线中段 ${bothSeg}|末端箭头区 ${bothArrow}`)
508
+ check('像素级:页 1 的 2 点直线(arrow: true)被正常画出(旧码路未被波及)', bothSeg > 20, `中段=${bothSeg} 箭头区=${bothArrow}`)
509
+
510
+ // ── 第 2 页:纯既有字段回归页(斜线 + 圆角矩形)───────────────────────
511
+ const png2 = pngFiles.find((f) => /02\.png$/.test(f))
512
+ if (png2) {
513
+ const img2 = decodePng(readFileSync(png2))
514
+ if (img2) {
515
+ const sc2 = img2.width / 960
516
+ // plain1 = (80,200)→(400,380):只取斜线扫过的对角窄带(避开同页其它元素)
517
+ const diagBlue = countInk(img2, [150, 240, 290, 330], [37, 99, 235], sc2, 90)
518
+ const diagAny = countInk(img2, [150, 240, 290, 330], [31, 41, 55], sc2, 60)
519
+ const card = countInk(img2, [100, 70, 260, 130], [37, 99, 235], sc2, 90)
520
+ info(`页 2(回归页):斜线命中 蓝=${diagBlue} 深=${diagAny}|圆角矩形卡命中=${card}`)
521
+ check('像素级:页 2 的纯既有字段元素(斜线 + 圆角矩形)被正常画出',
522
+ diagBlue + diagAny > 20 && card > 200,
523
+ `斜线合计=${diagBlue + diagAny} 卡片=${card}`)
524
+ }
525
+ }
526
+ }
527
+ }
528
+
529
+ // ══ 3. 负面对照(schema 层拒收 + 导出层不静默)══════════════════════════════
530
+ h('3. 负面对照:非法值必须被拒(schema)或降级并告警(export)——绝不许静默产坏 XML')
531
+ const badCases = [
532
+ { name: "arrow: 'bogus'", el: { elementId: 'b1', elementType: 'line', points: [[10, 10], [100, 10]], arrow: 'bogus' } },
533
+ { name: "line.dash: 'bogus'", el: { elementId: 'b2', elementType: 'line', points: [[10, 10], [100, 10]], line: { color: '#000', width: 1, dash: 'bogus' } } },
534
+ { name: '单点 points: [[10,10]]', el: { elementId: 'b3', elementType: 'line', points: [[10, 10]] } },
535
+ ]
536
+ for (const c of badCases) {
537
+ let schemaErr = null
538
+ try { schemaErr = validatePage({ pageType: 'content', elements: [c.el] }, 'neg.yaml') } catch (e) { schemaErr = e }
539
+ const rejected = !!(schemaErr && (schemaErr.errors?.length || String(schemaErr).length))
540
+ const msgs = schemaErr?.errors ?? (schemaErr ? [String(schemaErr)] : [])
541
+ check(`schema 拒收 ${c.name}`, rejected, rejected ? msgs.join(' | ').slice(0, 200) : 'schema 未拒收(非法值会被静默接受)')
542
+ }
543
+
544
+ // 导出层:把非法值直接喂给导出器 ⇒ 必须降级 + 进 warnings,且产物里没有坏 XML
545
+ const BAD_ELEMENTS = [
546
+ { elementId: 'bad_arrow', elementType: 'line', points: [[10, 10], [200, 10]], arrow: 'bogus', line: { color: '#1F2937', width: 1 } },
547
+ { elementId: 'bad_dash', elementType: 'line', points: [[10, 40], [200, 40]], line: { color: '#1F2937', width: 1, dash: 'bogus' } },
548
+ { elementId: 'one_pt', elementType: 'line', points: [[10, 70]], line: { color: '#1F2937', width: 1 } },
549
+ ]
550
+ const badOut = join(WORK, 'neg.pptx')
551
+ const badReport = await exportFixture(legacyCtx, BAD_ELEMENTS, badOut)
552
+ const badS1 = decodeXml(zipRead(readFileSync(badOut)).get('ppt/slides/slide1.xml'))
553
+ const badWarn = (badReport.warnings ?? []).join(' | ')
554
+ check("导出层:arrow:'bogus' 降级为无箭头 + 进 report.warnings(不静默)",
555
+ badWarn.includes('bogus') && !/<a:headEnd/.test(badS1) && !/<a:tailEnd/.test(badS1), `warnings=${badWarn.slice(0, 240)}`)
556
+ check("导出层:dash:'bogus' 降级为 solid(不写 prstDash)+ 进 report.warnings",
557
+ badWarn.includes('dash') && !/<a:prstDash/.test(badS1), `warnings=${badWarn.slice(0, 240)}`)
558
+ check('导出层:单点 points 被显式拒绝(进 warnings)且不产出坏 XML(无 NaN/undefined/越界)',
559
+ badWarn.includes('不足 2 点') && !/NaN|undefined|Infinity/.test(badS1),
560
+ `warnings=${badWarn.slice(0, 260)}\n 产物片段=${badS1.slice(badS1.indexOf('<p:spTree>'), badS1.indexOf('<p:spTree>') + 400)}`)
561
+ check('导出层:非法/退化夹具的 parity 仍然自洽(3 条线里 1 条被显式拒收 ⇒ lines 2/2,且无 poly)',
562
+ badReport.parity?.polyLinesExp === 0 && badReport.parity?.polyLinesOut === 0 && badReport.parity?.linesExp === 2 && badReport.parity?.linesOut === 2 && badReport.parity?.linesWrong === 0,
563
+ JSON.stringify({ poly: [badReport.parity?.polyLinesExp, badReport.parity?.polyLinesOut], lines: [badReport.parity?.linesExp, badReport.parity?.linesOut], wrong: badReport.parity?.linesWrong }))
564
+
565
+ // ══ 4. 字节不变硬门槛(基线 tag 对照)═══════════════════════════════════════
566
+ h('4. 字节不变:不使用任何新字段的工程 vs 基线 v1.1.5-baseline-before-diagram')
567
+ check(`基线可取得:${baseNote}`, baseReady, baseReady ? baseDir : 'git 不可用或 tag 缺失——本条硬门槛**无法**被判为通过(不许安静跳过)')
568
+ if (baseReady) {
569
+ const newOut = join(WORK, 'legacy-new.pptx')
570
+ const oldOut = join(WORK, 'legacy-old.pptx')
571
+ const same = await compareWithBaseline(legacyCtx, newOut, oldOut, `老工程(文本/表格/形状/渐变/2 点线+arrow/图片/讲稿):`)
572
+ check('字节不变(硬门槛):老工程产物与基线导出器**逐部件逐字节一致**(仅跳过 core.xml 时间戳)',
573
+ same === true,
574
+ same === true ? '全部部件一致(媒体/讲稿 rId 编号也未被动摇)' : '存在差异——见上一条明细')
575
+ const oldS1 = decodeXml(zipRead(readFileSync(oldOut)).get('ppt/slides/slide1.xml'))
576
+ check('字节不变夹具自证:确实含 2 点线 + arrow: true(判据不是空跑)',
577
+ /<a:prstGeom prst="straightConnector1">/.test(oldS1) && /<a:tailEnd/.test(oldS1) && !/<a:headEnd/.test(oldS1) && !/<a:prstDash/.test(oldS1))
578
+ }
579
+
580
+ // ══ 5. 确定性(同一 deck 连导两次)══════════════════════════════════════════
581
+ h('5. 确定性:同一 deck 连续导出两次 ⇒ 除 core.xml 外逐字节一致')
582
+ {
583
+ const a = zipRead(readFileSync(outPptx))
584
+ const out2 = join(WORK, 'out2.pptx')
585
+ await exportFixturePages(legacyCtx, [[PRIM_ELEMENTS], [PLAIN_ELEMENTS]], out2) // 与第一次**同一个夹具**(两页)
586
+ const b = zipRead(readFileSync(out2))
587
+ const keys = new Set([...a.keys(), ...b.keys()])
588
+ const diffs = []
589
+ for (const k of keys) {
590
+ if (k === 'docProps/core.xml') continue
591
+ const x = a.get(k)
592
+ const y = b.get(k)
593
+ if (!x || !y || !x.equals(y)) diffs.push(k)
594
+ }
595
+ check('确定性:两次导出的全部部件逐字节一致(含 UID 编号、路径单位取整)', diffs.length === 0, diffs.slice(0, 6).join('、') || '全部一致')
596
+ }
597
+
598
+ // ══ 汇总 ═══════════════════════════════════════════════════════════════════
599
+ h('汇总')
600
+ console.log(`PASS ${pass} / FAIL ${fail}`)
601
+ console.log(`临时产物:${WORK}(out.pptx / shots/*.png 可人工复核)`)
602
+ rmSync(baseDir, { recursive: true, force: true }) // node_modules 下的基线临时树(用完即删)
603
+ if (fail > 0) {
604
+ console.log('结论:FAIL(有判据未通过——详见上面的 FAIL 行)')
605
+ process.exitCode = 1
606
+ } else {
607
+ console.log('结论:PASS(折线开放路径 + 两端箭头 + 虚线 + 负面对照 + 字节不变 + 确定性全部通过)')
608
+ }
609
+
610
+ // ── PNG 解码(零依赖:IHDR/PLTE + IDAT inflate + 反过滤;够用于像素命中统计)──────
611
+ // PowerPoint 的幻灯片导出常见 colorType=3(调色板)⇒ 必须支持 PLTE/tRNS,
612
+ // 否则"渲染成功但读不了像素",像素级判据会被迫放弃(那就不是证据了)。
613
+ function decodePng(buf) {
614
+ if (buf.length < 8 || buf.readUInt32BE(0) !== 0x89504e47) return null
615
+ let off = 8
616
+ let width = 0
617
+ let height = 0
618
+ let bitDepth = 0
619
+ let colorType = 0
620
+ let palette = null
621
+ let alpha = null
622
+ const idat = []
623
+ while (off + 8 <= buf.length) {
624
+ const len = buf.readUInt32BE(off)
625
+ const type = buf.toString('ascii', off + 4, off + 8)
626
+ const data = buf.subarray(off + 8, off + 8 + len)
627
+ if (type === 'IHDR') {
628
+ width = data.readUInt32BE(0)
629
+ height = data.readUInt32BE(4)
630
+ bitDepth = data[8]
631
+ colorType = data[9]
632
+ } else if (type === 'PLTE') palette = Buffer.from(data)
633
+ else if (type === 'tRNS') alpha = Buffer.from(data)
634
+ else if (type === 'IDAT') idat.push(data)
635
+ else if (type === 'IEND') break
636
+ off += 12 + len
637
+ }
638
+ if (!([1, 2, 4, 8].includes(bitDepth)) || ![0, 2, 3, 4, 6].includes(colorType)) return { width, height, colorType, bitDepth, rgb: null }
639
+ // PowerPoint 的调色板导出实测是 **bitDepth=4 / colorType=3**(12 色)⇒ 必须支持 1/2/4 位解包。
640
+ const bitsPerPixel = colorType === 3 || colorType === 0 ? bitDepth : colorType === 4 ? 16 : colorType === 6 ? 32 : 24
641
+ const bpp = Math.max(1, bitsPerPixel >> 3) // 反过滤用(字节/像素);<8 位时按 1 字节算
642
+ const stride = Math.ceil((width * bitsPerPixel) / 8)
643
+ const raw = zlib.inflateSync(Buffer.concat(idat))
644
+ const rgb = Buffer.alloc(width * height * 3)
645
+ let prev = Buffer.alloc(stride)
646
+ let p = 0
647
+ for (let y = 0; y < height; y++) {
648
+ const filter = raw[p++]
649
+ const line = Buffer.from(raw.subarray(p, p + stride))
650
+ p += stride
651
+ for (let i = 0; i < stride; i++) {
652
+ const a = i >= bpp ? line[i - bpp] : 0
653
+ const b = prev[i]
654
+ const c = i >= bpp ? prev[i - bpp] : 0
655
+ if (filter === 1) line[i] = (line[i] + a) & 0xff
656
+ else if (filter === 2) line[i] = (line[i] + b) & 0xff
657
+ else if (filter === 3) line[i] = (line[i] + ((a + b) >> 1)) & 0xff
658
+ else if (filter === 4) {
659
+ const pa = Math.abs(b - c)
660
+ const pb = Math.abs(a - c)
661
+ const pc = Math.abs(a + b - 2 * c)
662
+ line[i] = (line[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff
663
+ }
664
+ }
665
+ for (let x = 0; x < width; x++) {
666
+ const o = (y * width + x) * 3
667
+ if (colorType === 3 || colorType === 0) {
668
+ // 取第 x 个样本(1/2/4 位时按位解包;灰度 8 位时就是字节)
669
+ const sample = bitsPerPixel === 8 ? line[x] : (line[(x * bitsPerPixel) >> 3] >> (8 - bitsPerPixel - ((x * bitsPerPixel) & 7))) & ((1 << bitsPerPixel) - 1)
670
+ if (colorType === 3) {
671
+ const pi = sample * 3
672
+ rgb[o] = palette?.[pi] ?? 0
673
+ rgb[o + 1] = palette?.[pi + 1] ?? 0
674
+ rgb[o + 2] = palette?.[pi + 2] ?? 0
675
+ } else {
676
+ // 灰度:按位深归一化到 0..255(只有黑白时 1 位 ⇒ 0/255)
677
+ const v = bitsPerPixel === 8 ? sample : Math.round((sample / ((1 << bitsPerPixel) - 1)) * 255)
678
+ rgb[o] = v
679
+ rgb[o + 1] = v
680
+ rgb[o + 2] = v
681
+ }
682
+ } else if (colorType === 4) {
683
+ const v = line[x * 2]
684
+ rgb[o] = v
685
+ rgb[o + 1] = v
686
+ rgb[o + 2] = v
687
+ } else {
688
+ rgb[o] = line[x * bpp]
689
+ rgb[o + 1] = line[x * bpp + 1]
690
+ rgb[o + 2] = line[x * bpp + 2]
691
+ }
692
+ }
693
+ prev = line
694
+ }
695
+ return { width, height, colorType, bitDepth, palette, alpha, rgb }
696
+ }
697
+
698
+ /**
699
+ * 命中统计:在**页面坐标**矩形 [x1,y1,x2,y2] 内数"接近目标色"的像素。
700
+ * 为什么要按页坐标 + scale:COM 渲染宽度可能不是 1920(写死 2× 会在换分辨率时假绿/假红)。
701
+ */
702
+ function countInk(img, [x1, y1, x2, y2], target, scale, tol) {
703
+ if (!img?.rgb) return -1
704
+ const px = (v) => Math.max(0, Math.min(img.width - 1, Math.round(v * scale)))
705
+ const py = (v) => Math.max(0, Math.min(img.height - 1, Math.round(v * scale)))
706
+ let n = 0
707
+ for (let y = py(y1); y <= py(y2); y++) {
708
+ for (let x = px(x1); x <= px(x2); x++) {
709
+ const i = (y * img.width + x) * 3
710
+ if (Math.abs(img.rgb[i] - target[0]) <= tol && Math.abs(img.rgb[i + 1] - target[1]) <= tol && Math.abs(img.rgb[i + 2] - target[2]) <= tol) n++
711
+ }
712
+ }
713
+ return n
714
+ }