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,369 @@
1
+ /**
2
+ * 手术模式(v0.10.0,候选 B):以模板 .pptx 为底版做"结构精确贴模板"——
3
+ * 单页 OOXML 文本/表格槽替换(只改 <a:t> 内容,rPr/几何/渐变/字体全部原样保留),
4
+ * 其余页**内容逐字节不变**(解包 sha256 验证),最后重打包。
5
+ *
6
+ * 与双轨的关系:双轨 = 风格复刻 + 骨架继承(快、稳、像);手术 = 结构精确贴模板(贴、重、准)。
7
+ * 与 ppt_export 的关系:两种成品路径,互不影响——常规导出走 PPTD 渲染;贴模板保真走手术。
8
+ *
9
+ * 实现要点:xmljs 是只读解析器,手术替换必须**字符串切片**;扫描时把每个 <a:t> 的
10
+ * 绝对位置(s/e)记下,替换 = 全局自后向前切片(只动 a:t 区间字节)——
11
+ * rPr/xfrm/渐变/几何因此全部原样保留,这就是"贴模板"的保真根源。
12
+ *
13
+ * 边界(v1 诚实声明):
14
+ * - 只替换 spTree 顶层的文本 sp 与表格 graphicFrame;grpSp 组合内文本、图片、形状几何不动(装饰原子)。
15
+ * - 无 xfrm 的槽(layout 占位符:页码/日期占位)不参与匹配,保留模板原文。
16
+ * - 表格按 cell 序(row-major)替换 min(用户, 模板) 行;模板多余 cell 保留原文。
17
+ * - 新增页(deck 页数 > 模板页数)v1 不注入:报告警告,请用常规导出;rels 增量/资源注入留 v1.1。
18
+ */
19
+ import { readFile, writeFile } from 'node:fs/promises'
20
+ import { createHash } from 'node:crypto'
21
+ import { join, basename } from 'node:path'
22
+ import { zipRead, zipWrite, decodeXml } from './zips.js'
23
+ import { parseXml, first, children, allText } from './xmljs.js'
24
+
25
+ const EMU = 12700
26
+ const px = (emu) => Math.round(Number(emu) / EMU)
27
+
28
+ /**
29
+ * 扫描 slide XML:spTree 顶层(跳过 grpSp 区间)的文本 sp 槽与表格 frame 槽。
30
+ * 槽的 spans 用**绝对 XML 位置**(s/e),便于直接切片。
31
+ * @returns { slots, tables } slots: [{kind:'sp', name, x,y,w,h, text, spans:[{s,e,text}]}]
32
+ * tables: [{kind:'table', name, x,y,w,h, rows:[{cells:[{spans,text,skip}]}]}]
33
+ */
34
+ export function scanSlideXml(xml) {
35
+ const slots = []
36
+ const tables = []
37
+ const start = xml.indexOf('<p:spTree>')
38
+ const end = xml.indexOf('</p:spTree>')
39
+ if (start < 0 || end < 0) return { slots, tables }
40
+ // grpSp 排除区间(组合 = 装饰原子)
41
+ const excluded = []
42
+ let i = start
43
+ while (i < end) {
44
+ const gs = xml.indexOf('<p:grpSp>', i)
45
+ if (gs < 0 || gs > end) break
46
+ const ge = xml.indexOf('</p:grpSp>', gs)
47
+ if (ge < 0 || ge > end) break
48
+ excluded.push([gs, ge + '</p:grpSp>'.length])
49
+ i = ge + '</p:grpSp>'.length
50
+ }
51
+ const inside = (pos) => excluded.some(([a, b]) => pos >= a && pos < b)
52
+ i = start
53
+ while (i < end) {
54
+ const sp = xml.indexOf('<p:sp>', i)
55
+ const fr = xml.indexOf('<p:graphicFrame>', i)
56
+ let pos
57
+ let closeTag
58
+ if (sp >= 0 && sp < end && (fr < 0 || fr > end || sp < fr)) { pos = sp; closeTag = '</p:sp>' }
59
+ else if (fr >= 0 && fr < end) { pos = fr; closeTag = '</p:graphicFrame>' }
60
+ else break
61
+ if (inside(pos)) { i = pos + 1; continue }
62
+ const ce = xml.indexOf(closeTag, pos)
63
+ if (ce < 0 || ce > end) break
64
+ const seg = xml.slice(pos, ce + closeTag.length)
65
+ const spot = closeTag === '</p:sp>' ? parseSpSpot(seg, pos) : parseFrameSpot(seg, pos)
66
+ if (spot) (closeTag === '</p:sp>' ? slots : tables).push(spot)
67
+ i = ce + closeTag.length
68
+ }
69
+ return { slots, tables }
70
+ }
71
+
72
+ function parseSpSpot(seg, base) {
73
+ const name = /<p:cNvPr[^>]*name="([^"]*)"/.exec(seg)?.[1] ?? '(no name)'
74
+ const off = /<a:off x="(-?\d+)" y="(-?\d+)"\s*\/>/.exec(seg)
75
+ const ext = /<a:ext cx="(\d+)" cy="(\d+)"\s*\/>/.exec(seg)
76
+ const spans = spansOf(seg, base)
77
+ if (!spans.length || !off || !ext) return null // 无文本/无位置(layout 页码占位等):不手术
78
+ const text = spans.map((s) => s.text).join('')
79
+ // 非文本框(AutoShape/椭圆等)的纯序号文本("1"/"2"/"3")= 装饰槽,排除(deck 无对应内容)
80
+ const isTextBox = /txBox="1"/.test(seg)
81
+ if (!isTextBox && /^\d{1,3}$/.test(text)) return null
82
+ return {
83
+ kind: 'sp', name,
84
+ x: px(off[1]), y: px(off[2]), w: Math.max(1, px(ext[1])), h: Math.max(1, px(ext[2])),
85
+ spans, text,
86
+ }
87
+ }
88
+
89
+ function parseFrameSpot(seg, base) {
90
+ const off = /<a:off x="(-?\d+)" y="(-?\d+)"\s*\/>/.exec(seg)
91
+ const ext = /<a:ext cx="(\d+)" cy="(\d+)"\s*\/>/.exec(seg)
92
+ if (!off || !ext) return null
93
+ const name = /<p:cNvPr[^>]*name="([^"]*)"/.exec(seg)?.[1] ?? '(table)'
94
+ const rows = []
95
+ let i = 0
96
+ while (i < seg.length) {
97
+ const tr = seg.indexOf('<a:tr ', i)
98
+ if (tr < 0) break
99
+ const te = seg.indexOf('</a:tr>', tr)
100
+ if (te < 0) break
101
+ const rowSeg = seg.slice(tr, te)
102
+ const cells = []
103
+ let j = 0
104
+ while (j < rowSeg.length) {
105
+ const tc = rowSeg.indexOf('<a:tc>', j)
106
+ if (tc < 0) break
107
+ const tce = rowSeg.indexOf('</a:tc>', tc)
108
+ if (tce < 0) break
109
+ const cellSeg = rowSeg.slice(tc + '<a:tc>'.length, tce)
110
+ const gridSpan = /gridSpan="(\d+)"/.exec(cellSeg)
111
+ const spans = spansOf(cellSeg, base + tr + tc + '<a:tc>'.length)
112
+ cells.push({
113
+ spans,
114
+ text: spans.map((s) => s.text).join(''),
115
+ skip: !!gridSpan || !spans.length,
116
+ })
117
+ j = tce + '</a:tc>'.length
118
+ }
119
+ rows.push({ cells })
120
+ i = te + '</a:tr>'.length
121
+ }
122
+ if (!rows.length) return null
123
+ return { kind: 'table', name, x: px(off[1]), y: px(off[2]), w: Math.max(1, px(ext[1])), h: Math.max(1, px(ext[2])), rows }
124
+ }
125
+
126
+ /** 段内所有 <a:t>(含 <a:t/>)的绝对位置与文本(base = 段在 XML 全串的起点)。 */
127
+ function spansOf(seg, base) {
128
+ const spans = []
129
+ const re = /<a:t\s[^>]*>|<a:t\/>|<a:t>/g
130
+ let m
131
+ while ((m = re.exec(seg))) {
132
+ const sPos = base + m.index
133
+ const head = m[0]
134
+ if (head.endsWith('/>')) {
135
+ spans.push({ s: sPos, e: sPos + head.length, text: '' })
136
+ } else {
137
+ const close = seg.indexOf('</a:t>', m.index + head.length)
138
+ if (close < 0) continue
139
+ spans.push({ s: sPos, e: sPos + (close + '</a:t>'.length - m.index), text: seg.slice(m.index + head.length, close) })
140
+ }
141
+ }
142
+ return spans
143
+ }
144
+
145
+ // ── 位置匹配(贪心唯一配对:中心距离升序)─────────────────────────────────
146
+
147
+ function center(b) {
148
+ // bounds 兼容 {x,y,w,h} 与 [x,y,w,h](resolveDeck 页面元素两种形态)
149
+ const x = Array.isArray(b) ? b[0] : b.x
150
+ const y = Array.isArray(b) ? b[1] : b.y
151
+ const w = Array.isArray(b) ? b[2] : b.w
152
+ const h = Array.isArray(b) ? b[3] : b.h
153
+ return { cx: x + w / 2, cy: y + h / 2 }
154
+ }
155
+ function dist(a, b) { return Math.hypot(a.cx - b.cx, a.cy - b.cy) }
156
+
157
+ /** 槽(模板侧)↔ 用户侧元素(内容真相)的贪心唯一配对。 */
158
+ export function matchByDistance(slots, items) {
159
+ const pairs = []
160
+ const usedSlot = new Set()
161
+ const usedItem = new Set()
162
+ const all = []
163
+ for (const s of slots) {
164
+ const cs = center(s)
165
+ if (!Number.isFinite(cs.cx) || !Number.isFinite(cs.cy)) continue
166
+ for (const it of items) {
167
+ const ci = center(it.bounds)
168
+ if (!Number.isFinite(ci.cx) || !Number.isFinite(ci.cy)) continue
169
+ all.push({ s, it, d: dist(cs, ci) })
170
+ }
171
+ }
172
+ all.sort((a, b) => a.d - b.d)
173
+ for (const { s, it, d } of all) {
174
+ if (usedSlot.has(s) || usedItem.has(it)) continue
175
+ usedSlot.add(s)
176
+ usedItem.add(it)
177
+ pairs.push({ slot: s, item: it, distance: Math.round(d) })
178
+ }
179
+ return pairs
180
+ }
181
+
182
+ // ── 替换(全局逆序切片;只动 a:t 区间)──────────────────────────────────
183
+
184
+ export function escapeXml(t) {
185
+ return String(t).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
186
+ }
187
+
188
+ /** 归一化文本比较:导入/多 run 拼接的空白差异不算"改动"(视觉等价 → 保持模板字节)。 */
189
+ export const normText = (s) => String(s ?? '').replace(/\s+/g, '').trim()
190
+
191
+ /**
192
+ * 单页手术。
193
+ * @param xml 原 slide XML(decodeXml 后字符串)
194
+ * @param textPairs matchByDistance(scan.slots, deckTextItems) 结果
195
+ * @param tablePairs matchByDistance(scan.tables, deckTableItems) 结果
196
+ * @returns { xml, fields, cleared, tableCells, warnings }
197
+ */
198
+ export function patchSlideXml(xml, textPairs, tablePairs) {
199
+ let fields = 0
200
+ let cleared = 0
201
+ let tableCells = 0
202
+ const warnings = []
203
+ const ops = [] // {s, e, replacement}(绝对位置)
204
+ for (const { slot, item } of textPairs) {
205
+ const newText = item.content?.text ?? ''
206
+ if (normText(slot.text) === normText(newText)) continue // 未改(含导入规范化差异)→ 保持模板字节
207
+ if (!newText) cleared++
208
+ fields++
209
+ const esc = escapeXml(newText)
210
+ slot.spans.forEach((sp, k) => ops.push({ s: sp.s, e: sp.e, replacement: k === 0 ? `<a:t>${esc}</a:t>` : '<a:t></a:t>' }))
211
+ }
212
+ for (const { slot, item } of tablePairs) {
213
+ const userRows = item.rows ?? []
214
+ const tRows = slot.rows
215
+ const n = Math.min(userRows.length, tRows.length)
216
+ if (userRows.length > tRows.length) warnings.push(`表格 "${slot.name}" 收缩:用户 ${userRows.length} 行 > 模板 ${tRows.length} 行,多余行未注入(v1 限制)`)
217
+ for (let r = 0; r < n; r++) {
218
+ const userRow = userRows[r] ?? []
219
+ for (let c = 0; c < userRow.length && c < tRows[r].cells.length; c++) {
220
+ const cell = tRows[r].cells[c]
221
+ if (cell.skip) continue // 合并 cell/空 cell:不碰
222
+ const newText = String(userRow[c] ?? '')
223
+ if (normText(cell.text) === normText(newText)) continue
224
+ tableCells++
225
+ const esc = escapeXml(newText)
226
+ cell.spans.forEach((sp, k) => ops.push({ s: sp.s, e: sp.e, replacement: k === 0 ? `<a:t>${esc}</a:t>` : '<a:t></a:t>' }))
227
+ }
228
+ }
229
+ }
230
+ if (!ops.length) return { xml, fields: 0, cleared, tableCells, warnings }
231
+ ops.sort((a, b) => b.s - a.s) // 逆序:位置不漂移
232
+ let out = xml
233
+ for (const op of ops) out = out.slice(0, op.s) + op.replacement + out.slice(op.e)
234
+ return { xml: out, fields, cleared, tableCells, warnings }
235
+ }
236
+
237
+ // ── zip 层:模板条目保留(内容哈希验证)─────────────────────────────────
238
+
239
+ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex')
240
+
241
+ /** 模板展示顺序:presentation.xml sldIdLst → rels → ppt/slides/slideN.xml。 */
242
+ export function slideOrderOf(zip) {
243
+ const pres = zip.get('ppt/presentation.xml')
244
+ if (!pres) return []
245
+ const doc = parseXml(decodeXml(pres))
246
+ const sldIdLst = first(first(doc, 'presentation'), 'sldIdLst')
247
+ const relsXml = zip.get('ppt/_rels/presentation.xml.rels')
248
+ const relMap = new Map()
249
+ if (relsXml) {
250
+ const rd = parseXml(decodeXml(relsXml))
251
+ for (const rel of first(rd, 'Relationships')?.children ?? []) {
252
+ if (rel.tag === 'Relationship' && rel.attrs?.Id && rel.attrs?.Target) {
253
+ relMap.set(rel.attrs.Id, rel.attrs.Target.startsWith('/') ? rel.attrs.Target.slice(1) : 'ppt/' + rel.attrs.Target.replace(/^\.\.\//g, ''))
254
+ }
255
+ }
256
+ }
257
+ const out = []
258
+ for (const sld of sldIdLst?.children ?? []) {
259
+ if (sld.tag !== 'sldId') continue
260
+ const target = relMap.get(sld.attrs['r:id'] ?? sld.attrs.id)
261
+ if (target && (zip.has(target) || zip.has(target.replace(/^ppt\//, '')))) {
262
+ const key = target.startsWith('ppt/') ? target : 'ppt/' + target.replace(/^ppt\//, '')
263
+ out.push(key.startsWith('ppt/') ? key : 'ppt/' + key)
264
+ }
265
+ }
266
+ // 兜底:rels 未命中时(懒模板)按文件名序
267
+ if (!out.length) {
268
+ const keys = [...zip.keys()].filter((k) => /^ppt\/slides\/slide\d+\.xml$/.test(k)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
269
+ return keys
270
+ }
271
+ return out
272
+ }
273
+
274
+ /**
275
+ * 手术主流程:模板真身 ← 用户内容(deck 页文本/表格)→ 成品 pptx。
276
+ * @param {template} 模板 pptx 绝对路径;@param deckDir 工作区(deck.yaml);@param out 输出路径
277
+ * @param map 可选 {1: 2}(模板页号(1-based) → deck 页号(1-based));**缺省时采纳 deck.yaml 的 `surgicalMap`**,
278
+ * 仍缺省则序号对齐。参数优先于 deck 字段。
279
+ * @returns { entries, patchedSlides, report }
280
+ */
281
+ export async function surgicalPatch({ template, deckDir, out, map = {} }) {
282
+ const { resolveDeck } = await import('./pptd/schema.js')
283
+ const tplBuf = await readFile(template)
284
+ const tplZip = zipRead(tplBuf)
285
+ const ctx = await resolveDeck(deckDir)
286
+ // 【2026-09-18 修】缺省时采纳 deck.yaml 的 `surgicalMap`。该字段此前**只有 schema 校验、全项目无消费者**
287
+ // ⇒ 用户写在 deck.yaml 里以为生效,实际**静默无效**("契约存在但不被消费",最难发现的一类缺陷)。
288
+ const effMap = (map && typeof map === 'object' && Object.keys(map).length > 0) ? map : (ctx.deck?.surgicalMap ?? {})
289
+ const order = slideOrderOf(tplZip)
290
+ const entries = new Map([...tplZip]) // 保序(central dir 顺序)
291
+ const pages = []
292
+ const patched = new Set()
293
+ let fields = 0
294
+ let tableCells = 0
295
+ const warnings = []
296
+ const extraDeckPages = []
297
+ for (let ti = 0; ti < order.length; ti++) {
298
+ const xmlPath = order[ti]
299
+ const di = effMap[ti + 1] !== undefined ? Number(effMap[ti + 1]) - 1 : (ti < ctx.pages.length ? ti : -1)
300
+ const deckPage = di >= 0 ? ctx.pages[di] : undefined
301
+ if (!deckPage) {
302
+ pages.push({ index: ti + 1, slide: xmlPath, action: 'kept', note: '无对应 deck 页(模板原样)' })
303
+ continue
304
+ }
305
+ const xml = decodeXml(tplZip.get(xmlPath))
306
+ const scan = scanSlideXml(xml)
307
+ // deck 页内的文本/表格元素(bounds px、content)
308
+ const textItems = []
309
+ const tableItems = []
310
+ for (const el of deckPage.page.elements ?? []) {
311
+ if (!el.bounds) continue
312
+ // 页码/编号占位元素:导入兜底 bounds(0,0)会抢左上角槽——排除(模板页码由 layout 渲染,槽侧也无位置)
313
+ if (el.elementType === 'text' && /灯片编号|slideNumber|页码/i.test(String(el.elementId ?? ''))) continue
314
+ if (el.elementType === 'text') textItems.push({ id: el.elementId, bounds: el.bounds, content: el.content ?? {} })
315
+ if (el.elementType === 'table') tableItems.push({ id: el.elementId, bounds: el.bounds, rows: el.rows ?? [], cols: el.cols ?? [] })
316
+ }
317
+ const textPairs = matchByDistance(scan.slots, textItems)
318
+ const tablePairs = matchByDistance(scan.tables, tableItems)
319
+ const res = patchSlideXml(xml, textPairs, tablePairs)
320
+ if (res.fields || res.tableCells) {
321
+ patched.add(xmlPath)
322
+ entries.set(xmlPath, Buffer.from(res.xml, 'utf8'))
323
+ fields += res.fields
324
+ tableCells += res.tableCells
325
+ warnings.push(...res.warnings)
326
+ pages.push({ index: ti + 1, slide: xmlPath, action: 'patched', fields: res.fields, cleared: res.cleared, tableCells: res.tableCells })
327
+ } else {
328
+ pages.push({ index: ti + 1, slide: xmlPath, action: 'kept', note: '内容与模板一致(无字段变更)' })
329
+ }
330
+ }
331
+ // deck 页数 > 模板页数:v1 不注入(诚实报告)
332
+ for (let di = ctx.pages.length - 1; di >= order.length; di--) extraDeckPages.push(di + 1)
333
+ if (extraDeckPages.length) {
334
+ warnings.push(`deck 有 ${extraDeckPages.length} 页(第 ${extraDeckPages.join('、')} 页)超过模板页数——v1 手术只处理模板已有页;新增页请用常规 ppt_export(成品为两段混合)`)
335
+ }
336
+ // 重打包(条目保序)
337
+ const outBuf = zipWrite(Object.fromEntries(entries))
338
+ // 验证:未手术条目内容 sha256 与模板一致
339
+ const ver = verifySurgical(outBuf, tplZip, patched)
340
+ await writeFile(out, outBuf)
341
+ return {
342
+ out,
343
+ pages,
344
+ patched: patched.size,
345
+ fields,
346
+ tableCells,
347
+ warnings,
348
+ verify: ver,
349
+ deckPages: ctx.pages.length,
350
+ templateSlides: order.length,
351
+ }
352
+ }
353
+
354
+ /** 手术成品验证:除被手术页外,输出条目解包内容与模板逐字节一致(sha256)。 */
355
+ export function verifySurgical(outBuf, tplZip, patchedKeys) {
356
+ let total = 0
357
+ let identical = 0
358
+ let mismatched = 0
359
+ const details = []
360
+ const outZip = zipRead(outBuf)
361
+ for (const [name, content] of tplZip) {
362
+ if (patchedKeys.has(name)) continue
363
+ total++
364
+ const outContent = outZip.get(name)
365
+ if (outContent && sha256(outContent) === sha256(content)) identical++
366
+ else { mismatched++; details.push(name) }
367
+ }
368
+ return { total, identical, mismatched, details }
369
+ }