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.
- package/README.md +41 -18
- package/agent-presets/ppt/agent.cordis.yml +22 -15
- package/cordis.patch.yml +19 -14
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +9 -5
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +153 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
- package/examples/fx/fx-pro/preview/deck.html +1 -1
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/imported/deck.yaml +6 -6
- package/examples/smoke/imported/import-styles.json +1 -33
- package/examples/smoke/imported/loss-audit.json +99 -0
- package/examples/smoke/imported/pages/slide_03.yaml +5 -36
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
- package/examples/smoke/imported/preview-rt/deck.html +1 -1
- package/examples/smoke/imported/preview-rt/layout.json +26 -78
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -1
- package/examples/smoke/preview/deck.html +1 -1
- package/lib/capabilities.js +189 -0
- package/lib/commands.js +12 -11
- package/lib/home.js +32 -0
- package/lib/index.js +278 -112
- package/lib/office-qa.js +111 -0
- package/lib/pptd/export-pptx.js +203 -13
- package/lib/pptd/import-pptx.js +12 -0
- package/lib/pptd/loss-audit.js +363 -0
- package/lib/pptd/nativeChart.js +454 -0
- package/lib/pptd/schema.js +48 -1
- package/lib/pptd/svgCharts.js +180 -37
- package/lib/pptxPy.js +96 -11
- package/lib/preset-delivery.js +107 -9
- package/lib/preview-server.js +41 -16
- package/lib/router.js +3 -2
- package/lib/scaffold.js +1 -1
- package/lib/state.js +8 -6
- package/lib/templates.js +9 -6
- package/lib/tools.js +102 -25
- package/lib/verify.js +12 -1
- package/package.json +2 -1
- package/scripts/audit-manual-facts.mjs +4 -2
- package/scripts/e2e-1.0.mjs +2 -2
- package/scripts/fixtures/workflow-baseline.json +10 -10
- package/scripts/install.mjs +13 -11
- package/scripts/smoke.mjs +473 -66
- package/scripts/verify-loss-audit.mjs +245 -0
- package/scripts/verify-native-charts.mjs +529 -0
- package/scripts/verify-preset-scope.mjs +593 -0
- package/skills/ppt-studio-data/SKILL.md +5 -7
- package/skills/ppt-studio-manual/SKILL.md +2 -2
package/lib/pptd/export-pptx.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PPTD → PPTX 导出器(主引擎):生成最小可打开的 OOXML。
|
|
3
3
|
* - 元素:text / shape(rect,ellipse,triangle) / line(箭头) / image / table
|
|
4
|
-
* / chart
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/lib/pptd/import-pptx.js
CHANGED
|
@@ -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)'] : []),
|