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,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-loss-audit —— 启发 B(审计半)自证:合成 OOXML 造正例/反例,断言
|
|
4
|
+
* `src/pptd/loss-audit.js` 的类别计数、证据结构、报告文案与 `ppt_import` 落盘集成。
|
|
5
|
+
*
|
|
6
|
+
* 为什么用合成片段而不是真 PPT:判据是"XML 里有没有这些节点",合成片段足以证明;
|
|
7
|
+
* 真装 PowerPoint 反而让测试不可重复(官方 office 技能同理:结构自证 > 工具链依赖)。
|
|
8
|
+
*
|
|
9
|
+
* 覆盖:
|
|
10
|
+
* 反例(防误报):干净 deck 0 命中——**且刻意塞入真实世界的噪声源**:空 `<a:effectLst/>`、
|
|
11
|
+
* 主题 `effectStyleLst` 里的 3 个空 effectLst、可内嵌的 png。
|
|
12
|
+
* 正例(防漏报):p:timing / a:effectLst+outerShdw+glow / a:hlinkClick+hlinkHover /
|
|
13
|
+
* p:transition / SmartArt(graphicData diagram + dgm 部件)/ EMF+MP4+OLE /
|
|
14
|
+
* 主题渐变 + p:bg 渐变;并验证**形状渐变不计**(有直通,属防误报)。
|
|
15
|
+
* 集成:importPptx 落盘 loss-audit.json + 返回 lossLine;既有产物(deck.yaml/pages/import-styles.json)不受影响。
|
|
16
|
+
*
|
|
17
|
+
* 用法:node scripts/build.mjs && node scripts/verify-loss-audit.mjs (退出码 0=PASS / 1=FAIL)
|
|
18
|
+
* 注意:脚本从 **lib/** 导入(测的就是插件实际装载的产物;lib 与 src 一致性由 check-lib-freshness/smoke 守)。
|
|
19
|
+
*/
|
|
20
|
+
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { tmpdir } from 'node:os'
|
|
23
|
+
import { join, dirname } from 'node:path'
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
25
|
+
|
|
26
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
const libFile = (...p) => pathToFileURL(join(root, 'lib', ...p)).href
|
|
28
|
+
if (!existsSync(join(root, 'lib', 'pptd', 'loss-audit.js'))) {
|
|
29
|
+
console.error('✗ 缺 lib/pptd/loss-audit.js —— 先跑 `node scripts/build.mjs`(本脚本测 lib 产物,与 smoke 同口径)')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
const { auditLoss, auditLossFromBuffer, lossAuditLine, LOSS_AUDIT_FILE } = await import(libFile('pptd', 'loss-audit.js'))
|
|
33
|
+
const { importPptx } = await import(libFile('pptd', 'import-pptx.js'))
|
|
34
|
+
const { zipWrite, zipRead } = await import(libFile('zips.js'))
|
|
35
|
+
|
|
36
|
+
// ── 断言器 ────────────────────────────────────────────────────────────────
|
|
37
|
+
let pass = 0
|
|
38
|
+
let fail = 0
|
|
39
|
+
const check = (name, cond, detail = '') => {
|
|
40
|
+
if (cond) { pass++; console.log(` ✓ ${name}`) } else { fail++; console.log(` ✗ ${name}${detail ? `\n ${String(detail).slice(0, 600)}` : ''}`) }
|
|
41
|
+
}
|
|
42
|
+
const section = (t) => console.log(`\n── ${t} ──`)
|
|
43
|
+
|
|
44
|
+
// ── 合成 OOXML 夹具 ───────────────────────────────────────────────────────
|
|
45
|
+
const XD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
46
|
+
const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"'
|
|
47
|
+
const GRAD = '<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:srgbClr val="1F2937"/></a:gs><a:gs pos="100000"><a:srgbClr val="2E4B9F"/></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill>'
|
|
48
|
+
const EFFECT_2 = '<a:effectLst><a:outerShdw blurRad="40000" dist="20000" dir="5400000" rotWithShape="0"><a:srgbClr val="000000"><a:alpha val="40000"/></a:srgbClr></a:outerShdw><a:glow rad="63500"><a:srgbClr val="2E4B9F"><a:alpha val="60000"/></a:srgbClr></a:glow></a:effectLst>'
|
|
49
|
+
const EFFECT_GLOW = '<a:effectLst><a:glow rad="63500"><a:srgbClr val="2E4B9F"/></a:glow></a:effectLst>'
|
|
50
|
+
const EMPTY_EFFECT = '<a:effectLst/>'
|
|
51
|
+
const ANIM_TIMING = '<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" fill="hold"><p:childTnLst><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="2" dur="500"/><p:tgtEl><p:spTgt spid="2"/></p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>'
|
|
52
|
+
const TRANSITION = '<p:transition spd="slow"><p:fade/></p:transition>'
|
|
53
|
+
|
|
54
|
+
const txtSp = (id, text, { rPr = '', spPr = '' } = {}) =>
|
|
55
|
+
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="1000000" y="1000000"/><a:ext cx="3000000" cy="800000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>${spPr}</p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="zh-CN" sz="1800">${rPr}</a:rPr><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`
|
|
56
|
+
|
|
57
|
+
const rectSp = (id, spPr) =>
|
|
58
|
+
`<p:sp><p:nvSpPr><p:cNvPr id="3" name="${id}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="1000000" y="2000000"/><a:ext cx="3000000" cy="1500000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>${spPr}</p:spPr></p:sp>`
|
|
59
|
+
|
|
60
|
+
const smartArtFrame = () =>
|
|
61
|
+
'<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="9" name="SmartArt 1"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="1000000" y="3000000"/><a:ext cx="3000000" cy="1500000"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rId1" r:lo="rId2"/></a:graphicData></a:graphic></p:graphicFrame>'
|
|
62
|
+
|
|
63
|
+
const videoPic = () =>
|
|
64
|
+
'<p:pic><p:nvPicPr><p:cNvPr id="10" name="视频 1"/><p:cNvPicPr/><p:nvPr><a:videoFile r:link="rId7"/></p:nvPr></p:nvPicPr><p:blipFill><a:blip r:embed="rId8"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="4000000" y="3000000"/><a:ext cx="2000000" cy="1200000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>'
|
|
65
|
+
|
|
66
|
+
const slideXml = ({ cSldHead = '', body = '', tail = '' }) =>
|
|
67
|
+
`${XD}<p:sld ${NS}><p:cSld>${cSldHead}<p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>${body}</p:spTree></p:cSld>${tail}</p:sld>`
|
|
68
|
+
|
|
69
|
+
/** 主题:刻意带 3 个空 `a:effectLst`(真实模板/我们自己的导出都有)+ 可控的渐变模板。
|
|
70
|
+
* `bgGrad` → bgFillStyleLst 第 1 项 = 渐变(可被 master 的 bgRef idx=1001 引用到 ⇒ visible);
|
|
71
|
+
* `fillGrad` → fillStyleLst 第 1 项 = 渐变(本夹具无人 fillRef ⇒ visible:false 样式表模板)。 */
|
|
72
|
+
const themeXml = ({ bgGrad = false, fillGrad = false } = {}) =>
|
|
73
|
+
`${XD}<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="fixture"><a:themeElements><a:clrScheme name="c"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1></a:clrScheme><a:fontScheme name="f"><a:majorFont><a:latin typeface="Calibri"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/></a:minorFont></a:fontScheme><a:fmtScheme name="s"><a:fillStyleLst>${fillGrad ? GRAD : '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'}<a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="6350"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle>${EMPTY_EFFECT}</a:effectStyle><a:effectStyle>${EMPTY_EFFECT}</a:effectStyle><a:effectStyle>${EMPTY_EFFECT}</a:effectStyle></a:effectStyleLst><a:bgFillStyleLst>${bgGrad ? GRAD : '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'}</a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`
|
|
74
|
+
|
|
75
|
+
const presentationXml = (n) =>
|
|
76
|
+
`${XD}<p:presentation ${NS}><p:sldIdLst>${Array.from({ length: n }, (_, i) => `<p:sldId id="${256 + i}" r:id="rId${i + 2}"/>`).join('')}</p:sldIdLst><p:sldSz cx="12192000" cy="6858000"/></p:presentation>`
|
|
77
|
+
|
|
78
|
+
const presentationRels = (n) =>
|
|
79
|
+
`${XD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>${Array.from({ length: n }, (_, i) => `<Relationship Id="rId${i + 2}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide${i + 1}.xml"/>`).join('')}</Relationships>`
|
|
80
|
+
|
|
81
|
+
const contentTypes = (n, extraExts = []) =>
|
|
82
|
+
`${XD}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/>${extraExts.map((e) => `<Default Extension="${e}" ContentType="application/octet-stream"/>`).join('')}<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>${Array.from({ length: n }, (_, i) => `<Override PartName="/ppt/slides/slide${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>`).join('')}<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/></Types>`
|
|
83
|
+
|
|
84
|
+
const ROOT_RELS = `${XD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`
|
|
85
|
+
const SLIDE_RELS = `${XD}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`
|
|
86
|
+
|
|
87
|
+
/** 干净 deck:只有文本;噪声源齐备(空 effectLst / 主题 effectStyleLst / 可内嵌 png)。 */
|
|
88
|
+
function cleanPptx() {
|
|
89
|
+
return {
|
|
90
|
+
'[Content_Types].xml': contentTypes(2),
|
|
91
|
+
'_rels/.rels': ROOT_RELS,
|
|
92
|
+
'ppt/presentation.xml': presentationXml(2),
|
|
93
|
+
'ppt/_rels/presentation.xml.rels': presentationRels(2),
|
|
94
|
+
'ppt/slides/slide1.xml': slideXml({ cSldHead: '<p:bg><p:bgPr><a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill>' + EMPTY_EFFECT + '</p:bgPr></p:bg>', body: txtSp('Title 1', '干净第一页') }),
|
|
95
|
+
'ppt/slides/slide2.xml': slideXml({ body: txtSp('Title 2', '干净第二页') + rectSp('Box 2', '<a:solidFill><a:srgbClr val="EEEEEE"/></a:solidFill>' + EMPTY_EFFECT) }),
|
|
96
|
+
'ppt/slides/_rels/slide1.xml.rels': SLIDE_RELS,
|
|
97
|
+
'ppt/slides/_rels/slide2.xml.rels': SLIDE_RELS,
|
|
98
|
+
'ppt/theme/theme1.xml': themeXml({ bgGrad: false, fillGrad: false }),
|
|
99
|
+
'ppt/media/photo.png': Buffer.from('fake-png-bytes'),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 主题样式表专用 deck:只有一个**没被任何 bgRef/fillRef 引用**的主题渐变(python-pptx 风格默认主题就是这个形态)。
|
|
104
|
+
* 期望:total 1 / visibleTotal 0 / themeStyleOnly true → 报告降级为 ℹ,不谎报"会丢可见效果"。 */
|
|
105
|
+
function themeOnlyPptx() {
|
|
106
|
+
const e = cleanPptx()
|
|
107
|
+
e['ppt/theme/theme1.xml'] = themeXml({ bgGrad: true, fillGrad: true })
|
|
108
|
+
return e
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 脏 deck(正例):7 类全命中,并且埋了 4 个"不该计"的陷阱:
|
|
113
|
+
* ① 主题空的 effectLst(×3)不计;② 形状渐变(spPr/gradFill)不计;③ 可内嵌 photo.png 不计;
|
|
114
|
+
* ④ 主题 fillStyleLst 渐变无人 fillRef → 计 1 处但 visible:false(不进入"会丢可见效果"计数)。
|
|
115
|
+
* 主题 bgFillStyleLst 第 1 项渐变被 slideMaster1 的 `<p:bgRef idx="1001"/>` 引用 ⇒ visible:true。
|
|
116
|
+
* 期望计数:animation 2 / effect 3 / hyperlink 3 / transition 1 / smartart 1 / media 4 / gradient 3 = 17(visible 16)
|
|
117
|
+
*/
|
|
118
|
+
function dirtyPptx() {
|
|
119
|
+
const slide1 = slideXml({
|
|
120
|
+
body: txtSp('Title 1', '带链接', { rPr: '<a:hlinkClick r:id="rId9" action="ppaction://hlinksldjump"/>' })
|
|
121
|
+
+ rectSp('Card 1', EFFECT_2) // effectLst(outerShdw+glow) → effect 1
|
|
122
|
+
+ rectSp('GradCard', GRAD) // 形状渐变 → 不计(陷阱 ②)
|
|
123
|
+
+ smartArtFrame(), // smartart 1
|
|
124
|
+
tail: TRANSITION + ANIM_TIMING, // transition 1 + animation 1
|
|
125
|
+
})
|
|
126
|
+
const slide2 = slideXml({
|
|
127
|
+
cSldHead: '<p:bg><p:bgPr>' + GRAD + EMPTY_EFFECT + '<a:effectLst/></p:bgPr></p:bg>', // 背景渐变 1(空 effectLst 不计)
|
|
128
|
+
body: txtSp('Title 2', '点击链接', { rPr: '<a:hlinkClick r:id="rId9"/>' })
|
|
129
|
+
+ txtSp('Title 3', '悬停链接', { rPr: '<a:hlinkHover r:id="rId9"/>' })
|
|
130
|
+
+ rectSp('Card 2', EFFECT_GLOW) // effectLst(glow) → effect 1
|
|
131
|
+
+ rectSp('ShadowCard', '<a:prstShdw prst="shdw1" dist="20000" dir="5400000"/>') // 裸效果节点 → effect 1
|
|
132
|
+
+ videoPic(), // 幻灯片内音视频引用 → media 1
|
|
133
|
+
tail: ANIM_TIMING, // animation 1
|
|
134
|
+
})
|
|
135
|
+
return {
|
|
136
|
+
'[Content_Types].xml': contentTypes(2, ['emf', 'mp4', 'bin']),
|
|
137
|
+
'_rels/.rels': ROOT_RELS,
|
|
138
|
+
'ppt/presentation.xml': presentationXml(2),
|
|
139
|
+
'ppt/_rels/presentation.xml.rels': presentationRels(2),
|
|
140
|
+
'ppt/slides/slide1.xml': slide1,
|
|
141
|
+
'ppt/slides/slide2.xml': slide2,
|
|
142
|
+
'ppt/slides/_rels/slide1.xml.rels': SLIDE_RELS,
|
|
143
|
+
'ppt/slides/_rels/slide2.xml.rels': SLIDE_RELS,
|
|
144
|
+
'ppt/theme/theme1.xml': themeXml({ bgGrad: true, fillGrad: true }), // 主题渐变 2(1 被引用 + 1 样式表模板)
|
|
145
|
+
// 母版:背景引用 bgFillStyleLst 第 1 项(= 上面的主题渐变)→ 该主题渐变 visible:true
|
|
146
|
+
'ppt/slideMasters/slideMaster1.xml': `${XD}<p:sldMaster ${NS}><p:cSld><p:bg><p:bgRef idx="1001"><a:schemeClr val="bg1"/></p:bgRef></p:bg><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/></p:spTree></p:cSld></p:sldMaster>`,
|
|
147
|
+
// 备注母版的 fillRef idx=1 指向 fillStyleLst 第 1 项渐变——**不得**因此判 visible(备注页不参与放映观感)
|
|
148
|
+
'ppt/notesMasters/notesMaster1.xml': `${XD}<p:notesMaster ${NS}><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/><p:sp><p:nvSpPr><p:cNvPr id="2" name="Notes Placeholder"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1000000" cy="1000000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:style><a:lnRef idx="1"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="1"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></p:style></p:sp></p:spTree></p:cSld></p:notesMaster>`,
|
|
149
|
+
'ppt/diagrams/data1.xml': `${XD}<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"/>`,
|
|
150
|
+
'ppt/diagrams/layout1.xml': `${XD}<dgm:layoutDef xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"/>`,
|
|
151
|
+
'ppt/media/vector.emf': Buffer.from('fake-emf'),
|
|
152
|
+
'ppt/media/clip.mp4': Buffer.from('fake-mp4'),
|
|
153
|
+
'ppt/media/photo.png': Buffer.from('fake-png-bytes'), // 可内嵌 → 不计(陷阱 ③)
|
|
154
|
+
'ppt/embeddings/oleObject1.bin': Buffer.from('fake-ole'),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const EXPECT_DIRTY = { animation: 2, effect: 3, hyperlink: 3, transition: 1, smartart: 1, media: 4, gradient: 3 }
|
|
159
|
+
const EXPECT_DIRTY_VISIBLE = { animation: 2, effect: 3, hyperlink: 3, transition: 1, smartart: 1, media: 4, gradient: 2 }
|
|
160
|
+
const CATEGORY_IDS = ['animation', 'effect', 'hyperlink', 'transition', 'smartart', 'media', 'gradient']
|
|
161
|
+
const cat = (r, id) => r.categories.find((c) => c.id === id)
|
|
162
|
+
|
|
163
|
+
// ── 跑 ────────────────────────────────────────────────────────────────────
|
|
164
|
+
const work = await mkdtemp(join(tmpdir(), 'verify-loss-audit-'))
|
|
165
|
+
try {
|
|
166
|
+
const cleanEntries = cleanPptx()
|
|
167
|
+
const dirtyEntries = dirtyPptx()
|
|
168
|
+
const cleanBuf = zipWrite(cleanEntries)
|
|
169
|
+
const dirtyBuf = zipWrite(dirtyEntries)
|
|
170
|
+
|
|
171
|
+
section('A. 反例:干净 deck(含真实噪声源)→ 0 命中(防误报)')
|
|
172
|
+
const clean = auditLossFromBuffer(cleanBuf, { source: 'clean.pptx' })
|
|
173
|
+
check('total === 0 且 clean === true', clean.total === 0 && clean.clean === true, JSON.stringify(clean.counts))
|
|
174
|
+
check('7 个类别齐全、顺序固定、计数全 0', JSON.stringify(clean.categories.map((c) => c.id)) === JSON.stringify(CATEGORY_IDS) && CATEGORY_IDS.every((id) => cat(clean, id).count === 0))
|
|
175
|
+
check('空 <a:effectLst/>(页面 + 主题 effectStyleLst×3)不计为阴影', cat(clean, 'effect').count === 0 && !clean.categories.some((c) => c.evidence.some((e) => /theme/.test(e.part))))
|
|
176
|
+
check('干净源稿报告文案 = 一行"未发现",且不出现手术建议', lossAuditLine(clean).includes('未发现') && !lossAuditLine(clean).includes('ppt_splice'), lossAuditLine(clean))
|
|
177
|
+
|
|
178
|
+
section('B. 正例:含动画/阴影/超链接/切换/SmartArt/不可嵌入媒体/渐变 → 类别与计数正确')
|
|
179
|
+
const dirty = auditLossFromBuffer(dirtyBuf, { source: 'dirty.pptx' })
|
|
180
|
+
const totalExpected = Object.values(EXPECT_DIRTY).reduce((a, b) => a + b, 0)
|
|
181
|
+
check(`total === ${totalExpected};visibleTotal === ${totalExpected - 1}(1 处主题样式表渐变未被引用)`,
|
|
182
|
+
dirty.total === totalExpected && dirty.visibleTotal === totalExpected - 1 && dirty.themeStyleOnly === false,
|
|
183
|
+
`total=${dirty.total} visibleTotal=${dirty.visibleTotal}`)
|
|
184
|
+
for (const [id, n] of Object.entries(EXPECT_DIRTY)) {
|
|
185
|
+
check(`${id} === ${n}(visible ${EXPECT_DIRTY_VISIBLE[id]})`, cat(dirty, id).count === n && cat(dirty, id).visible === EXPECT_DIRTY_VISIBLE[id], `实际 count=${cat(dirty, id).count} visible=${cat(dirty, id).visible}:${JSON.stringify(cat(dirty, id).evidence.map((e) => e.detail))}`)
|
|
186
|
+
}
|
|
187
|
+
check('动画证据带页码 1/2(页码级)', JSON.stringify(cat(dirty, 'animation').evidence.map((e) => e.page)) === '[1,2]', JSON.stringify(cat(dirty, 'animation').evidence))
|
|
188
|
+
check('阴影证据 = 元素级:outerShdw+glow 容器 1 处 + glow 容器 1 处 + 裸 prstShdw 1 处', JSON.stringify(cat(dirty, 'effect').evidence.map((e) => e.element)) === '["Card 1","Card 2","ShadowCard"]', JSON.stringify(cat(dirty, 'effect').evidence.map((e) => [e.element, e.kinds])))
|
|
189
|
+
check('theme 部件不出现在阴影证据里(主题 effectStyleLst 不计)', !cat(dirty, 'effect').evidence.some((e) => /theme/.test(e.part)))
|
|
190
|
+
check('超链接分辨 click ×2 + hover ×1', JSON.stringify(cat(dirty, 'hyperlink').evidence.map((e) => e.subtype)) === '["click","click","hover"]', JSON.stringify(cat(dirty, 'hyperlink').evidence.map((e) => e.subtype)))
|
|
191
|
+
check('页切换带类型(p:fade)', cat(dirty, 'transition').evidence[0]?.type === 'fade' && cat(dirty, 'transition').evidence[0]?.page === 1)
|
|
192
|
+
check('SmartArt 记 1 处(2 个 dgm 部件不放大)且证据含 SmartArt 元素名', cat(dirty, 'smartart').evidence[0]?.element === 'SmartArt 1' && /2 个 SmartArt 图部件/.test(cat(dirty, 'smartart').evidence[0]?.detail ?? ''), JSON.stringify(cat(dirty, 'smartart').evidence))
|
|
193
|
+
check('不可嵌入媒体 4 处:emf / mp4 / OLE 部件 / 幻灯片内 videoFile(photo.png 不计)', JSON.stringify(cat(dirty, 'media').evidence.map((e) => e.ext).sort()) === JSON.stringify(['bin', 'emf', 'mp4', 'video']), JSON.stringify(cat(dirty, 'media').evidence.map((e) => [e.ext, e.reason])))
|
|
194
|
+
check('渐变 3 处:p:bg 背景 1(visible)+ 主题 2(bgRef 引用的 visible / fillStyleLst 无引用的不 visible)——形状渐变不计(包内 gradFill 实为 4)',
|
|
195
|
+
JSON.stringify(cat(dirty, 'gradient').evidence.map((e) => [e.scope, e.visible])) === JSON.stringify([['background', true], ['theme', true], ['theme', false]])
|
|
196
|
+
&& (String(dirtyEntries['ppt/slides/slide1.xml']).match(/<a:gradFill/g) ?? []).length === 1
|
|
197
|
+
&& (String(dirtyEntries['ppt/slides/slide2.xml']).match(/<a:gradFill/g) ?? []).length === 1
|
|
198
|
+
&& (String(dirtyEntries['ppt/theme/theme1.xml']).match(/<a:gradFill/g) ?? []).length === 2
|
|
199
|
+
&& JSON.stringify(dirty.themeRefs) === JSON.stringify({ bgRef: [1001], fillRef: [] })
|
|
200
|
+
&& cat(dirty, 'gradient').evidence.every((e) => e.tag === 'gradFill' && e.count === 2),
|
|
201
|
+
JSON.stringify(cat(dirty, 'gradient').evidence))
|
|
202
|
+
check('备注母版里的 fillRef 不算"可见引用"(备注页不参与放映观感)', !cat(dirty, 'gradient').evidence.some((e) => e.holder === 'fillStyleLst' && e.visible === true), JSON.stringify(dirty.themeRefs))
|
|
203
|
+
check('每类 count === evidence.length;hitSummary 与命中类别一致(含 visible)', dirty.categories.every((c) => c.count === c.evidence.length) && JSON.stringify(dirty.hitSummary.map((h) => h.id)) === JSON.stringify(CATEGORY_IDS.filter((id) => EXPECT_DIRTY[id] > 0)) && dirty.hitSummary.every((h) => h.count >= h.visible && h.visible >= 0))
|
|
204
|
+
check('source 元数据写入报告', dirty.source === 'dirty.pptx' && clean.source === 'clean.pptx')
|
|
205
|
+
const line = lossAuditLine(dirty)
|
|
206
|
+
check('报告文案含分类计数 + 未引用渐变的限定语 + 就地手术建议', ['动画 2 处', '阴影/发光 3 处', '超链接 3 处', '主题/背景渐变 2 处', '另有 1 处主题样式表渐变未被引用', 'ppt_splice', LOSS_AUDIT_FILE].every((s) => line.includes(s)), line)
|
|
207
|
+
check('JSON 可序列化且往返稳定(写盘格式)', JSON.stringify(JSON.parse(JSON.stringify(dirty))) === JSON.stringify(dirty))
|
|
208
|
+
check('输入形态无关:Map / 普通对象 / [name,data] 数组计数一致',
|
|
209
|
+
JSON.stringify(auditLoss(zipRead(dirtyBuf)).counts) === JSON.stringify(dirty.counts)
|
|
210
|
+
&& JSON.stringify(auditLoss(dirtyEntries).counts) === JSON.stringify(dirty.counts)
|
|
211
|
+
&& JSON.stringify(auditLoss(Object.entries(dirtyEntries)).counts) === JSON.stringify(dirty.counts))
|
|
212
|
+
|
|
213
|
+
section('D. 主题样式表渐变专用 deck(python-pptx 默认主题形态)→ 不谎报"会丢可见效果"')
|
|
214
|
+
const themeOnly = auditLossFromBuffer(zipWrite(themeOnlyPptx()))
|
|
215
|
+
const themeLine = lossAuditLine(themeOnly)
|
|
216
|
+
check('total 2 / visibleTotal 0 / themeStyleOnly true(bgFillStyleLst + fillStyleLst 均无人引用)',
|
|
217
|
+
themeOnly.total === 2 && themeOnly.visibleTotal === 0 && themeOnly.themeStyleOnly === true && themeOnly.categories.every((c) => c.id !== 'gradient' || c.evidence.every((e) => e.visible === false)),
|
|
218
|
+
`total=${themeOnly.total} visible=${themeOnly.visibleTotal}`)
|
|
219
|
+
check('报告降级为 ℹ(不含"会丢这些"的断言,仍给 ppt_splice 指引)',
|
|
220
|
+
themeLine.startsWith('ℹ') && themeLine.includes('未发现会丢的可见效果') && themeLine.includes('不影响观感') && themeLine.includes('ppt_splice'), themeLine)
|
|
221
|
+
check('advice 同步降级(不写"整册重渲会丢上述 N 处可见特性")', themeOnly.advice.includes('未发现会丢的可见效果'), themeOnly.advice)
|
|
222
|
+
|
|
223
|
+
section('C. 集成:ppt_import 落盘 loss-audit.json + 一行结论(既有产物无回归)')
|
|
224
|
+
const cleanFile = join(work, 'clean.pptx')
|
|
225
|
+
const dirtyFile = join(work, 'dirty.pptx')
|
|
226
|
+
await writeFile(cleanFile, cleanBuf)
|
|
227
|
+
await writeFile(dirtyFile, dirtyBuf)
|
|
228
|
+
const cleanOut = join(work, 'out-clean')
|
|
229
|
+
const dirtyOut = join(work, 'out-dirty')
|
|
230
|
+
const rClean = await importPptx(cleanFile, cleanOut)
|
|
231
|
+
const rDirty = await importPptx(dirtyFile, dirtyOut)
|
|
232
|
+
const cleanJson = JSON.parse(await readFile(join(cleanOut, LOSS_AUDIT_FILE), 'utf8'))
|
|
233
|
+
const dirtyJson = JSON.parse(await readFile(join(dirtyOut, LOSS_AUDIT_FILE), 'utf8'))
|
|
234
|
+
check(`干净源稿:${LOSS_AUDIT_FILE} 落盘且 clean=true;工具输出行含"未发现"`, cleanJson.clean === true && cleanJson.total === 0 && rClean.lossLine.includes('未发现'), rClean.lossLine)
|
|
235
|
+
check('脏源稿:JSON 计数与纯函数一致;返回 lossLine 含分类计数 + ppt_splice', JSON.stringify(dirtyJson.counts) === JSON.stringify(EXPECT_DIRTY) && rDirty.lossLine === lossAuditLine(dirtyJson) && rDirty.lossLine.includes('ppt_splice'), rDirty.lossLine)
|
|
236
|
+
check('既有产物无回归(deck.yaml / pages / import-styles.json 仍在)',
|
|
237
|
+
existsSync(join(dirtyOut, 'deck.yaml')) && existsSync(join(dirtyOut, 'pages', 'slide_01.yaml')) && existsSync(join(dirtyOut, 'import-styles.json')))
|
|
238
|
+
check('importPptx 返回结构仍含 pages/media/warnings(未破坏既有消费者)',
|
|
239
|
+
rDirty.pages === 2 && Array.isArray(rDirty.media) && Array.isArray(rDirty.warnings) && typeof rDirty.lossAudit === 'object')
|
|
240
|
+
} finally {
|
|
241
|
+
await rm(work, { recursive: true, force: true }).catch(() => {})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
console.log(`\n${fail === 0 ? 'PASS' : 'FAIL'} ${pass}/${pass + fail} 断言通过${fail ? `(${fail} 条失败)` : ''}`)
|
|
245
|
+
process.exit(fail === 0 ? 0 : 1)
|