dsh-ppt-studio 1.1.2 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -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 +5 -5
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +105 -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/home.js +32 -0
- package/lib/index.js +278 -112
- package/lib/office-qa.js +111 -0
- package/lib/pptd/export-pptx.js +159 -10
- 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 +7 -5
- package/lib/router.js +3 -2
- package/lib/state.js +8 -6
- package/lib/templates.js +4 -4
- package/lib/tools.js +81 -21
- 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 +390 -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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导入损失审计(第二轮 启发 B 审计半,2026-09-26):
|
|
3
|
+
* `ppt_import` 时扫源 .pptx 的 OOXML,统计**我们的 DSL 中间层/导出无法保真**的特性,
|
|
4
|
+
* 并给出**页码 / 元素级证据**。用途:任务若是"只改某几页 / 改一处文字",
|
|
5
|
+
* 用户与模型据此**优先就地手术**(ppt_splice:只替换目标页、其余页逐字节不变),
|
|
6
|
+
* 而不是整册重渲把原稿的动画/阴影/超链接等丢掉。
|
|
7
|
+
*
|
|
8
|
+
* 官方 office 技能同款纪律:"Rebuilding slides can discard unsupported animation,
|
|
9
|
+
* SmartArt, or other extension content." —— 所以"重建有损"必须可见。
|
|
10
|
+
*
|
|
11
|
+
* ── 判据(写死在代码里,验证脚本逐条断言;改判据必须同步改 verify) ──────────────
|
|
12
|
+
* | 类别 | 命中判据 |
|
|
13
|
+
* |------------|--------------------------------------------------------------------------|
|
|
14
|
+
* | animation | slide/layout/master/notes 里 `<p:timing>` 且其下确有 ≥1 个动画效果节点 |
|
|
15
|
+
* | | (anim/animClr/animEffect/animMotion/animRot/animScale/animBg/set/cmd/ |
|
|
16
|
+
* | | audio/video)。**空 `<p:timing><p:tnLst/></p:timing>` 不计**(无效果)。|
|
|
17
|
+
* | effect | 元素级 `a:effectLst`/`a:effectDag` **且含 ≥1 个效果子节点**(outerShdw/ |
|
|
18
|
+
* | | innerShdw/prstShdw/glow/reflection/softEdge/blur/fillOverlay)计 1 处;|
|
|
19
|
+
* | | 不在 effectLst 内的裸效果节点各计 1 处。**空 `<a:effectLst/>` 不计** |
|
|
20
|
+
* | | (PowerPoint/我们自己的导出都大量写空 effectLst 表"无效果"); |
|
|
21
|
+
* | | **主题 `a:effectStyleLst` 里的效果模板不计**(那是主题装饰,非页面元素)。 |
|
|
22
|
+
* | hyperlink | 元素级 `a:hlinkClick` / `a:hlinkHover` 各计 1 处 |
|
|
23
|
+
* | transition | slide/layout/master 里 `p:transition`(含 mc:AlternateContent 里的 morph)|
|
|
24
|
+
* | smartart | slide/layout/master 里 `graphicData@uri` 含 `/diagram` 各计 1 处; |
|
|
25
|
+
* | | 无任何引用但包内有 `ppt/diagrams/*` 部件 → 合计 1 处(避免 4 部件放大) |
|
|
26
|
+
* | media | `ppt/media/*` 扩展名 ∉ {png,jpeg,jpg,gif,webp}(导出仅内嵌这 5 种)各 1 处;|
|
|
27
|
+
* | | `ppt/embeddings/*`(OLE 对象)各 1 处;幻灯片内 `videoFile`/`audioFile`/ |
|
|
28
|
+
* | | `p14:media`(带 r:link|r:embed)、`graphicData@uri` 含 `/ole` 各 1 处 |
|
|
29
|
+
* | gradient | 非主题部件里 `<p:bg>` 祖先下的 `a:gradFill`(页面背景渐变被丢)各 1 处,`visible: true`;|
|
|
30
|
+
* | | 主题部件里所有 `a:gradFill` 各 1 处,其中**被 `p:bgRef@idx`/`a:fillRef@idx` 按序号 |
|
|
31
|
+
* | | 引用到**的 `visible: true`(bgRef 1001=N → bgFillStyleLst 第 N 项;fillRef N → |
|
|
32
|
+
* | | fillStyleLst 第 N 项),未被引用的样式表模板 `visible: false`(不影响页面观感, |
|
|
33
|
+
* | | 只在 `lossAuditLine` 里降级为 ℹ 提示,不谎报"会丢可见效果")。 |
|
|
34
|
+
* | | **形状渐变(spPr/gradFill)不计**——v0.9.1 起形状渐变直通 fill.gradient, |
|
|
35
|
+
* | | 计它属于误报。 |
|
|
36
|
+
*
|
|
37
|
+
* 报告 JSON 写进工程的 `loss-audit.json`(见 importPptx 里的选择理由),
|
|
38
|
+
* 工具输出用 `lossAuditLine(report)` 给一行结论(干净源稿只给一行"未发现",不制造噪声)。
|
|
39
|
+
*
|
|
40
|
+
* 纯函数:`auditLoss(parts)` 只吃 OOXML 部件表(Map / 普通对象 / [name, data] 数组),
|
|
41
|
+
* 不碰文件系统;`auditLossFromBuffer(buf)` 只是加一层 zipRead。便于单测。
|
|
42
|
+
*/
|
|
43
|
+
import { parseXml } from '../xmljs.js'
|
|
44
|
+
import { zipRead, decodeXml } from '../zips.js'
|
|
45
|
+
|
|
46
|
+
/** 报告文件名(导入器写进工程目录)。 */
|
|
47
|
+
export const LOSS_AUDIT_FILE = 'loss-audit.json'
|
|
48
|
+
|
|
49
|
+
const MAX_EVIDENCE = 50 // 每类证据上限(超出记 evidenceTruncated;计数仍是全量)
|
|
50
|
+
|
|
51
|
+
/** 类别定义(顺序 = 报告里的顺序,也是 lossAuditLine 里的顺序)。 */
|
|
52
|
+
const CATEGORY_DEFS = [
|
|
53
|
+
{ id: 'animation', label: '动画', loss: 'DSL 中间层没有动画通道:整册重渲后动画全丢' },
|
|
54
|
+
{ id: 'effect', label: '阴影/发光', loss: 'DSL 没有 effectLst 通道(import-styles.json 只留 rawShadow 标志):整册重渲后阴影/发光消失' },
|
|
55
|
+
{ id: 'hyperlink', label: '超链接', loss: 'DSL 没有链接通道:整册重渲后链接退化为纯文本' },
|
|
56
|
+
{ id: 'transition', label: '页切换', loss: 'DSL 没有 transition 通道:整册重渲后切换效果丢' },
|
|
57
|
+
{ id: 'smartart', label: 'SmartArt', loss: 'SmartArt 图形框架被当作图表降级为文本占位' },
|
|
58
|
+
{ id: 'media', label: '不可嵌入媒体', loss: '导出仅内嵌 png/jpeg/jpg/gif/webp:EMF/WMF 等矢量图、音视频、OLE 嵌入对象不会被带入成品' },
|
|
59
|
+
{ id: 'gradient', label: '主题/背景渐变', loss: '主题渐变与页面背景渐变被归一为纯色(形状渐变有直通,不计)' },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** 动画效果节点(timing 下有 ≥1 个才算"有动画")。 */
|
|
63
|
+
const ANIM_NODES = new Set(['anim', 'animClr', 'animEffect', 'animMotion', 'animRot', 'animScale', 'animBg', 'set', 'cmd', 'audio', 'video'])
|
|
64
|
+
/** 效果容器(元素级)。 */
|
|
65
|
+
const EFFECT_LIST_TAGS = new Set(['effectLst', 'effectDag'])
|
|
66
|
+
/** 效果节点(容器内子节点 / 裸节点)。 */
|
|
67
|
+
const EFFECT_TAGS = new Set(['outerShdw', 'innerShdw', 'prstShdw', 'glow', 'reflection', 'softEdge', 'blur', 'fillOverlay'])
|
|
68
|
+
/** 导出内容类型表里支持内嵌的图片扩展名(export-pptx.js 的 Default Extension 集合)。 */
|
|
69
|
+
const EMBEDDABLE_EXT = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp'])
|
|
70
|
+
/** 音视频扩展名(我们完全不支持)。 */
|
|
71
|
+
const AV_EXT = new Set(['mp4', 'm4v', 'mov', 'avi', 'wmv', 'mpg', 'mpeg', 'mkv', 'webm', 'mp3', 'wav', 'm4a', 'wma', 'mid', 'midi', 'aiff', 'aac', 'ogg'])
|
|
72
|
+
/** 矢量/位图扩展名里我们不能内嵌的(EMF/WMF 是官方点名的两类)。 */
|
|
73
|
+
const VECTOR_EXT = new Set(['emf', 'wmf', 'svg', 'tif', 'tiff', 'bmp', 'ico', 'eps'])
|
|
74
|
+
/** 会被遍历(元素级判据)的部件类型。 */
|
|
75
|
+
const WALK_KINDS = new Set(['slide', 'layout', 'master', 'notes', 'notesMaster'])
|
|
76
|
+
|
|
77
|
+
const lower = (s) => String(s).toLowerCase()
|
|
78
|
+
const extOf = (name) => {
|
|
79
|
+
const b = name.slice(name.lastIndexOf('/') + 1)
|
|
80
|
+
const i = b.lastIndexOf('.')
|
|
81
|
+
return i < 0 ? '' : lower(b.slice(i + 1))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 部件类型(决定用哪套判据)。 */
|
|
85
|
+
function partKind(name) {
|
|
86
|
+
if (/^ppt\/slides\/slide\d+\.xml$/i.test(name)) return 'slide'
|
|
87
|
+
if (/^ppt\/slideLayouts\/slideLayout\d+\.xml$/i.test(name)) return 'layout'
|
|
88
|
+
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/i.test(name)) return 'master'
|
|
89
|
+
if (/^ppt\/notesSlides\//i.test(name)) return 'notes'
|
|
90
|
+
if (/^ppt\/notesMasters\//i.test(name)) return 'notesMaster'
|
|
91
|
+
if (/^ppt\/theme\//i.test(name)) return 'theme'
|
|
92
|
+
if (/^ppt\/diagrams\//i.test(name)) return 'diagram'
|
|
93
|
+
if (/^ppt\/media\//i.test(name)) return 'media'
|
|
94
|
+
if (/^ppt\/embeddings\//i.test(name)) return 'embedding'
|
|
95
|
+
return 'other'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** slide 页号(1 基);非 slides/ 部件返回 null(页面证据靠 part 名兜底)。 */
|
|
99
|
+
function pageOf(name) {
|
|
100
|
+
const m = /^ppt\/slides\/slide(\d+)\.xml$/i.exec(name)
|
|
101
|
+
return m ? Number(m[1]) : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 部件表归一:Map / 普通对象 / [name, data] 数组 / [{name,data}] 数组都接受。 */
|
|
105
|
+
function normalizeParts(parts) {
|
|
106
|
+
const out = []
|
|
107
|
+
const push = (k, v) => { if (typeof k === 'string' && k) out.push([k, v ?? '']) }
|
|
108
|
+
if (parts instanceof Map) for (const [k, v] of parts) push(k, v)
|
|
109
|
+
else if (Array.isArray(parts)) {
|
|
110
|
+
for (const it of parts) {
|
|
111
|
+
if (Array.isArray(it)) push(it[0], it[1])
|
|
112
|
+
else if (it && typeof it === 'object') push(it.name ?? it.path ?? it.file ?? it.part, it.data ?? it.xml ?? it.text ?? it.content)
|
|
113
|
+
}
|
|
114
|
+
} else if (parts && typeof parts === 'object') for (const [k, v] of Object.entries(parts)) push(k, v)
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** gradFill 的 stops(计数 + 颜色,证据用)。 */
|
|
119
|
+
function stopsOf(gradFill) {
|
|
120
|
+
const gsLst = gradFill.children.find((c) => c.tag === 'gsLst')
|
|
121
|
+
const gs = gsLst ? gsLst.children.filter((c) => c.tag === 'gs') : []
|
|
122
|
+
const colors = gs.map((g) => {
|
|
123
|
+
const srgb = g.children.find((c) => c.tag === 'srgbClr')
|
|
124
|
+
if (srgb?.attrs?.val) return '#' + lower(srgb.attrs.val)
|
|
125
|
+
const scheme = g.children.find((c) => c.tag === 'schemeClr')
|
|
126
|
+
return scheme?.attrs?.val ? `scheme:${scheme.attrs.val}` : '?'
|
|
127
|
+
})
|
|
128
|
+
return { count: gs.length, colors }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 递归数某几个 tag 的节点数(timing 下动画效果计数用)。 */
|
|
132
|
+
function countTags(node, tags) {
|
|
133
|
+
let n = 0
|
|
134
|
+
const walk = (x) => {
|
|
135
|
+
if (tags.has(x.tag)) n++
|
|
136
|
+
for (const c of x.children ?? []) walk(c)
|
|
137
|
+
}
|
|
138
|
+
for (const c of node.children ?? []) walk(c)
|
|
139
|
+
return n
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 形状容器(元素名的宿主)。 */
|
|
143
|
+
const SHAPE_TAGS = new Set(['sp', 'pic', 'graphicFrame', 'cxnSp', 'grpSp', 'contentPart', 'oleObj'])
|
|
144
|
+
|
|
145
|
+
/** 在形状容器子树里找 cNvPr 的名字(`sp > nvSpPr > cNvPr`,深度 ≤2,不跨子形状)。 */
|
|
146
|
+
function cnvPrName(node, depth = 0) {
|
|
147
|
+
if (!node || depth > 2) return null
|
|
148
|
+
for (const c of node.children ?? []) {
|
|
149
|
+
if (c.tag === 'cNvPr') return c.attrs?.name ?? c.attrs?.id ?? null
|
|
150
|
+
const nested = cnvPrName(c, depth + 1)
|
|
151
|
+
if (nested) return nested
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 审计入口(纯函数)。
|
|
158
|
+
* @param {Map<string,Buffer|string>|object|Array} parts OOXML 部件表
|
|
159
|
+
* @param {{source?:string}} [opts] source = 源文件名(写进报告元数据)
|
|
160
|
+
* @returns 报告对象(写 loss-audit.json,也直接给工具输出用)
|
|
161
|
+
*/
|
|
162
|
+
export function auditLoss(parts, opts = {}) {
|
|
163
|
+
const map = normalizeParts(parts)
|
|
164
|
+
const hits = new Map(CATEGORY_DEFS.map((d) => [d.id, []]))
|
|
165
|
+
const add = (id, ev) => { hits.get(id).push(ev) }
|
|
166
|
+
const diagramParts = []
|
|
167
|
+
const themeRefs = { bgRef: [], fillRef: [] } // p:bgRef / a:fillRef 的 idx(判断主题渐变是否被引用)
|
|
168
|
+
let slides = 0
|
|
169
|
+
let shapeGradients = 0 // 形状渐变(有直通,不计;只在 note 里说明)
|
|
170
|
+
|
|
171
|
+
const walk = (name, kind, text) => {
|
|
172
|
+
const page = pageOf(name)
|
|
173
|
+
if (kind === 'slide') slides++
|
|
174
|
+
const tree = parseXml(text)
|
|
175
|
+
const anc = []
|
|
176
|
+
// 元素名 = 最近的形状容器(sp/pic/graphicFrame/…)里的 cNvPr@name。
|
|
177
|
+
// 注意 cNvPr 与效果/文本是**兄弟分支**(spPr/ txBody 侧),不是祖先,所以按容器反查。
|
|
178
|
+
const elName = () => {
|
|
179
|
+
for (let i = anc.length - 1; i >= 0; i--) {
|
|
180
|
+
if (SHAPE_TAGS.has(anc[i].tag)) return cnvPrName(anc[i])
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
const inTheme = kind === 'theme'
|
|
185
|
+
const visit = (node, inEffect) => {
|
|
186
|
+
const tag = node.tag
|
|
187
|
+
const childInEffect = inEffect || EFFECT_LIST_TAGS.has(tag)
|
|
188
|
+
// ── 效果(阴影/发光)── 空 effectLst 与主题效果模板不计
|
|
189
|
+
if (EFFECT_LIST_TAGS.has(tag)) {
|
|
190
|
+
const kinds = {}
|
|
191
|
+
for (const c of node.children) if (EFFECT_TAGS.has(c.tag)) kinds[c.tag] = (kinds[c.tag] ?? 0) + 1
|
|
192
|
+
const kk = Object.keys(kinds)
|
|
193
|
+
if (kk.length && !inTheme) {
|
|
194
|
+
add('effect', { page, part: name, element: elName(), tag, kinds, detail: `效果容器:${kk.map((k) => `${k}×${kinds[k]}`).join('+')}` })
|
|
195
|
+
}
|
|
196
|
+
} else if (!inEffect && !inTheme && EFFECT_TAGS.has(tag)) {
|
|
197
|
+
add('effect', { page, part: name, element: elName(), tag, kinds: { [tag]: 1 }, detail: `裸效果节点 ${tag}(不在 effectLst 内)` })
|
|
198
|
+
}
|
|
199
|
+
if (!inTheme) {
|
|
200
|
+
// ── 动画 ──
|
|
201
|
+
if (tag === 'timing') {
|
|
202
|
+
const n = countTags(node, ANIM_NODES)
|
|
203
|
+
if (n > 0) add('animation', { page, part: name, element: null, tag, anims: n, detail: `时间轴含 ${n} 个动画效果节点` })
|
|
204
|
+
}
|
|
205
|
+
// ── 超链接 ──
|
|
206
|
+
if (tag === 'hlinkClick' || tag === 'hlinkHover') {
|
|
207
|
+
const rid = node.attrs['r:id'] ?? node.attrs.id ?? null
|
|
208
|
+
add('hyperlink', { page, part: name, element: elName(), tag, subtype: tag === 'hlinkClick' ? 'click' : 'hover', rId: rid, detail: `${tag === 'hlinkClick' ? '点击' : '悬停'}超链接${rid ? `(${rid})` : ''}` })
|
|
209
|
+
}
|
|
210
|
+
// ── 页切换 ──
|
|
211
|
+
if (tag === 'transition' && kind !== 'notes' && kind !== 'notesMaster') {
|
|
212
|
+
const child = node.children?.find((c) => c.tag !== 'sldClickOn')?.tag
|
|
213
|
+
const type = node.attrs?.type ?? child ?? null
|
|
214
|
+
add('transition', { page, part: name, element: null, tag, type, detail: `页切换${type ? `(${type})` : ''}` })
|
|
215
|
+
}
|
|
216
|
+
// ── SmartArt ──
|
|
217
|
+
if (tag === 'graphicData' && /\/diagram/.test(node.attrs?.uri ?? '')) {
|
|
218
|
+
add('smartart', { page, part: name, element: elName(), tag, uri: node.attrs.uri, detail: `图形框架引用 SmartArt(${String(node.attrs.uri).replace(/^.*\//, '')})` })
|
|
219
|
+
}
|
|
220
|
+
// ── 幻灯片内的 OLE / 音视频引用 ──
|
|
221
|
+
if (tag === 'graphicData' && /\/ole/i.test(node.attrs?.uri ?? '')) {
|
|
222
|
+
add('media', { page, part: name, element: elName(), tag, ext: 'ole-ref', reason: 'OLE 嵌入对象(幻灯片内引用)', detail: `幻灯片内 OLE 嵌入对象(${String(node.attrs.uri).replace(/^.*\//, '')})` })
|
|
223
|
+
}
|
|
224
|
+
if ((tag === 'videoFile' || tag === 'audioFile' || tag === 'media') && (node.attrs?.['r:link'] || node.attrs?.['r:embed'])) {
|
|
225
|
+
const ext = tag === 'media' ? 'media' : tag === 'videoFile' ? 'video' : 'audio'
|
|
226
|
+
add('media', { page, part: name, element: elName(), tag, ext, reason: tag === 'media' ? '内嵌/外链媒体(p14:media)' : '内嵌/外链音视频', detail: `${tag}(${node.attrs['r:link'] ? 'link' : 'embed'}: ${node.attrs['r:link'] ?? node.attrs['r:embed']})` })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ── 渐变 ──
|
|
230
|
+
if (tag === 'gradFill') {
|
|
231
|
+
const st = stopsOf(node)
|
|
232
|
+
if (inTheme) {
|
|
233
|
+
const holderNode = [...anc].reverse().find((n) => /fillStyleLst$/i.test(n.tag))
|
|
234
|
+
const index = holderNode ? holderNode.children.indexOf(node) + 1 : null
|
|
235
|
+
add('gradient', { page, part: name, element: null, tag, scope: 'theme', holder: holderNode?.tag ?? null, index, ...st, detail: `主题渐变(${holderNode?.tag ?? 'theme'}${index ? ` 第 ${index} 项` : ''},${st.count} 个 stop:${st.colors.join('→')})→ 归一为纯色` })
|
|
236
|
+
} else if (WALK_KINDS.has(kind) && anc.some((n) => n.tag === 'bg')) {
|
|
237
|
+
add('gradient', { page, part: name, element: null, tag, scope: 'background', visible: true, ...st, detail: `页面背景渐变(${st.count} 个 stop:${st.colors.join('→')})→ 归一为纯色` })
|
|
238
|
+
} else {
|
|
239
|
+
shapeGradients++
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ── 主题引用(判"主题渐变是否真的用在**可见页面**上")──
|
|
243
|
+
// 只看幻灯片/版式/母版:备注页(notes/notesMaster)不参与放映观感,引用它不算"可见损失"
|
|
244
|
+
// (真实 python-pptx 稿就有 "notesSlide 里 fillRef=3 指向主题渐变" 的情形)。
|
|
245
|
+
if ((tag === 'bgRef' || tag === 'fillRef') && (kind === 'slide' || kind === 'layout' || kind === 'master')) {
|
|
246
|
+
const idx = Number(node.attrs?.idx)
|
|
247
|
+
if (Number.isFinite(idx) && idx > 0) {
|
|
248
|
+
const k = tag === 'bgRef' ? 'bgRef' : 'fillRef'
|
|
249
|
+
if (!themeRefs[k].includes(idx)) themeRefs[k].push(idx)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
anc.push(node)
|
|
253
|
+
for (const c of node.children) visit(c, childInEffect)
|
|
254
|
+
anc.pop()
|
|
255
|
+
}
|
|
256
|
+
visit(tree, false)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const [name, data] of map) {
|
|
260
|
+
const kind = partKind(name)
|
|
261
|
+
if (kind === 'media') {
|
|
262
|
+
const ext = extOf(name)
|
|
263
|
+
if (!EMBEDDABLE_EXT.has(ext)) {
|
|
264
|
+
const reason = AV_EXT.has(ext) ? '音视频(导出不内嵌)' : VECTOR_EXT.has(ext) ? '矢量/位图格式(导出仅内嵌 png/jpeg/jpg/gif/webp)' : '扩展名不在导出支持列表(导出仅内嵌 png/jpeg/jpg/gif/webp)'
|
|
265
|
+
add('media', { page: null, part: name, element: name.slice(name.lastIndexOf('/') + 1), tag: 'media-part', ext, reason, detail: `媒体部件 .${ext}:${reason}` })
|
|
266
|
+
}
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (kind === 'embedding') {
|
|
270
|
+
add('media', { page: null, part: name, element: name.slice(name.lastIndexOf('/') + 1), tag: 'embedding-part', ext: extOf(name), reason: 'OLE 嵌入对象(DSL 无通道)', detail: `嵌入对象部件(.${extOf(name)}):导出不会带入成品` })
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (kind === 'diagram') {
|
|
274
|
+
diagramParts.push(name)
|
|
275
|
+
continue // 有引用时按引用计数;无引用时下方兜底合计 1 处
|
|
276
|
+
}
|
|
277
|
+
if (kind !== 'theme' && !WALK_KINDS.has(kind)) continue
|
|
278
|
+
if (!/\.xml$/i.test(name)) continue
|
|
279
|
+
let text
|
|
280
|
+
try {
|
|
281
|
+
text = typeof data === 'string' ? data : decodeXml(data)
|
|
282
|
+
} catch {
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
walk(name, kind, text)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// SmartArt 兜底:没有任何图形框架引用但包里有图部件(合计 1 处,不按 4 个部件放大)
|
|
289
|
+
if (!hits.get('smartart').length && diagramParts.length) {
|
|
290
|
+
add('smartart', { page: null, part: diagramParts[0], element: null, tag: 'diagram-part', parts: diagramParts.length, detail: `包内有 ${diagramParts.length} 个 SmartArt 图部件但未发现图形框架引用(合计 1 处)` })
|
|
291
|
+
} else if (diagramParts.length) {
|
|
292
|
+
for (const ev of hits.get('smartart')) ev.detail += `;包内另有 ${diagramParts.length} 个 SmartArt 图部件`
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 主题渐变的"是否真的用在页面上":bgRef idx=1001+N → bgFillStyleLst 第 N+1 项;fillRef idx=N → fillStyleLst 第 N 项。
|
|
296
|
+
// (refs 在全量遍历后才齐——主题部件常排在 master 之前,所以必须在此后处理。)
|
|
297
|
+
for (const ev of hits.get('gradient')) {
|
|
298
|
+
if (ev.scope === 'background') continue
|
|
299
|
+
if (!ev.holder || !ev.index) { ev.visible = false; ev.detail += ';未被引用(样式表模板)'; continue }
|
|
300
|
+
ev.visible = /^bg/i.test(ev.holder) ? themeRefs.bgRef.includes(1000 + ev.index) : themeRefs.fillRef.includes(ev.index)
|
|
301
|
+
if (!ev.visible) ev.detail += ';未被引用(样式表模板)'
|
|
302
|
+
}
|
|
303
|
+
const visibleOf = (ev) => ev.filter((e) => e.visible !== false).length
|
|
304
|
+
|
|
305
|
+
// 证据排序(确定性:页 → 部件 → 元素 → tag),并按上限截断
|
|
306
|
+
const categories = CATEGORY_DEFS.map((d) => {
|
|
307
|
+
const ev = hits.get(d.id)
|
|
308
|
+
ev.sort((a, b) => (a.page ?? 1e9) - (b.page ?? 1e9) || String(a.part).localeCompare(String(b.part)) || String(a.element ?? '').localeCompare(String(b.element ?? '')) || String(a.tag).localeCompare(String(b.tag)) || String(a.holder ?? '').localeCompare(String(b.holder ?? '')) || (a.index ?? 0) - (b.index ?? 0) || String(a.ext ?? '').localeCompare(String(b.ext ?? '')))
|
|
309
|
+
const out = { id: d.id, label: d.label, count: ev.length, visible: visibleOf(ev), loss: d.loss, evidence: ev.slice(0, MAX_EVIDENCE) }
|
|
310
|
+
if (ev.length > MAX_EVIDENCE) out.evidenceTruncated = true
|
|
311
|
+
return out
|
|
312
|
+
})
|
|
313
|
+
const counts = Object.fromEntries(categories.map((c) => [c.id, c.count]))
|
|
314
|
+
const total = categories.reduce((s, c) => s + c.count, 0)
|
|
315
|
+
const visibleTotal = categories.reduce((s, c) => s + c.visible, 0)
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
generatedBy: 'dsh-ppt-studio',
|
|
319
|
+
version: 1,
|
|
320
|
+
source: opts.source ?? null,
|
|
321
|
+
kind: 'pptx-rebuild-loss-audit',
|
|
322
|
+
clean: total === 0,
|
|
323
|
+
total,
|
|
324
|
+
visibleTotal,
|
|
325
|
+
// 主题样式表渐变未被引用:整册重渲只丢主题标识,不丢可见效果(lossAuditLine 降级为 ℹ)
|
|
326
|
+
themeStyleOnly: visibleTotal === 0 && total > 0,
|
|
327
|
+
counts,
|
|
328
|
+
scannedParts: map.length,
|
|
329
|
+
slides,
|
|
330
|
+
categories,
|
|
331
|
+
...(themeRefs.bgRef.length || themeRefs.fillRef.length ? { themeRefs: { bgRef: themeRefs.bgRef, fillRef: themeRefs.fillRef } } : {}),
|
|
332
|
+
note: '判据:动画=非空 p:timing;阴影/发光=含效果子节点的 a:effectLst/a:effectDag 或裸效果节点(空 effectLst 与主题 effectStyleLst 不计);超链接=a:hlinkClick/a:hlinkHover;切换=p:transition;SmartArt=graphicData@uri 含 /diagram(无引用时包内部件合计 1 处);不可嵌入媒体=ppt/media 扩展名 ∉ {png,jpeg,jpg,gif,webp}、ppt/embeddings、幻灯片内 OLE/音视频引用;渐变=p:bg 下 a:gradFill(可见)+ 主题 a:gradFill(被 bgRef idx=1001+N / fillRef idx=N 引用者 visible:true,未被引用者 visible:false=样式表模板);形状渐变直通不计。证据字段:page(1 基,非幻灯片为 null)/part/element/tag/visible。',
|
|
333
|
+
advice: total === 0
|
|
334
|
+
? '未发现 DSL 无法保真的特性:整册重渲不额外丢东西(图表降级/未映射形状另有 warnings)。'
|
|
335
|
+
: visibleTotal === 0
|
|
336
|
+
? `未发现会丢的可见效果;仅主题样式表含 ${total} 处渐变且未被任何 bgRef/fillRef 引用(不影响页面观感,整册重渲只丢主题标识)。若只需改某几页,仍优先 ppt_splice。`
|
|
337
|
+
: `整册重渲会丢上述 ${visibleTotal} 处可见特性;若只需改某几页 / 改一处文字,优先 ppt_splice(只把工作区目标页替换进原稿,其余页逐字节不变)——动画/阴影/超链接等留在原稿里。`,
|
|
338
|
+
...(shapeGradients ? { note_shapeGradientsPreserved: `包内另有 ${shapeGradients} 处形状渐变:v0.9.1 起直通 fill.gradient,不计入损失。` } : {}),
|
|
339
|
+
hitSummary: categories.filter((c) => c.count > 0).map((c) => ({ id: c.id, label: c.label, count: c.count, visible: c.visible })),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** zipRead 一层:从 .pptx 二进制审计(测试/工具用)。 */
|
|
344
|
+
export function auditLossFromBuffer(buf, opts = {}) {
|
|
345
|
+
return auditLoss(zipRead(buf), opts)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 工具输出结论行(ppt_import 用):干净源稿只给一行"未发现",不制造噪声;
|
|
350
|
+
* 只有"未被引用的主题样式表渐变"时不谎报"会丢可见效果"(降级为 ℹ)。
|
|
351
|
+
*/
|
|
352
|
+
export function lossAuditLine(report) {
|
|
353
|
+
if (!report || typeof report !== 'object') return ''
|
|
354
|
+
if (report.clean) return '✓ 损失审计:未发现 DSL 无法保真的特性(动画 / 阴影发光 / 超链接 / 切换 / SmartArt / 不可嵌入媒体 / 主题渐变均为 0)——整册重渲不额外丢东西'
|
|
355
|
+
const hit = report.hitSummary ?? []
|
|
356
|
+
const visible = hit.filter((c) => (c.visible ?? c.count) > 0)
|
|
357
|
+
const hiddenCount = hit.reduce((s, c) => s + (c.count - (c.visible ?? c.count)), 0)
|
|
358
|
+
if (!visible.length) {
|
|
359
|
+
return `ℹ 损失审计:未发现会丢的可见效果;源稿主题含 ${report.total} 处渐变样式模板(未被任何 bgRef/fillRef 引用,不影响观感)——整册重渲只丢主题标识,若只需改某几页仍优先 ppt_splice;证据见 ${LOSS_AUDIT_FILE}`
|
|
360
|
+
}
|
|
361
|
+
const parts = visible.map((c) => `${c.label} ${c.visible ?? c.count} 处`).join(' / ')
|
|
362
|
+
return `⚠ 源稿含不可保真特性:${parts}${hiddenCount ? `(另有 ${hiddenCount} 处主题样式表渐变未被引用,不影响观感)` : ''} —— 整册重渲会丢这些;若只需改某几页 / 改一处文字,优先 ppt_splice(只替换目标页,其余页逐字节不变);页码/元素级证据见 ${LOSS_AUDIT_FILE}`
|
|
363
|
+
}
|