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/README.html +804 -0
- package/README.md +201 -0
- package/bin/pptx2js.js +75 -0
- package/lib/chart.js +245 -0
- package/lib/codegen.js +172 -0
- package/lib/convert.js +195 -0
- package/lib/extractor.js +394 -0
- package/lib/graphic.js +63 -0
- package/lib/index.js +7 -0
- package/lib/mapper.js +98 -0
- package/lib/packager.js +72 -0
- package/lib/placeholder.js +258 -0
- package/lib/presentation.js +70 -0
- package/lib/rels.js +117 -0
- package/lib/smartart.js +96 -0
- package/lib/table.js +138 -0
- package/lib/unpacker.js +40 -0
- package/lib/utils/bounds.js +34 -0
- package/lib/utils/color.js +128 -0
- package/lib/utils/emu.js +20 -0
- package/lib/xml-parser.js +25 -0
- package/lib/xml-utils.js +68 -0
- package/package.json +41 -0
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 };
|
package/lib/packager.js
ADDED
|
@@ -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
|
+
};
|
package/lib/smartart.js
ADDED
|
@@ -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 };
|