dsh-ppt-studio 1.1.1 → 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 (58) hide show
  1. package/README.md +41 -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 +9 -5
  6. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +153 -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/commands.js +12 -11
  30. package/lib/home.js +32 -0
  31. package/lib/index.js +278 -112
  32. package/lib/office-qa.js +111 -0
  33. package/lib/pptd/export-pptx.js +203 -13
  34. package/lib/pptd/import-pptx.js +12 -0
  35. package/lib/pptd/loss-audit.js +363 -0
  36. package/lib/pptd/nativeChart.js +454 -0
  37. package/lib/pptd/schema.js +48 -1
  38. package/lib/pptd/svgCharts.js +180 -37
  39. package/lib/pptxPy.js +96 -11
  40. package/lib/preset-delivery.js +107 -9
  41. package/lib/preview-server.js +41 -16
  42. package/lib/router.js +3 -2
  43. package/lib/scaffold.js +1 -1
  44. package/lib/state.js +8 -6
  45. package/lib/templates.js +9 -6
  46. package/lib/tools.js +102 -25
  47. package/lib/verify.js +12 -1
  48. package/package.json +2 -1
  49. package/scripts/audit-manual-facts.mjs +4 -2
  50. package/scripts/e2e-1.0.mjs +2 -2
  51. package/scripts/fixtures/workflow-baseline.json +10 -10
  52. package/scripts/install.mjs +13 -11
  53. package/scripts/smoke.mjs +473 -66
  54. package/scripts/verify-loss-audit.mjs +245 -0
  55. package/scripts/verify-native-charts.mjs +529 -0
  56. package/scripts/verify-preset-scope.mjs +593 -0
  57. package/skills/ppt-studio-data/SKILL.md +5 -7
  58. package/skills/ppt-studio-manual/SKILL.md +2 -2
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * PPTD → PPTX 导出器(主引擎):生成最小可打开的 OOXML。
3
3
  * - 元素:text / shape(rect,ellipse,triangle) / line(箭头) / image / table
4
- * / chart(矢量拼绘:bar=矩形、line=连接线+圆点、pie=饼形)
4
+ * / chart(第三轮起**默认原生可编辑图表**:ppt/charts/chartN.xml + 内嵌数据工作簿,
5
+ * PowerPoint 里可"编辑数据"/换类型;`chart.render: 'vector'` 保留矢量拼绘降级:bar=矩形、line=连线+圆点、pie=饼形)
5
6
  * - 文本自动 fit:与 verify 同一度量与阈值(overflow > 1px 才缩),按比例缩字号;
6
7
  * 下限语义(2026-09-06 用户拍板):theme.minFontSize 显式 = 用户给出的字号下限(严格遵守,且不超过原字号);
7
8
  * 未设置 = 无强制下限(仅 60% 原字号防荒谬保底),绝不升字;到下限仍溢出记 floorHit。
@@ -12,9 +13,11 @@
12
13
  */
13
14
  import { writeFile, readFile } from 'node:fs/promises'
14
15
  import { join, isAbsolute } from 'node:path'
16
+ import { createHash } from 'node:crypto'
15
17
  import { zipWrite } from '../zips.js'
16
18
  import { normalizePage } from './layout.js'
17
- import { chartData, chartColors, chartIssue } from './svgCharts.js'
19
+ import { chartData, chartColors, chartIssue, resolveChart } from './svgCharts.js'
20
+ import { auditNativeCharts, chartFrameXml, chartPartNames, embeddedWorkbook, nativeChartRels, nativeChartXml } from './nativeChart.js'
18
21
 
19
22
  const EMU = 12700
20
23
  const xm = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
@@ -36,17 +39,50 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
36
39
  const lineProof = { exp: 0, out: 0, wrong: [] }
37
40
  UID = 1000
38
41
 
42
+ // 媒体部件命名(2026-09-26 修复"同名静默错图")——**整册共享**(不能按页各算一套,否则同一 srcPath
43
+ // 在不同页会拿到不同部件名,parity 会假红)。要防的事故:旧实现 addMedia 按**完整路径**去重(rId 不同),
44
+ // 但包内部件名与 rels Target 都取 `basename` ⇒ `media/a/logo.png` 与 `media/b/logo.png` 共用一个
45
+ // `ppt/media/logo.png`,后写覆盖先写 —— **成品里一张图被静默换成另一张**,而 parity(只数 `<p:pic>`)与
46
+ // warnings 全绿(2026-09-26 实测复现)。
47
+ // 规则:ASCII 安全且未被占用 ⇒ **原名不动**(既有产物零变化);非 ASCII/空格或重名 ⇒ `stem-<hash8>.<ext>`
48
+ // (hash 取相对路径,跨机器/跨运行确定;`ppt/media/` 是包内部件名,用户看不到)。
49
+ const usedParts = new Map() // srcPath → 部件名
50
+ const takenParts = new Set()
51
+ const renamedMedia = new Map() // srcPath → 改名后的部件名(供报告"不静默")
52
+ const mediaPartName = (srcPath) => {
53
+ if (usedParts.has(srcPath)) return usedParts.get(srcPath)
54
+ const key = String(srcPath)
55
+ const base = key.split(/[\\/]/).pop() || 'image'
56
+ const dot = base.lastIndexOf('.')
57
+ const ext = dot > 0 ? base.slice(dot) : ''
58
+ const stem = dot > 0 ? base.slice(0, dot) : base
59
+ let part = base
60
+ if (!/^[A-Za-z0-9._-]+$/.test(base) || takenParts.has(base)) {
61
+ const slug = stem.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'image'
62
+ const h = createHash('sha1').update(key).digest('hex').slice(0, 8)
63
+ part = `${slug}-${h}${ext}`
64
+ let n = 1
65
+ while (takenParts.has(part)) part = `${slug}-${h}-${n++}${ext}`
66
+ renamedMedia.set(key, part)
67
+ }
68
+ takenParts.add(part)
69
+ usedParts.set(srcPath, part)
70
+ return part
71
+ }
72
+
39
73
  for (const page of ctx.pages) {
40
74
  const els = normalizePage(page, ctx)
41
75
  const shapes = []
42
76
  const media = []
43
77
  const mediaSeen = new Map()
44
- const counts = { table: 0, image: 0, chart: 0 }
78
+ const counts = { table: 0, image: 0, chart: 0, chartNative: 0, chartVector: 0 }
79
+ // 原生图表:收集本页要产出的图表部件(rId / 部件名在"媒体发现完之后"才分配,见下方 relBase)
80
+ const chartRefs = []
45
81
  const addMedia = (srcPath) => {
46
82
  if (mediaSeen.has(srcPath)) return mediaSeen.get(srcPath)
47
83
  const rId = `rId${media.length + 2}`
48
84
  mediaSeen.set(srcPath, rId)
49
- media.push({ srcPath, rId })
85
+ media.push({ srcPath, rId, part: mediaPartName(srcPath) })
50
86
  return rId
51
87
  }
52
88
 
@@ -70,7 +106,17 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
70
106
  counts.chart++
71
107
  const issue = chartIssue(el.chart)
72
108
  if (issue) report.chartInfos.push(`第 ${page.index + 1} 页(${page.name})${el.id}: ${issue}`)
73
- shapes.push(...chartSp(el))
109
+ // 第三轮:默认原生可编辑图表;`render: 'vector'` 才走旧的矢量拼绘(形状计数自证照旧)
110
+ if (resolveChart(el.chart).opts.render === 'vector') {
111
+ counts.chartVector++
112
+ shapes.push(...chartSp(el))
113
+ } else {
114
+ counts.chartNative++
115
+ const k = chartRefs.length
116
+ chartRefs.push({ el, pageNo: page.index + 1, pageName: page.name })
117
+ // rId 先占位:媒体是在本循环里陆续发现的,图表关系必须排在媒体(与讲稿)之后
118
+ shapes.push(chartFrameXml(el, nid(), `__CHART_RID_${k}__`))
119
+ }
74
120
  break
75
121
  }
76
122
  }
@@ -83,13 +129,28 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
83
129
  bg.xml = bg.xml.replace('BGPLACEHOLDER', bg.rId)
84
130
  }
85
131
 
132
+ // ── 关系编号(媒体之后):讲稿 → 图表 ──────────────────────────────────
133
+ // 不产图表的工程:编号与旧实现**逐字节一致**(rId1=slideLayout,rId2..=media,notesRel=media+2)。
134
+ const notes = typeof page.page.notes === 'string' && page.page.notes.trim() ? page.page.notes.trim() : null
135
+ const relBase = media.length + 2
136
+ const notesRId = notes ? `rId${relBase}` : null
137
+ chartRefs.forEach((c, k) => {
138
+ c.rId = `rId${relBase + (notes ? 1 : 0) + k}`
139
+ })
140
+ for (let k = 0; k < chartRefs.length; k++) {
141
+ const idx = shapes.findIndex((s) => typeof s === 'string' && s.includes(`__CHART_RID_${k}__`))
142
+ if (idx >= 0) shapes[idx] = shapes[idx].replace(`__CHART_RID_${k}__`, chartRefs[k].rId)
143
+ }
144
+
86
145
  slides.push({
87
146
  page,
88
147
  shapes,
89
148
  media,
90
149
  counts,
150
+ chartRefs,
91
151
  // 讲稿:只有非空字符串才产备注页(空/未写 = 与旧行为完全一致,不生成部件)
92
- notes: typeof page.page.notes === 'string' && page.page.notes.trim() ? page.page.notes.trim() : null,
152
+ notes,
153
+ notesRId,
93
154
  notesNo: 0,
94
155
  spTree: `<p:spTree>${spTreeHeader()}${shapes.join('')}</p:spTree>`,
95
156
  bg,
@@ -99,8 +160,21 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
99
160
  let notesCount = 0
100
161
  for (const s of slides) if (s.notes) s.notesNo = ++notesCount
101
162
 
163
+ // ── 原生图表编号(跨页递增,chart1..chartN)与部件生成 ──────────────────────
164
+ // 每张原生图表 = 一个 chartN.xml + 一个内嵌工作簿(Microsoft_Excel_SheetN.xlsx),与 python-pptx 产物同构。
165
+ const nativeCharts = []
166
+ for (const s of slides) {
167
+ for (const c of s.chartRefs ?? []) {
168
+ const chartNo = nativeCharts.length + 1
169
+ const names = chartPartNames(chartNo)
170
+ c.chartNo = chartNo
171
+ c.names = names
172
+ nativeCharts.push({ id: c.el.id, chart: c.el.chart, pageNo: c.pageNo, pageName: c.pageName, chartNo, names })
173
+ }
174
+ }
175
+
102
176
  const files = {}
103
- files['[Content_Types].xml'] = contentTypes(slides)
177
+ files['[Content_Types].xml'] = contentTypes(slides, nativeCharts)
104
178
  files['_rels/.rels'] = rootRels()
105
179
  files['docProps/core.xml'] = coreProps(ctx)
106
180
  files['docProps/app.xml'] = appProps(ctx)
@@ -115,13 +189,19 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
115
189
  files['ppt/notesMasters/notesMaster1.xml'] = notesMasterXml()
116
190
  files['ppt/notesMasters/_rels/notesMaster1.xml.rels'] = notesMasterRels()
117
191
  }
192
+ // 图表部件三件套(chartN.xml / chartN.xml.rels / 内嵌 xlsx)
193
+ for (const ch of nativeCharts) {
194
+ files[ch.names.chart] = nativeChartXml({ chart: ch.chart, chartNo: ch.chartNo })
195
+ files[ch.names.rels] = nativeChartRels(ch.chartNo)
196
+ files[ch.names.embed] = embeddedWorkbook(ch.chart)
197
+ }
118
198
 
119
199
  for (let i = 0; i < slides.length; i++) {
120
200
  const s = slides[i]
121
201
  const n = i + 1
122
202
  files[`ppt/slides/slide${n}.xml`] = slideXml(s)
123
- // 备注页关系:rId 取媒体之后的下一个空闲号(媒体用 rId2..rId(1+media))
124
- const notesRel = s.notes ? `<Relationship Id="rId${s.media.length + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${s.notesNo}.xml"/>` : ''
203
+ // 备注页关系:rId 取媒体之后的下一个空闲号(媒体用 rId2..rId(1+media))——见页面循环里的统一分配
204
+ const notesRel = s.notesRId ? `<Relationship Id="${s.notesRId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide" Target="../notesSlides/notesSlide${s.notesNo}.xml"/>` : ''
125
205
  if (s.notes) {
126
206
  files[`ppt/notesSlides/notesSlide${s.notesNo}.xml`] = notesSlideXml(s.notes)
127
207
  // 关键:notesSlide **必须自带 rels**(→ notesMaster + 回指本页幻灯)。
@@ -136,13 +216,14 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
136
216
  '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
137
217
  '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
138
218
  '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>',
139
- ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.srcPath.split(/[\\/]/).pop()}"/>`),
219
+ ...s.media.map((m) => `<Relationship Id="${m.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/${m.part}"/>`),
140
220
  notesRel,
221
+ ...(s.chartRefs ?? []).map((c) => `<Relationship Id="${c.rId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart" Target="../charts/chart${c.chartNo}.xml"/>`),
141
222
  '</Relationships>',
142
223
  ].join('')
143
224
  files[`ppt/slides/_rels/slide${n}.xml.rels`] = srels
144
225
  for (const m of s.media) {
145
- const bn = m.srcPath.split(/[\\/]/).pop()
226
+ const bn = m.part
146
227
  try {
147
228
  files[`ppt/media/${bn}`] = await readFile(join(ctx.dir, m.srcPath))
148
229
  } catch {
@@ -171,13 +252,90 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
171
252
  const notesParts = Object.keys(files).filter((k) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(k))
172
253
  const notesOut = notesParts.length
173
254
  const notesRelOk = slides.filter((s) => s.notes).every((s) => String(files[`ppt/slides/_rels/slide${slides.indexOf(s) + 1}.xml.rels`]).includes(`notesSlide${s.notesNo}.xml`))
255
+ // 媒体 parity(2026-09-26 新增):**不同 srcPath 数 == 包内媒体部件数**。
256
+ // 为什么必须单独加一条:图数量 parity(imagesExp/imagesOut)只数 `<p:pic>`,两张不同的图塌成同一个部件时
257
+ // 它依然是 2/2、ok=true(实测)。而"两张不同的图被写成同一个部件"= 成品里静默用错图,正是本插件最不能出的错。
258
+ const mediaExpSet = new Set()
259
+ for (const s of slides) for (const m of s.media) mediaExpSet.add(m.srcPath)
260
+ const mediaOut = Object.keys(files).filter((k) => /^ppt\/media\//.test(k)).length
261
+ // 内容级回读(2026-09-26 第二轮 启发 A):**产物里到底有没有我们打算写的那句话、那格数据**。
262
+ // 为什么必须补:此前 parity 只有**计数**(表/图/线/媒体)+ audit 的页数/尺寸/字号 ⇒
263
+ // "预览层对、成品层丢字/串行/表格漏格"这一类抓不到。官方 office 技能同款纪律:
264
+ // "Reopen the file to check the requested edits." 本检查**从产物反推内容**,不是读中间层。
265
+ const textMissing = []
266
+ const chartFramesMissing = []
267
+ let textExp = 0
268
+ let chartShapesExp = 0
269
+ let chartShapesOut = 0
270
+ for (let i = 0; i < slides.length; i++) {
271
+ const slot = (ctx.pages ?? [])[i]
272
+ if (slot === undefined) continue
273
+ // `ctx.pages[i]` = `{ file, ref, page, name, index }`,元素在 `slot.page.elements`(DSL 原文:`elementId`)
274
+ const elements = slot.page?.elements ?? []
275
+ const pageNo = (slot.index ?? i) + 1
276
+ const raw = files[`ppt/slides/slide${i + 1}.xml`]
277
+ const txt = typeof raw === 'string' ? raw : String(raw ?? '')
278
+ for (const el of elements) {
279
+ const id = String(el.elementId ?? el.id ?? '?')
280
+ const want = []
281
+ if (el.elementType === 'text') {
282
+ for (const line of String(el.content?.text ?? '').split('\n')) if (line.trim() !== '') want.push(line)
283
+ } else if (el.elementType === 'table') {
284
+ for (const v of [...(el.cols ?? []), ...((el.rows ?? []).flat())]) {
285
+ if (v !== null && v !== undefined && String(v) !== '') want.push(String(v))
286
+ }
287
+ }
288
+ for (const w of want) {
289
+ textExp++
290
+ if (!txt.includes(xm(w))) textMissing.push(`第 ${pageNo} 页/${id}: ${w.slice(0, 24)}`)
291
+ }
292
+ if (el.elementType === 'chart') {
293
+ if (resolveChart(el.chart).opts.render === 'vector') {
294
+ // 矢量拼绘(形状不是图表对象)⇒ 内容级判据只能是"该画的图形个数对不对"。
295
+ chartShapesExp += chartShapeCountOf(el)
296
+ const re = new RegExp(`name="${escapeRe(xm(id))}-`, 'g')
297
+ chartShapesOut += (txt.match(re) ?? []).length
298
+ } else {
299
+ // 原生图表:判据在包级(部件数 + externalData 关系 + 内嵌工作簿数据 == deck 数据),见下面的 auditNativeCharts。
300
+ // 幻灯片级还要自证"这个图表帧真的在产物里"(防止 graphicFrame 被静默丢弃/参数写错)。
301
+ const frameOk = txt.includes(`name="${xm(id)}"`) && txt.includes('drawingml/2006/chart')
302
+ if (!frameOk) chartFramesMissing.push(`第 ${pageNo} 页/${id}: slide XML 里没有图表帧(graphicFrame + graphicData uri=.../chart)`)
303
+ }
304
+ }
305
+ }
306
+ }
307
+ // 原生图表部件自证(第三轮):声明的 chart 元素数 == ppt/charts/*.xml 数;
308
+ // 每个图表帧的 rId 真的指向 chartN.xml;chartN 的 externalData 关系真的指向存在的内嵌工作簿;
309
+ // 内嵌工作簿 `xl/worksheets/sheet1.xml` 取值 == deck 数据(逐格比对)。
310
+ const chartAudit = auditNativeCharts(files, nativeCharts.map((c) => ({ id: `${c.id}(第 ${c.pageNo} 页)`, chart: c.chart, chartPart: c.names.chart })))
311
+ const chartRelMissing = []
312
+ // 图表帧 → slide rels → chartN.xml 的链路自证(逐条从产物反查)
313
+ for (let i = 0; i < slides.length; i++) {
314
+ for (const c of slides[i].chartRefs ?? []) {
315
+ const srels = String(files[`ppt/slides/_rels/slide${i + 1}.xml.rels`] ?? '')
316
+ if (!srels.includes(`Id="${c.rId}"`) || !srels.includes(`../charts/chart${c.chartNo}.xml`)) {
317
+ chartRelMissing.push(`第 ${i + 1} 页/${c.el.id}: slide rels 缺 ${c.rId} → chart${c.chartNo}.xml`)
318
+ }
319
+ if (!String(files[`ppt/slides/slide${i + 1}.xml`]).includes(`r:id="${c.rId}"`)) {
320
+ chartRelMissing.push(`第 ${i + 1} 页/${c.el.id}: 图表帧没有引用 ${c.rId}`)
321
+ }
322
+ }
323
+ }
174
324
  report.parity = {
175
325
  tablesExp, tablesOut, imagesExp, imagesOut, illegalFrames: illegal,
176
326
  linesExp: lineProof.exp, linesOut: lineProof.out, linesWrong: lineProof.wrong.length,
177
327
  notesExp, notesOut,
328
+ mediaExp: mediaExpSet.size, mediaOut,
329
+ textExp, textMissing, chartShapesExp, chartShapesOut,
330
+ chartFramesMissing,
331
+ chartsExp: chartAudit.chartsExp, chartsOut: chartAudit.chartsOut,
332
+ chartEmbedsOut: chartAudit.embedsOut, chartSheetsChecked: chartAudit.sheetsChecked,
333
+ chartDataMismatch: chartAudit.dataMismatch, chartRelsMissing: [...chartAudit.relsMissing, ...chartRelMissing],
178
334
  ok: tablesExp === tablesOut && imagesExp === imagesOut && illegal === 0 &&
179
335
  lineProof.exp === lineProof.out && lineProof.wrong.length === 0 &&
180
- notesExp === notesOut && notesRelOk,
336
+ notesExp === notesOut && notesRelOk && mediaExpSet.size === mediaOut &&
337
+ textMissing.length === 0 && chartShapesExp === chartShapesOut &&
338
+ chartFramesMissing.length === 0 && chartAudit.ok && chartRelMissing.length === 0,
181
339
  }
182
340
 
183
341
  // out:绝对路径原样使用;相对路径相对 deck 目录(反馈 E1 ★)
@@ -186,6 +344,12 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
186
344
  report.file = outPath
187
345
  report.slides = slides.length
188
346
  report.engine = engine
347
+ // 原生图表清单(可观测,便于 smoke/验证脚本核对"第几个 chartN 是哪一页的哪个元素")
348
+ if (nativeCharts.length) {
349
+ report.nativeCharts = nativeCharts.map((c) => ({ id: c.id, page: c.pageNo, chartNo: c.chartNo, part: c.names.chart, embed: c.names.embed }))
350
+ }
351
+ // 改名必须**可见**(不静默):非 ASCII/空格或重名时部件名变了,报告里说明改成了什么。
352
+ if (renamedMedia.size) report.mediaRenamed = [...renamedMedia].map(([src, part]) => `${src} → ppt/media/${part}`)
189
353
  return report
190
354
  }
191
355
 
@@ -421,6 +585,24 @@ function tableFrame(el) {
421
585
  }
422
586
 
423
587
  // ── chart(矢量拼绘)───────────────────────────────────────────────────────
588
+ /** 正则元字符转义(元素 id 可能含 `(`、`.` 等;用于"这个图表的形状有没有真的写进产物")。 */
589
+ function escapeRe(s) {
590
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
591
+ }
592
+
593
+ /**
594
+ * 一个 chart 元素**应当**在产物里画出多少个形状(与下面的 `chartSp` 一一对应;改 chartSp 必须同步改这里)。
595
+ * bar:每个数值一根矩形;line:每系列 (点数−1) 条连接线 + 每个点一个圆点;pie:每个正数一个扇区。
596
+ */
597
+ function chartShapeCountOf(el) {
598
+ const data = chartData(el.chart)
599
+ const kind = el.chart?.type
600
+ if (kind === 'bar') return data.series.reduce((n, s) => n + s.values.length, 0)
601
+ if (kind === 'line') return data.series.reduce((n, s) => n + Math.max(0, s.values.length - 1) + s.values.length, 0)
602
+ if (kind === 'pie') return (data.series[0]?.values ?? []).filter((v) => v > 0).length
603
+ return 0
604
+ }
605
+
424
606
  function chartSp(el) {
425
607
  const x = el.bounds.x
426
608
  const y = el.bounds.y
@@ -490,7 +672,13 @@ function chartSp(el) {
490
672
  }
491
673
 
492
674
  // ── package parts ─────────────────────────────────────────────────────────
493
- function contentTypes(slides) {
675
+ /**
676
+ * `[Content_Types].xml`。
677
+ * `nativeCharts`(第三轮):每个 `ppt/charts/chartN.xml` 一条 Override(`drawingml.chart+xml`),
678
+ * 并补 `<Default Extension="xlsx">`(内嵌数据工作簿是 `ppt/embeddings/*.xlsx`)。
679
+ * 无原生图表的工程**逐字节不变**(老工程/回归样例零变化)。
680
+ */
681
+ function contentTypes(slides, nativeCharts = []) {
494
682
  const notes = slides.filter((s) => s.notes)
495
683
  const overrides = [
496
684
  '<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>',
@@ -502,6 +690,7 @@ function contentTypes(slides) {
502
690
  ...(notes.length ? ['<Override PartName="/ppt/notesMasters/notesMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'] : []),
503
691
  ...slides.map((_, i) => '<Override PartName="/ppt/slides/slide' + (i + 1) + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>'),
504
692
  ...notes.map((s) => '<Override PartName="/ppt/notesSlides/notesSlide' + s.notesNo + '.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>'),
693
+ ...nativeCharts.map((c) => '<Override PartName="/' + c.names.chart + '" ContentType="application/vnd.openxmlformats-officedocument.drawingml.chart+xml"/>'),
505
694
  ]
506
695
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
507
696
  + '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
@@ -509,6 +698,7 @@ function contentTypes(slides) {
509
698
  + '<Default Extension="png" ContentType="image/png"/><Default Extension="jpeg" ContentType="image/jpeg"/>'
510
699
  + '<Default Extension="jpg" ContentType="image/jpeg"/><Default Extension="gif" ContentType="image/gif"/>'
511
700
  + '<Default Extension="webp" ContentType="image/webp"/>'
701
+ + (nativeCharts.length ? '<Default Extension="xlsx" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/>' : '')
512
702
  + overrides.join('') + '</Types>'
513
703
  }
514
704
 
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs'
9
9
  import { join, basename } from 'node:path'
10
10
  import { zipRead, decodeXml } from '../zips.js'
11
11
  import { parseXml, children, first, allText } from '../xmljs.js'
12
+ import { auditLoss, lossAuditLine, LOSS_AUDIT_FILE } from './loss-audit.js'
12
13
 
13
14
  const EMU = 12700
14
15
  const px = (emu) => Math.round(emu / EMU)
@@ -152,11 +153,22 @@ export async function importPptx(pptxPath, outDir) {
152
153
  elements: pages.flatMap((p) => p.elements.map((el) => ({ page: p.index + 1, id: el.elementId, ...(el._styleRaw ?? {}) }))),
153
154
  }
154
155
  await writeFile(join(outDir, 'import-styles.json'), JSON.stringify(stylesJson, null, 2))
156
+ // 启发 B(审计半):重建有损审计——源稿里 DSL/导出无法保真的特性(动画/阴影/超链接/切换/
157
+ // SmartArt/不可嵌入媒体/主题渐变)。单独成文 loss-audit.json(理由见下方 return 注释),
158
+ // 工具输出用 lossAuditLine 给一行结论,支撑"只改某几页 ⇒ 优先 ppt_splice"的选择。
159
+ const lossAudit = auditLoss(files, { source: basename(pptxPath) })
160
+ await writeFile(join(outDir, LOSS_AUDIT_FILE), JSON.stringify(lossAudit, null, 2))
155
161
  const bgCount = pages.filter((p) => p.background).length
156
162
  const styleCount = Object.keys(stats.colors).length
157
163
  return {
158
164
  outDir, pages: pages.length, media: [...mediaNames], size: { width, height },
159
165
  reference: refPreviews?.length ? { previews: refPreviews, source: 'source.pptx' } : null,
166
+ // 启发 B:损失审计报告 + 一行结论(tools.js 的 ppt_import 只加这一行输出)。
167
+ // JSON 独立成 loss-audit.json(而非塞进 import-styles.json):① 语义相反——styles 记"我们保住了什么",
168
+ // audit 记"我们保不住什么",混在一起会让"改版求一致直接复用 styles"的既有使用者读到无关字段;
169
+ // ② 生命周期不同:audit 是决策输入(改哪几页 / 要不要 splice),styles 是素材账本;
170
+ // ③ 独立文件名便于模型/用户直接定位与断言(smoke 无需解析既有 schema,向后兼容、零破坏)。
171
+ lossAudit, lossLine: lossAuditLine(lossAudit),
160
172
  warnings: ['chart 降级为文本占位;未映射 prst 形状近似为矩形(import-styles.json 有记录)',
161
173
  ...(missingMedia.length ? [`⚠ 原稿 ${missingMedia.length} 个媒体引用未提取(${missingMedia.join('、')}——rels 指向包外/特殊格式):已在 media/ 生成 1×1 白色占位图,导出可用;建议人工替换或删除对应页面元素`] : []),
162
174
  ...(existsSync(join(outDir, 'source.pptx')) ? ['已保留原始 pptx ⊳ source.pptx(零失真真相层;参考双轨 v0.9.1)'] : []),