pptx2js 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mapper.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ④ 映射引擎 — 实体 → 平台无关 IR
3
+ */
4
+ const { getSlideSizeInches } = require('./presentation');
5
+
6
+ /**
7
+ * @param {import('./extractor').SlideEntity[][]} entitiesBySlide
8
+ * @param {{ parsed: Record<string, object> }} ctx
9
+ */
10
+ function mapToIR(entitiesBySlide, ctx) {
11
+ const layout = getSlideSizeInches(ctx.parsed);
12
+
13
+ const slides = entitiesBySlide.map((entities, index) => {
14
+ const elements = [];
15
+ let background = null;
16
+
17
+ for (const entity of entities) {
18
+ if (entity.kind === 'background' && entity.shape) {
19
+ background = { color: entity.shape.fill, decision: entity.decision };
20
+ continue;
21
+ }
22
+
23
+ if (entity.kind === 'skip') {
24
+ elements.push({
25
+ type: 'skip',
26
+ decision: entity.decision,
27
+ skipReason: entity.skipReason,
28
+ bounds: entity.bounds,
29
+ });
30
+ continue;
31
+ }
32
+
33
+ if (entity.kind === 'text' && entity.text) {
34
+ elements.push({
35
+ type: 'text',
36
+ decision: entity.decision,
37
+ bounds: entity.bounds,
38
+ runs: entity.text.runs,
39
+ degradeReason: entity.degradeReason,
40
+ });
41
+ continue;
42
+ }
43
+
44
+ if (entity.kind === 'image' && entity.image) {
45
+ elements.push({
46
+ type: 'image',
47
+ decision: entity.decision,
48
+ bounds: entity.bounds,
49
+ mediaPath: `media/${entity.image.fileName}`,
50
+ zipPath: entity.image.zipPath,
51
+ degradeReason: entity.degradeReason,
52
+ });
53
+ continue;
54
+ }
55
+
56
+ if (entity.kind === 'table' && entity.table) {
57
+ elements.push({
58
+ type: 'table',
59
+ decision: entity.decision,
60
+ bounds: entity.bounds,
61
+ rows: entity.table.rows,
62
+ colWidths: entity.table.colWidths,
63
+ });
64
+ continue;
65
+ }
66
+
67
+ if (entity.kind === 'chart' && entity.chart) {
68
+ elements.push({
69
+ type: 'chart',
70
+ decision: entity.decision,
71
+ bounds: entity.bounds,
72
+ chartType: entity.chart.type,
73
+ data: entity.chart.data,
74
+ title: entity.chart.title,
75
+ });
76
+ continue;
77
+ }
78
+
79
+ if (entity.kind === 'shape' && entity.shape) {
80
+ elements.push({
81
+ type: 'shape',
82
+ decision: entity.decision,
83
+ bounds: entity.bounds,
84
+ shape: entity.shape.type,
85
+ fill: entity.shape.fill,
86
+ line: entity.shape.line,
87
+ degradeReason: entity.degradeReason,
88
+ });
89
+ }
90
+ }
91
+
92
+ return { index, background, elements };
93
+ });
94
+
95
+ return { layout, slides };
96
+ }
97
+
98
+ module.exports = { mapToIR };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * ⑥ 资源打包器
3
+ * 复制 media/ 文件,写入退化生成图片。
4
+ * TODO: 重名冲突处理(不同 zip 路径、同名文件时避免覆盖)
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * @typedef {object} PackagerInput
11
+ * @property {import('./unpacker').PptxArchive} archive
12
+ * @property {import('./mapper').IntermediateRepresentation} ir
13
+ */
14
+
15
+ /**
16
+ * @param {PackagerInput} input
17
+ * @param {string} outputDir
18
+ * @param {{ noMedia?: boolean }} options
19
+ * @returns {Promise<string[]>} 已写入的媒体文件相对路径列表
20
+ */
21
+ async function packageMedia(input, outputDir, options = {}) {
22
+ if (options.noMedia) return [];
23
+
24
+ const mediaDir = path.join(outputDir, 'media');
25
+ fs.mkdirSync(mediaDir, { recursive: true });
26
+
27
+ const written = [];
28
+ const { zip } = input.archive;
29
+ for (const [entryPath, entry] of Object.entries(zip.files)) {
30
+ if (entry.dir) continue;
31
+ if (!entryPath.startsWith('ppt/media/')) continue;
32
+ const name = path.basename(entryPath); // TODO: 重名冲突时生成唯一文件名
33
+ const dest = path.join(mediaDir, name);
34
+ const data = await entry.async('nodebuffer');
35
+ fs.writeFileSync(dest, data);
36
+ written.push(path.join('media', name));
37
+ }
38
+ return written;
39
+ }
40
+
41
+ /**
42
+ * @param {string} outputDir
43
+ * @param {object} log
44
+ */
45
+ function writeConversionLog(outputDir, log) {
46
+ const logPath = path.join(outputDir, 'conversion.log');
47
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2), 'utf8');
48
+ }
49
+
50
+ /**
51
+ * @param {string} outputDir
52
+ * @param {object} meta
53
+ */
54
+ function writeOutputReadme(outputDir, meta) {
55
+ const readmePath = path.join(outputDir, 'README.md');
56
+ const content = [
57
+ '# pptx2js 转换输出',
58
+ '',
59
+ `源文件:\`${meta.sourcePath}\``,
60
+ `生成时间:${meta.generatedAt}`,
61
+ '',
62
+ '## 文件说明',
63
+ '',
64
+ '- `output.js` — 可直接 `node output.js` 运行的 PptxGenJS 脚本',
65
+ '- `media/` — 提取的图片等媒体资源',
66
+ '- `conversion.log` — JSON 格式转换报告',
67
+ '',
68
+ ].join('\n');
69
+ fs.writeFileSync(readmePath, content, 'utf8');
70
+ }
71
+
72
+ module.exports = { packageMedia, writeConversionLog, writeOutputReadme };
@@ -0,0 +1,258 @@
1
+ /**
2
+ * 母版 / 版式占位符继承(design.html §4.1)
3
+ * 优先级:slide → slideLayout → slideMaster
4
+ */
5
+ const { asArray, attr, child } = require('./xml-utils');
6
+
7
+ const REL_SLIDE_LAYOUT =
8
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout';
9
+ const REL_SLIDE_MASTER =
10
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster';
11
+
12
+ /**
13
+ * @typedef {object} PlaceholderKey
14
+ * @property {string|undefined} idx
15
+ * @property {string|undefined} type
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} SlideInheritance
20
+ * @property {string} slidePath
21
+ * @property {string|null} layoutPath
22
+ * @property {string|null} masterPath
23
+ * @property {object|null} layoutSpTree
24
+ * @property {object|null} masterSpTree
25
+ */
26
+
27
+ /**
28
+ * @param {Record<string, object>} parsed
29
+ * @param {import('./rels').RelationIndex} relIndex
30
+ * @param {string} slidePath
31
+ * @returns {SlideInheritance}
32
+ */
33
+ function buildSlideInheritance(parsed, relIndex, slidePath) {
34
+ const layoutPath = resolveRelByType(relIndex, slidePath, REL_SLIDE_LAYOUT);
35
+ const masterPath = layoutPath
36
+ ? resolveRelByType(relIndex, layoutPath, REL_SLIDE_MASTER)
37
+ : null;
38
+
39
+ const layoutSpTree = layoutPath ? getSpTree(parsed, layoutPath) : null;
40
+ const masterSpTree = masterPath ? getSpTree(parsed, masterPath) : null;
41
+
42
+ return {
43
+ slidePath,
44
+ layoutPath,
45
+ masterPath,
46
+ layoutSpTree,
47
+ masterSpTree,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {import('./rels').RelationIndex} relIndex
53
+ * @param {string} sourcePath
54
+ * @param {string} typeFragment
55
+ * @returns {string|null}
56
+ */
57
+ function resolveRelByType(relIndex, sourcePath, typeFragment) {
58
+ const rel = relIndex
59
+ .list(sourcePath)
60
+ .find((r) => r.type && r.type.includes(typeFragment));
61
+ return rel?.target ?? null;
62
+ }
63
+
64
+ /**
65
+ * @param {Record<string, object>} parsed
66
+ * @param {string} partPath slide / slideLayout / slideMaster
67
+ * @returns {object|null}
68
+ */
69
+ function getSpTree(parsed, partPath) {
70
+ const doc = parsed[partPath];
71
+ if (!doc) return null;
72
+ const root =
73
+ child(doc, 'p:sld') ??
74
+ child(doc, 'p:sldLayout') ??
75
+ child(doc, 'p:sldMaster');
76
+ if (!root) return null;
77
+ return child(child(root, 'p:cSld'), 'p:spTree');
78
+ }
79
+
80
+ /**
81
+ * @param {object} sp
82
+ * @returns {PlaceholderKey|null}
83
+ */
84
+ function getPlaceholderKey(sp) {
85
+ const ph = child(child(child(sp, 'p:nvSpPr'), 'p:nvPr'), 'p:ph');
86
+ if (!ph) return null;
87
+ return {
88
+ idx: attr(ph, 'idx'),
89
+ type: attr(ph, 'type'),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * @param {object|null} spTree
95
+ * @param {PlaceholderKey} key
96
+ * @returns {object|null}
97
+ */
98
+ function findSpByPlaceholder(spTree, key) {
99
+ if (!spTree || !key) return null;
100
+
101
+ for (const sp of walkAllSp(spTree)) {
102
+ const ph = getPlaceholderKey(sp);
103
+ if (!ph) continue;
104
+ if (key.idx != null && ph.idx === key.idx) return sp;
105
+ if (key.type && ph.type === key.type) return sp;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /**
111
+ * @param {object} spTree
112
+ * @returns {object[]}
113
+ */
114
+ function* walkAllSp(spTree) {
115
+ for (const sp of asArray(spTree['p:sp'])) {
116
+ yield sp;
117
+ }
118
+ for (const grp of asArray(spTree['p:grpSp'])) {
119
+ const inner = child(grp, 'p:spTree');
120
+ if (inner) yield* walkAllSp(inner);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {object} slideSp
126
+ * @param {SlideInheritance} inheritance
127
+ * @returns {{ layoutSp: object|null, masterSp: object|null }}
128
+ */
129
+ function resolvePlaceholderSps(slideSp, inheritance) {
130
+ const key = getPlaceholderKey(slideSp);
131
+ if (!key) {
132
+ return { layoutSp: null, masterSp: null };
133
+ }
134
+
135
+ const layoutSp = inheritance.layoutSpTree
136
+ ? findSpByPlaceholder(inheritance.layoutSpTree, key)
137
+ : null;
138
+ const masterSp = inheritance.masterSpTree
139
+ ? findSpByPlaceholder(inheritance.masterSpTree, key)
140
+ : null;
141
+
142
+ return { layoutSp, masterSp };
143
+ }
144
+
145
+ /**
146
+ * 合并 a:xfrm:off 与 ext 各属性独立按 slide → layout → master 继承
147
+ * @param {object|null|undefined} slideXfrm
148
+ * @param {object|null|undefined} layoutXfrm
149
+ * @param {object|null|undefined} masterXfrm
150
+ * @returns {object|null}
151
+ */
152
+ function mergeXfrm(slideXfrm, layoutXfrm, masterXfrm) {
153
+ const layers = [slideXfrm, layoutXfrm, masterXfrm];
154
+ const off = mergeXfrmPart(
155
+ layers.map((x) => child(x, 'a:off')),
156
+ ['x', 'y']
157
+ );
158
+ const ext = mergeXfrmPart(
159
+ layers.map((x) => child(x, 'a:ext')),
160
+ ['cx', 'cy'],
161
+ { skipZero: true }
162
+ );
163
+
164
+ if (!off && !ext) return null;
165
+ return {
166
+ 'a:off': off ?? { $: { x: '0', y: '0' } },
167
+ 'a:ext': ext ?? { $: { cx: '0', cy: '0' } },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * @param {(object|null|undefined)[]} parts
173
+ * @param {string[]} attrNames
174
+ * @param {{ skipZero?: boolean }} [opts]
175
+ * @returns {object|null}
176
+ */
177
+ function mergeXfrmPart(parts, attrNames, opts = {}) {
178
+ const values = {};
179
+ let hasAny = false;
180
+
181
+ for (const name of attrNames) {
182
+ for (const part of parts) {
183
+ if (!part) continue;
184
+ const v = attr(part, name);
185
+ if (v == null || v === '') continue;
186
+ if (opts.skipZero && (name === 'cx' || name === 'cy')) {
187
+ if (parseInt(v, 10) <= 0) continue;
188
+ }
189
+ values[name] = v;
190
+ hasAny = true;
191
+ break;
192
+ }
193
+ }
194
+
195
+ if (!hasAny) return null;
196
+ return { $: values };
197
+ }
198
+
199
+ /**
200
+ * @param {object} slideSp
201
+ * @param {SlideInheritance} inheritance
202
+ * @returns {object|null}
203
+ */
204
+ function getEffectiveXfrm(slideSp, inheritance) {
205
+ const slideSpPr = child(slideSp, 'p:spPr');
206
+ const { layoutSp, masterSp } = resolvePlaceholderSps(slideSp, inheritance);
207
+
208
+ return mergeXfrm(
209
+ child(slideSpPr, 'a:xfrm'),
210
+ layoutSp && child(child(layoutSp, 'p:spPr'), 'a:xfrm'),
211
+ masterSp && child(child(masterSp, 'p:spPr'), 'a:xfrm')
212
+ );
213
+ }
214
+
215
+ /**
216
+ * 合并 txBody 默认样式:用 layout/master 的首段/首 run 补全 slide 层缺失的 rPr
217
+ * @param {object} slideTxBody
218
+ * @param {object|null} layoutSp
219
+ * @param {object|null} masterSp
220
+ * @returns {object}
221
+ */
222
+ function mergeTxBody(slideTxBody, layoutSp, masterSp) {
223
+ const slideBody = slideTxBody;
224
+ const fallbackBody =
225
+ (layoutSp && child(layoutSp, 'p:txBody')) ||
226
+ (masterSp && child(masterSp, 'p:txBody'));
227
+ if (!fallbackBody) return slideBody;
228
+
229
+ const slideParas = asArray(slideBody['a:p']);
230
+ const fallbackPara = asArray(fallbackBody['a:p'])[0];
231
+ const fallbackRPr = fallbackPara && child(asArray(fallbackPara['a:r'])[0], 'a:rPr');
232
+
233
+ if (!fallbackRPr) return slideBody;
234
+
235
+ const mergedParas = slideParas.map((p) => {
236
+ const runs = asArray(p['a:r']).map((r) => {
237
+ const rPr = child(r, 'a:rPr');
238
+ if (rPr) return r;
239
+ return { ...r, 'a:rPr': JSON.parse(JSON.stringify(fallbackRPr)) };
240
+ });
241
+ return { ...p, 'a:r': runs.length === 1 ? runs[0] : runs };
242
+ });
243
+
244
+ return { ...slideBody, 'a:p': mergedParas.length === 1 ? mergedParas[0] : mergedParas };
245
+ }
246
+
247
+ module.exports = {
248
+ REL_SLIDE_LAYOUT,
249
+ REL_SLIDE_MASTER,
250
+ buildSlideInheritance,
251
+ getPlaceholderKey,
252
+ findSpByPlaceholder,
253
+ resolvePlaceholderSps,
254
+ mergeXfrm,
255
+ getEffectiveXfrm,
256
+ mergeTxBody,
257
+ getSpTree,
258
+ };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * presentation.xml:幻灯片列表与版面尺寸
3
+ */
4
+ const { asArray, attr, child } = require('./xml-utils');
5
+ const { emuToInch } = require('./utils/emu');
6
+
7
+ const PRESENTATION_PATH = 'ppt/presentation.xml';
8
+
9
+ /**
10
+ * @param {Record<string, object>} parsed
11
+ * @param {import('./rels').RelationIndex} relIndex
12
+ * @returns {string[]}
13
+ */
14
+ function getSlidePaths(parsed, relIndex) {
15
+ const doc = parsed[PRESENTATION_PATH];
16
+ const pres = child(doc, 'p:presentation');
17
+ if (!pres) return [];
18
+
19
+ const sldIds = asArray(child(child(pres, 'p:sldIdLst'), 'p:sldId'));
20
+ const paths = [];
21
+ for (const sldId of sldIds) {
22
+ const relId = attr(sldId, 'r:id');
23
+ if (!relId) continue;
24
+ const target = relIndex.resolve(PRESENTATION_PATH, relId);
25
+ if (target) paths.push(target);
26
+ }
27
+ return paths;
28
+ }
29
+
30
+ /**
31
+ * @param {Record<string, object>} parsed
32
+ * @returns {{ width: number, height: number }}
33
+ */
34
+ function getSlideSizeInches(parsed) {
35
+ const pres = child(parsed[PRESENTATION_PATH], 'p:presentation');
36
+ const sldSz = child(pres, 'p:sldSz');
37
+ const cx = parseInt(attr(sldSz, 'cx') ?? '9144000', 10);
38
+ const cy = parseInt(attr(sldSz, 'cy') ?? '6858000', 10);
39
+ return {
40
+ width: round3(emuToInch(cx)),
41
+ height: round3(emuToInch(cy)),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * @param {Record<string, object>} parsed
47
+ * @param {import('./rels').RelationIndex} relIndex
48
+ * @returns {string|null}
49
+ */
50
+ function getThemePath(parsed, relIndex) {
51
+ const themeRel = relIndex
52
+ .list(PRESENTATION_PATH)
53
+ .find((r) => r.type && r.type.includes('/relationships/theme'));
54
+ return themeRel?.target ?? null;
55
+ }
56
+
57
+ /**
58
+ * @param {number} n
59
+ * @returns {number}
60
+ */
61
+ function round3(n) {
62
+ return Math.round(n * 1000) / 1000;
63
+ }
64
+
65
+ module.exports = {
66
+ PRESENTATION_PATH,
67
+ getSlidePaths,
68
+ getSlideSizeInches,
69
+ getThemePath,
70
+ };
package/lib/rels.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * OOXML 关系(.rels)解析与路径解析
3
+ */
4
+ const path = require('path');
5
+ const { asArray, attr } = require('./xml-utils');
6
+
7
+ /**
8
+ * @typedef {object} Relationship
9
+ * @property {string} id
10
+ * @property {string} type
11
+ * @property {string} target
12
+ */
13
+
14
+ /**
15
+ * @typedef {object} RelationIndex
16
+ * @property {(sourcePath: string, relId: string) => string|null} resolve
17
+ * @property {(sourcePath: string) => Relationship[]} list
18
+ */
19
+
20
+ /**
21
+ * .rels 文件路径 → 所属部件路径
22
+ * @param {string} relsPath 如 ppt/slides/_rels/slide1.xml.rels
23
+ * @returns {string}
24
+ */
25
+ function relsOwnerPath(relsPath) {
26
+ const normalized = relsPath.replace(/\\/g, '/');
27
+ // 包根关系:_rels/.rels → owner 为包根(空路径)
28
+ if (normalized === '_rels/.rels') {
29
+ return '';
30
+ }
31
+
32
+ const parts = normalized.split('/');
33
+ const relFile = parts.pop();
34
+ parts.pop(); // _rels
35
+ const ownerFile = relFile.replace(/\.rels$/i, '');
36
+ parts.push(ownerFile);
37
+ return parts.join('/');
38
+ }
39
+
40
+ /**
41
+ * @param {string} ownerPath
42
+ * @param {string} target
43
+ * @returns {string}
44
+ */
45
+ /**
46
+ * @param {Relationship} rel
47
+ */
48
+ function isExternalTarget(rel) {
49
+ if (rel.targetMode === 'External') return true;
50
+ return /^(https?|mailto|ftp):/i.test(rel.target);
51
+ }
52
+
53
+ function resolveTargetPath(ownerPath, target) {
54
+ if (!ownerPath) {
55
+ return path.posix.normalize(target);
56
+ }
57
+ const ownerDir = path.posix.dirname(ownerPath);
58
+ return path.posix.normalize(path.posix.join(ownerDir, target));
59
+ }
60
+
61
+ /**
62
+ * @param {object} relsDoc xml2js 解析结果
63
+ * @returns {Relationship[]}
64
+ */
65
+ function parseRelationships(relsDoc) {
66
+ const root = relsDoc?.Relationships ?? relsDoc;
67
+ const rels = asArray(root?.Relationship);
68
+ return rels
69
+ .map((rel) => ({
70
+ id: attr(rel, 'Id'),
71
+ type: attr(rel, 'Type'),
72
+ target: attr(rel, 'Target'),
73
+ targetMode: attr(rel, 'TargetMode'),
74
+ }))
75
+ .filter((r) => r.id && r.target);
76
+ }
77
+
78
+ /**
79
+ * @param {Record<string, object>} parsed
80
+ * @returns {RelationIndex}
81
+ */
82
+ function buildRelationIndex(parsed) {
83
+ /** @type {Map<string, Map<string, Relationship>>} */
84
+ const bySource = new Map();
85
+
86
+ for (const [filePath, doc] of Object.entries(parsed)) {
87
+ if (!filePath.endsWith('.rels')) continue;
88
+ const owner = relsOwnerPath(filePath);
89
+ const relationships = parseRelationships(doc);
90
+ const map = new Map();
91
+ for (const rel of relationships) {
92
+ const target = isExternalTarget(rel)
93
+ ? rel.target
94
+ : resolveTargetPath(owner, rel.target);
95
+ map.set(rel.id, { ...rel, target });
96
+ }
97
+ bySource.set(owner, map);
98
+ }
99
+
100
+ return {
101
+ resolve(sourcePath, relId) {
102
+ const rel = bySource.get(sourcePath)?.get(relId);
103
+ return rel?.target ?? null;
104
+ },
105
+ list(sourcePath) {
106
+ const map = bySource.get(sourcePath);
107
+ return map ? Array.from(map.values()) : [];
108
+ },
109
+ };
110
+ }
111
+
112
+ module.exports = {
113
+ relsOwnerPath,
114
+ resolveTargetPath,
115
+ parseRelationships,
116
+ buildRelationIndex,
117
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * SmartArt 退化处理(design.html §4.2)
3
+ */
4
+ const { attr, child, textContent } = require('./xml-utils');
5
+ const { getGraphicXfrm } = require('./graphic');
6
+ const { boundsFromXfrm } = require('./utils/bounds');
7
+
8
+ /**
9
+ * @param {object} graphicFrame
10
+ * @param {object} ctx
11
+ * @returns {import('./extractor').SlideEntity}
12
+ */
13
+ function extractSmartArt(graphicFrame, ctx) {
14
+ const bounds = boundsFromXfrm(getGraphicXfrm(graphicFrame), ctx.offset);
15
+
16
+ // 层① 缓存图片:SmartArt 缓存图片的 rels 结构因 PPT 版本差异较大,暂不实现
17
+ // 直接走层② 文本提取
18
+
19
+ const texts = extractSmartArtTexts(graphicFrame, ctx);
20
+ if (texts.length) {
21
+ return {
22
+ slideIndex: ctx.slideIndex,
23
+ slidePath: ctx.slidePath,
24
+ decision: 'DEGRADE',
25
+ kind: 'text',
26
+ bounds,
27
+ text: {
28
+ runs: texts.map((t, i) => ({
29
+ text: `${i + 1}. ${t}`,
30
+ options: { fontSize: 12 },
31
+ })),
32
+ },
33
+ degradeReason: 'SmartArt 退化为文本列表',
34
+ };
35
+ }
36
+
37
+ return {
38
+ slideIndex: ctx.slideIndex,
39
+ slidePath: ctx.slidePath,
40
+ decision: 'DEGRADE',
41
+ kind: 'text',
42
+ bounds,
43
+ text: {
44
+ runs: [
45
+ {
46
+ text: '[SmartArt 无法解析]',
47
+ options: { fontSize: 12, color: '888888' },
48
+ },
49
+ ],
50
+ },
51
+ degradeReason: 'SmartArt 无可提取文本',
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param {object} graphicFrame
57
+ * @param {object} ctx
58
+ * @returns {string[]}
59
+ */
60
+ function extractSmartArtTexts(graphicFrame, ctx) {
61
+ const graphicData = child(child(graphicFrame, 'a:graphic'), 'a:graphicData');
62
+ const relIds = child(graphicData, 'dgm:relIds');
63
+ const dgmRelId = relIds && (attr(relIds, 'r:dm') ?? attr(relIds, 'dm'));
64
+ const dataPath = dgmRelId && ctx.relIndex.resolve(ctx.slidePath, dgmRelId);
65
+ if (!dataPath || !ctx.parsed[dataPath]) return [];
66
+
67
+ const texts = [];
68
+ collectTextNodes(ctx.parsed[dataPath], texts);
69
+ return [...new Set(texts)];
70
+ }
71
+
72
+ /**
73
+ * @param {object} node
74
+ * @param {string[]} out
75
+ */
76
+ function collectTextNodes(node, out) {
77
+ if (node == null) return;
78
+ if (typeof node === 'string') return;
79
+ if (typeof node !== 'object') return;
80
+
81
+ if (node['a:t'] != null) {
82
+ const t = textContent(node['a:t']);
83
+ if (t) out.push(t);
84
+ }
85
+
86
+ for (const [key, val] of Object.entries(node)) {
87
+ if (key === '$') continue;
88
+ if (Array.isArray(val)) {
89
+ for (const item of val) collectTextNodes(item, out);
90
+ } else if (val && typeof val === 'object') {
91
+ collectTextNodes(val, out);
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = { extractSmartArt, extractSmartArtTexts };