pptx2js 0.4.0 → 0.4.1
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/chart.js +52 -14
- package/lib/codegen.js +63 -7
- package/lib/extractor.js +70 -16
- package/lib/mapper.js +1 -0
- package/lib/rels.js +15 -2
- package/lib/run-utils.js +179 -0
- package/lib/table.js +28 -22
- package/package.json +3 -2
package/lib/chart.js
CHANGED
|
@@ -48,6 +48,7 @@ function extractChart(graphicFrame, ctx) {
|
|
|
48
48
|
|
|
49
49
|
const parsed = parseChartPlotArea(plotArea);
|
|
50
50
|
if (parsed) {
|
|
51
|
+
parsed.title = extractChartTitle(chartRoot);
|
|
51
52
|
return {
|
|
52
53
|
slideIndex: ctx.slideIndex,
|
|
53
54
|
slidePath: ctx.slidePath,
|
|
@@ -74,11 +75,10 @@ function parseChartPlotArea(plotArea) {
|
|
|
74
75
|
const series = extractSeries(chartNode, plotArea);
|
|
75
76
|
if (!series.length) continue;
|
|
76
77
|
|
|
77
|
-
const title = extractChartTitle(plotArea);
|
|
78
78
|
return {
|
|
79
79
|
type: pptxType,
|
|
80
80
|
data: series,
|
|
81
|
-
title,
|
|
81
|
+
title: '',
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
return null;
|
|
@@ -94,7 +94,7 @@ function extractSeries(chartNode, plotArea) {
|
|
|
94
94
|
|
|
95
95
|
for (const ser of seriesNodes) {
|
|
96
96
|
const titleText = extractSeriesName(ser);
|
|
97
|
-
const cat = extractCategoryLabels(ser
|
|
97
|
+
const cat = extractCategoryLabels(ser);
|
|
98
98
|
const values = extractSeriesValues(ser, plotArea);
|
|
99
99
|
if (!values.length) continue;
|
|
100
100
|
|
|
@@ -119,19 +119,56 @@ function extractSeriesName(ser) {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* @param {object} ser
|
|
122
|
-
* @param {object} plotArea
|
|
123
122
|
*/
|
|
124
|
-
function extractCategoryLabels(ser
|
|
125
|
-
void plotArea;
|
|
123
|
+
function extractCategoryLabels(ser) {
|
|
126
124
|
const cat = child(ser, 'c:cat');
|
|
127
125
|
if (!cat) return [];
|
|
126
|
+
|
|
128
127
|
const strRef = child(cat, 'c:strRef');
|
|
128
|
+
if (strRef) {
|
|
129
|
+
const pts = extractCachePoints(strRef, 'c:strCache', 'c:v');
|
|
130
|
+
if (pts.length) return pts;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const multiRef = child(cat, 'c:multiLvlStrRef');
|
|
134
|
+
if (multiRef) {
|
|
135
|
+
const pts = extractMultiLvlStrLabels(multiRef);
|
|
136
|
+
if (pts.length) return pts;
|
|
137
|
+
}
|
|
138
|
+
|
|
129
139
|
const numRef = child(cat, 'c:numRef');
|
|
130
|
-
if (
|
|
131
|
-
|
|
140
|
+
if (numRef) {
|
|
141
|
+
const pts = extractCachePoints(numRef, 'c:numCache', 'c:v').map(String);
|
|
142
|
+
if (pts.length) return pts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const strLit = child(cat, 'c:strLit');
|
|
146
|
+
if (strLit) {
|
|
147
|
+
return extractCachePoints(strLit, null, 'c:v').filter(Boolean);
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
return [];
|
|
133
151
|
}
|
|
134
152
|
|
|
153
|
+
/**
|
|
154
|
+
* PptxGenJS 常用 multiLvlStrRef 缓存分类标签
|
|
155
|
+
* @param {object} multiRef
|
|
156
|
+
*/
|
|
157
|
+
function extractMultiLvlStrLabels(multiRef) {
|
|
158
|
+
const cache = child(multiRef, 'c:multiLvlStrCache');
|
|
159
|
+
if (!cache) return [];
|
|
160
|
+
const lvl = child(cache, 'c:lvl');
|
|
161
|
+
if (!lvl) return [];
|
|
162
|
+
const pts = asArray(lvl['c:pt']);
|
|
163
|
+
return pts
|
|
164
|
+
.sort(
|
|
165
|
+
(a, b) =>
|
|
166
|
+
parseInt(attr(a, 'idx') ?? '0', 10) - parseInt(attr(b, 'idx') ?? '0', 10)
|
|
167
|
+
)
|
|
168
|
+
.map((pt) => textContent(child(pt, 'c:v')))
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
135
172
|
/**
|
|
136
173
|
* @param {object} ser
|
|
137
174
|
* @param {object} plotArea
|
|
@@ -147,22 +184,23 @@ function extractSeriesValues(ser, plotArea) {
|
|
|
147
184
|
|
|
148
185
|
/**
|
|
149
186
|
* @param {object} refNode
|
|
150
|
-
* @param {string} cacheKey
|
|
187
|
+
* @param {string|null} cacheKey null 时直接在 refNode 上找 c:pt(如 strLit)
|
|
151
188
|
* @param {string} valueKey
|
|
152
189
|
*/
|
|
153
190
|
function extractCachePoints(refNode, cacheKey, valueKey) {
|
|
154
|
-
const
|
|
155
|
-
|
|
191
|
+
const container = cacheKey ? child(refNode, cacheKey) : refNode;
|
|
192
|
+
if (!container) return [];
|
|
193
|
+
const pts = asArray(child(container, 'c:pt'));
|
|
156
194
|
return pts
|
|
157
195
|
.sort((a, b) => parseInt(attr(a, 'idx') ?? '0', 10) - parseInt(attr(b, 'idx') ?? '0', 10))
|
|
158
196
|
.map((pt) => textContent(child(pt, valueKey)));
|
|
159
197
|
}
|
|
160
198
|
|
|
161
199
|
/**
|
|
162
|
-
* @param {object}
|
|
200
|
+
* @param {object} chartRoot c:chart
|
|
163
201
|
*/
|
|
164
|
-
function extractChartTitle(
|
|
165
|
-
const title = child(
|
|
202
|
+
function extractChartTitle(chartRoot) {
|
|
203
|
+
const title = child(chartRoot, 'c:title');
|
|
166
204
|
if (!title) return '';
|
|
167
205
|
const tx = child(title, 'c:tx');
|
|
168
206
|
const rich = child(tx, 'c:rich');
|
package/lib/codegen.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ⑤ 代码生成器 — IR → PptxGenJS 脚本
|
|
3
3
|
*/
|
|
4
|
+
const { compressRunOptions } = require('./run-utils');
|
|
4
5
|
|
|
5
6
|
function generateScript(ir, options = {}) {
|
|
6
7
|
const lines = [];
|
|
@@ -80,7 +81,10 @@ function emitElement(el, slideVar, indent, options) {
|
|
|
80
81
|
if (el.type === 'chart') {
|
|
81
82
|
const dataLit = JSON.stringify(el.data);
|
|
82
83
|
const opts = [`${pos}`];
|
|
83
|
-
if (el.title)
|
|
84
|
+
if (el.title) {
|
|
85
|
+
opts.push('showTitle: true');
|
|
86
|
+
opts.push(`title: '${escapeJs(el.title)}'`);
|
|
87
|
+
}
|
|
84
88
|
return [
|
|
85
89
|
`${indent(1)}${slideVar}.addChart(pptx.charts.${el.chartType}, ${dataLit}, { ${opts.join(', ')} });`,
|
|
86
90
|
];
|
|
@@ -90,7 +94,10 @@ function emitElement(el, slideVar, indent, options) {
|
|
|
90
94
|
const shapeRef = `pptx.shapes.${el.shape}`;
|
|
91
95
|
const opts = [pos];
|
|
92
96
|
if (el.fill) opts.push(`fill: { color: '${escapeJs(el.fill)}' }`);
|
|
93
|
-
if (el.line)
|
|
97
|
+
if (el.line) {
|
|
98
|
+
const w = el.lineWidth ?? 1;
|
|
99
|
+
opts.push(`line: { color: '${escapeJs(el.line)}', width: ${w} }`);
|
|
100
|
+
}
|
|
94
101
|
return [`${indent(1)}${slideVar}.addShape(${shapeRef}, { ${opts.join(', ')} });`];
|
|
95
102
|
}
|
|
96
103
|
|
|
@@ -100,10 +107,13 @@ function emitElement(el, slideVar, indent, options) {
|
|
|
100
107
|
function formatTableRows(rows) {
|
|
101
108
|
const mapped = rows.map((row) =>
|
|
102
109
|
row.map((cell) => {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
const textLit = Array.isArray(cell.text)
|
|
111
|
+
? formatTextRuns(cell.text)
|
|
112
|
+
: `'${escapeJs(cell.text ?? '')}'`;
|
|
113
|
+
const parts = [`text: ${textLit}`];
|
|
114
|
+
const opts = compressTableCellOptions(cell.options ?? {});
|
|
115
|
+
if (Object.keys(opts).length) {
|
|
116
|
+
parts.push(`options: ${formatOptionsObject(opts)}`);
|
|
107
117
|
}
|
|
108
118
|
return `{ ${parts.join(', ')} }`;
|
|
109
119
|
})
|
|
@@ -111,6 +121,47 @@ function formatTableRows(rows) {
|
|
|
111
121
|
return `[\n ${mapped.map((r) => `[${r.join(', ')}]`).join(',\n ')}\n ]`;
|
|
112
122
|
}
|
|
113
123
|
|
|
124
|
+
/**
|
|
125
|
+
* @param {object} opts
|
|
126
|
+
*/
|
|
127
|
+
function compressTableCellOptions(opts) {
|
|
128
|
+
const copy = compressRunOptions({ ...opts });
|
|
129
|
+
return copy;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 生成可读的 options 字面量(避免 JSON 双引号风格)
|
|
134
|
+
* @param {object} opts
|
|
135
|
+
*/
|
|
136
|
+
function formatBorderObject(border) {
|
|
137
|
+
if (border.type === 'solid' && border.color) {
|
|
138
|
+
const parts = [`type: 'solid'`, `color: '${escapeJs(border.color)}'`];
|
|
139
|
+
if (border.pt != null) parts.push(`pt: ${border.pt}`);
|
|
140
|
+
return `{ ${parts.join(', ')} }`;
|
|
141
|
+
}
|
|
142
|
+
return JSON.stringify(border);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatOptionsObject(opts) {
|
|
146
|
+
const entries = [];
|
|
147
|
+
for (const [key, val] of Object.entries(opts)) {
|
|
148
|
+
if (key === 'border' && val && typeof val === 'object') {
|
|
149
|
+
entries.push(`border: ${formatBorderObject(val)}`);
|
|
150
|
+
} else if (val === true) {
|
|
151
|
+
entries.push(`${key}: true`);
|
|
152
|
+
} else if (val === false) {
|
|
153
|
+
entries.push(`${key}: false`);
|
|
154
|
+
} else if (typeof val === 'number') {
|
|
155
|
+
entries.push(`${key}: ${val}`);
|
|
156
|
+
} else if (typeof val === 'string') {
|
|
157
|
+
entries.push(`${key}: '${escapeJs(val)}'`);
|
|
158
|
+
} else {
|
|
159
|
+
entries.push(`${key}: ${JSON.stringify(val)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return `{ ${entries.join(', ')} }`;
|
|
163
|
+
}
|
|
164
|
+
|
|
114
165
|
function formatTextRuns(runs) {
|
|
115
166
|
if (!runs?.length) return "''";
|
|
116
167
|
|
|
@@ -131,7 +182,7 @@ function formatTextRuns(runs) {
|
|
|
131
182
|
}
|
|
132
183
|
|
|
133
184
|
function formatRunOptions(options) {
|
|
134
|
-
const copy = { ...options };
|
|
185
|
+
const copy = compressRunOptions({ ...options });
|
|
135
186
|
delete copy._degraded;
|
|
136
187
|
const entries = [];
|
|
137
188
|
if (copy.fontSize != null) entries.push(`fontSize: ${copy.fontSize}`);
|
|
@@ -146,6 +197,11 @@ function formatRunOptions(options) {
|
|
|
146
197
|
if (copy.hyperlink?.url) {
|
|
147
198
|
entries.push(`hyperlink: { url: '${escapeJs(copy.hyperlink.url)}' }`);
|
|
148
199
|
}
|
|
200
|
+
if (copy.underline === true) {
|
|
201
|
+
entries.push('underline: true');
|
|
202
|
+
} else if (copy.underline && typeof copy.underline === 'object') {
|
|
203
|
+
entries.push(`underline: { style: '${escapeJs(copy.underline.style ?? 'sng')}' }`);
|
|
204
|
+
}
|
|
149
205
|
if (copy.align) entries.push(`align: '${escapeJs(String(copy.align))}'`);
|
|
150
206
|
if (copy.indentLevel != null && copy.indentLevel > 0) {
|
|
151
207
|
entries.push(`indentLevel: ${copy.indentLevel}`);
|
package/lib/extractor.js
CHANGED
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
resolveFillColor,
|
|
10
10
|
resolveColorFromContainer,
|
|
11
11
|
} = require('./utils/color');
|
|
12
|
+
const { compressTextRuns } = require('./run-utils');
|
|
12
13
|
const { getSlidePaths, getThemePath } = require('./presentation');
|
|
13
14
|
const {
|
|
14
15
|
buildSlideInheritance,
|
|
@@ -207,6 +208,7 @@ function extractShape(sp, ctx) {
|
|
|
207
208
|
);
|
|
208
209
|
const ln = child(spPr, 'a:ln');
|
|
209
210
|
const lineColor = ln ? resolveFillColor(ln, ctx.scheme).color : null;
|
|
211
|
+
const lineWidth = extractLineWidthPt(ln);
|
|
210
212
|
|
|
211
213
|
if (!shapeName) {
|
|
212
214
|
ctx.entities.push({
|
|
@@ -215,7 +217,7 @@ function extractShape(sp, ctx) {
|
|
|
215
217
|
decision: 'DEGRADE',
|
|
216
218
|
kind: 'shape',
|
|
217
219
|
bounds,
|
|
218
|
-
shape: { type: 'RECTANGLE', fill: fillColor, line: lineColor },
|
|
220
|
+
shape: { type: 'RECTANGLE', fill: fillColor, line: lineColor, lineWidth },
|
|
219
221
|
degradeReason: `未知预设形状 "${prst}",退化为矩形`,
|
|
220
222
|
});
|
|
221
223
|
return;
|
|
@@ -227,11 +229,22 @@ function extractShape(sp, ctx) {
|
|
|
227
229
|
decision: fillDegraded ? 'DEGRADE' : 'FULL',
|
|
228
230
|
kind: 'shape',
|
|
229
231
|
bounds,
|
|
230
|
-
shape: { type: shapeName, fill: fillColor, line: lineColor },
|
|
232
|
+
shape: { type: shapeName, fill: fillColor, line: lineColor, lineWidth },
|
|
231
233
|
degradeReason: fillDegraded ? '渐变填充退化为纯色' : undefined,
|
|
232
234
|
});
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* @param {object|null|undefined} ln a:ln
|
|
239
|
+
* @returns {number|undefined}
|
|
240
|
+
*/
|
|
241
|
+
function extractLineWidthPt(ln) {
|
|
242
|
+
if (!ln) return undefined;
|
|
243
|
+
const w = parseInt(attr(ln, 'w') ?? '0', 10);
|
|
244
|
+
if (!w) return undefined;
|
|
245
|
+
return Math.max(1, Math.round(w / 12700));
|
|
246
|
+
}
|
|
247
|
+
|
|
235
248
|
function extractPicture(pic, ctx) {
|
|
236
249
|
const spPr = child(pic, 'p:spPr');
|
|
237
250
|
const bounds = boundsFromXfrm(child(spPr, 'a:xfrm'), ctx.offset);
|
|
@@ -300,21 +313,56 @@ const PARA_ALIGN_MAP = {
|
|
|
300
313
|
|
|
301
314
|
function extractTextRuns(txBody, scheme, linkCtx) {
|
|
302
315
|
const runs = [];
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
316
|
+
const bodyParaOpts = extractBodyPrParaOptions(txBody);
|
|
317
|
+
const paragraphs = asArray(txBody['a:p']);
|
|
318
|
+
|
|
319
|
+
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
320
|
+
const p = paragraphs[pi];
|
|
321
|
+
// 同一段落内可有多个 a:pPr(PptxGenJS 为每个 run 内联段落属性)→ xml2js 解析为数组
|
|
322
|
+
const pPrs = asArray(p['a:pPr']);
|
|
323
|
+
const pRuns = asArray(p['a:r']);
|
|
324
|
+
const bullet0 = extractBullet(pPrs[0]);
|
|
325
|
+
|
|
326
|
+
pRuns.forEach((r, ri) => {
|
|
327
|
+
const pPr = pPrs[ri] ?? pPrs[0];
|
|
328
|
+
const paraOpts = { ...bodyParaOpts, ...extractParaOptions(pPr) };
|
|
329
|
+
const bullet = extractBullet(pPr) ?? bullet0;
|
|
307
330
|
|
|
308
|
-
for (const r of asArray(p['a:r'])) {
|
|
309
331
|
const text = textContent(r['a:t']);
|
|
310
|
-
if (!text)
|
|
332
|
+
if (!text) return;
|
|
311
333
|
const options = extractRunOptions(r['a:rPr'], scheme, linkCtx);
|
|
312
334
|
Object.assign(options, paraOpts);
|
|
313
335
|
if (bullet) Object.assign(options, bullet);
|
|
314
336
|
runs.push({ text, options, degraded: options._degraded });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
for (const _br of asArray(p['a:br'])) {
|
|
340
|
+
const pPr = pPrs[0];
|
|
341
|
+
const paraOpts = { ...bodyParaOpts, ...extractParaOptions(pPr) };
|
|
342
|
+
runs.push({ text: '\n', options: { ...paraOpts }, degraded: false });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (pi < paragraphs.length - 1) {
|
|
346
|
+
const last = runs[runs.length - 1];
|
|
347
|
+
if (!last || !last.text.endsWith('\n')) {
|
|
348
|
+
const paraOpts = { ...bodyParaOpts, ...extractParaOptions(pPrs[0]) };
|
|
349
|
+
if (bullet0) delete paraOpts.bullet;
|
|
350
|
+
runs.push({ text: '\n', options: { ...paraOpts }, degraded: false });
|
|
351
|
+
}
|
|
315
352
|
}
|
|
316
353
|
}
|
|
317
|
-
return runs;
|
|
354
|
+
return compressTextRuns(runs);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 文本框级默认段落属性(a:lstStyle 在 a:txBody 下,不在 a:bodyPr 下)
|
|
359
|
+
* @param {object|null|undefined} txBody
|
|
360
|
+
*/
|
|
361
|
+
function extractBodyPrParaOptions(txBody) {
|
|
362
|
+
if (!txBody) return {};
|
|
363
|
+
const lstStyle = child(txBody, 'a:lstStyle');
|
|
364
|
+
const defPPr = lstStyle && child(lstStyle, 'a:defPPr');
|
|
365
|
+
return defPPr ? extractParaOptions(defPPr) : {};
|
|
318
366
|
}
|
|
319
367
|
|
|
320
368
|
/**
|
|
@@ -322,24 +370,25 @@ function extractTextRuns(txBody, scheme, linkCtx) {
|
|
|
322
370
|
*/
|
|
323
371
|
function extractParaOptions(pPr) {
|
|
324
372
|
if (!pPr) return {};
|
|
373
|
+
const node = Array.isArray(pPr) ? pPr[0] : pPr;
|
|
325
374
|
const opts = {};
|
|
326
375
|
|
|
327
|
-
const algn = attr(
|
|
376
|
+
const algn = attr(node, 'algn');
|
|
328
377
|
if (algn && PARA_ALIGN_MAP[algn]) opts.align = PARA_ALIGN_MAP[algn];
|
|
329
378
|
|
|
330
|
-
const lvl = attr(
|
|
379
|
+
const lvl = attr(node, 'lvl');
|
|
331
380
|
if (lvl) opts.indentLevel = parseInt(lvl, 10);
|
|
332
381
|
|
|
333
382
|
// 注:a:spcPct(百分比段前/段后距)暂不处理
|
|
334
|
-
const spcBef = attr(child(child(
|
|
383
|
+
const spcBef = attr(child(child(node, 'a:spcBef'), 'a:spcPts'), 'val');
|
|
335
384
|
if (spcBef) opts.paraSpaceBefore = parseInt(spcBef, 10) / 100;
|
|
336
385
|
|
|
337
386
|
// 注:a:spcPct(百分比段后距)暂不处理
|
|
338
|
-
const spcAft = attr(child(child(
|
|
387
|
+
const spcAft = attr(child(child(node, 'a:spcAft'), 'a:spcPts'), 'val');
|
|
339
388
|
if (spcAft) opts.paraSpaceAfter = parseInt(spcAft, 10) / 100;
|
|
340
389
|
|
|
341
390
|
// 注:a:spcPct(百分比行距)暂不处理
|
|
342
|
-
const lnSpc = attr(child(child(
|
|
391
|
+
const lnSpc = attr(child(child(node, 'a:lnSpc'), 'a:spcPts'), 'val');
|
|
343
392
|
if (lnSpc) opts.lineSpacing = parseInt(lnSpc, 10) / 100;
|
|
344
393
|
|
|
345
394
|
return opts;
|
|
@@ -347,8 +396,9 @@ function extractParaOptions(pPr) {
|
|
|
347
396
|
|
|
348
397
|
function extractBullet(pPr) {
|
|
349
398
|
if (!pPr) return null;
|
|
350
|
-
|
|
351
|
-
if (child(
|
|
399
|
+
const node = Array.isArray(pPr) ? pPr[0] : pPr;
|
|
400
|
+
if (child(node, 'a:buChar')) return { bullet: true };
|
|
401
|
+
if (child(node, 'a:buAutoNum')) return { bullet: { type: 'number' } };
|
|
352
402
|
return null;
|
|
353
403
|
}
|
|
354
404
|
|
|
@@ -362,6 +412,9 @@ function extractRunOptions(rPr, scheme, linkCtx) {
|
|
|
362
412
|
if (attr(rPr, 'b') === '1') options.bold = true;
|
|
363
413
|
if (attr(rPr, 'i') === '1') options.italic = true;
|
|
364
414
|
|
|
415
|
+
const u = attr(rPr, 'u');
|
|
416
|
+
if (u && u !== 'none') options.underline = { style: 'sng' };
|
|
417
|
+
|
|
365
418
|
const face = attr(child(rPr, 'a:latin'), 'typeface');
|
|
366
419
|
if (face) options.fontFace = face;
|
|
367
420
|
|
|
@@ -389,6 +442,7 @@ function extractRunOptions(rPr, scheme, linkCtx) {
|
|
|
389
442
|
|
|
390
443
|
module.exports = {
|
|
391
444
|
extractEntities,
|
|
445
|
+
extractTextRuns,
|
|
392
446
|
PRST_TO_SHAPE,
|
|
393
447
|
boundsFromXfrm,
|
|
394
448
|
};
|
package/lib/mapper.js
CHANGED
package/lib/rels.js
CHANGED
|
@@ -51,11 +51,24 @@ function isExternalTarget(rel) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function resolveTargetPath(ownerPath, target) {
|
|
54
|
+
const normalized = String(target).replace(/\\/g, '/');
|
|
55
|
+
|
|
56
|
+
// PptxGenJS 等写入的包根绝对路径:/ppt/charts/chart1.xml
|
|
57
|
+
if (normalized.startsWith('/')) {
|
|
58
|
+
return path.posix.normalize(normalized.slice(1));
|
|
59
|
+
}
|
|
60
|
+
|
|
54
61
|
if (!ownerPath) {
|
|
55
|
-
return path.posix.normalize(
|
|
62
|
+
return path.posix.normalize(normalized);
|
|
56
63
|
}
|
|
64
|
+
|
|
65
|
+
// 已是包内绝对路径(无 ..):ppt/charts/chart1.xml
|
|
66
|
+
if (normalized.startsWith('ppt/') && !normalized.includes('..')) {
|
|
67
|
+
return path.posix.normalize(normalized);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
const ownerDir = path.posix.dirname(ownerPath);
|
|
58
|
-
return path.posix.normalize(path.posix.join(ownerDir,
|
|
71
|
+
return path.posix.normalize(path.posix.join(ownerDir, normalized));
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
/**
|
package/lib/run-utils.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文本 run 合并与 PptxGenJS 选项压缩(IR → 代码 简化层)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TEXT_COLOR = '000000';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {object} a
|
|
9
|
+
* @param {object} b
|
|
10
|
+
*/
|
|
11
|
+
function optionsEqual(a, b) {
|
|
12
|
+
return optionsKey(a) === optionsKey(b);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} opts
|
|
17
|
+
*/
|
|
18
|
+
function optionsKey(opts) {
|
|
19
|
+
const o = compressRunOptions({ ...opts });
|
|
20
|
+
return JSON.stringify(o, Object.keys(o).sort());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} text
|
|
25
|
+
*/
|
|
26
|
+
function isNewlineOnly(text) {
|
|
27
|
+
return typeof text === 'string' && /^[\r\n\s]*$/.test(text) && /[\r\n]/.test(text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 剔除 PptxGenJS 默认值,简化 underline
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
*/
|
|
34
|
+
function compressRunOptions(opts) {
|
|
35
|
+
if (!opts || typeof opts !== 'object') return {};
|
|
36
|
+
const copy = { ...opts };
|
|
37
|
+
delete copy._degraded;
|
|
38
|
+
|
|
39
|
+
if (copy.color === DEFAULT_TEXT_COLOR || copy.color === '000000') {
|
|
40
|
+
delete copy.color;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (copy.underline && typeof copy.underline === 'object') {
|
|
44
|
+
if ((copy.underline.style ?? 'sng') === 'sng') {
|
|
45
|
+
copy.underline = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return copy;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 合并相邻同样式 run,将独立换行 run 并入前一段文本
|
|
54
|
+
* @param {Array<{ text: string, options?: object }>} runs
|
|
55
|
+
*/
|
|
56
|
+
function compressTextRuns(runs) {
|
|
57
|
+
if (!runs?.length) return [];
|
|
58
|
+
|
|
59
|
+
/** @type {Array<{ text: string, options: object }>} */
|
|
60
|
+
const out = [];
|
|
61
|
+
|
|
62
|
+
for (const run of runs) {
|
|
63
|
+
const text = run.text ?? '';
|
|
64
|
+
const options = compressRunOptions(run.options ?? {});
|
|
65
|
+
|
|
66
|
+
if (!text && Object.keys(options).length === 0) continue;
|
|
67
|
+
|
|
68
|
+
if (!out.length) {
|
|
69
|
+
out.push({ text, options });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const last = out[out.length - 1];
|
|
74
|
+
|
|
75
|
+
if (isNewlineOnly(text)) {
|
|
76
|
+
const nlOpts = { ...options };
|
|
77
|
+
delete nlOpts.bullet;
|
|
78
|
+
if (optionsEqual(last.options, nlOpts) || optionsEqual(last.options, options)) {
|
|
79
|
+
if (!last.text.endsWith('\n')) {
|
|
80
|
+
last.text += text.includes('\r') ? '\r\n' : '\n';
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (optionsEqual(last.options, options)) {
|
|
87
|
+
last.text += text;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
out.push({ text, options });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
while (out.length > 1 && isNewlineOnly(out[out.length - 1].text)) {
|
|
95
|
+
const tail = out.pop();
|
|
96
|
+
const prev = out[out.length - 1];
|
|
97
|
+
if (optionsEqual(prev.options, tail.options) && !prev.text.endsWith('\n')) {
|
|
98
|
+
prev.text += tail.text.includes('\r') ? '\r\n' : '\n';
|
|
99
|
+
} else {
|
|
100
|
+
out.push(tail);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return out.map((run) => {
|
|
106
|
+
if (!isNewlineOnly(run.text)) return run;
|
|
107
|
+
const options = { ...run.options };
|
|
108
|
+
delete options.bullet;
|
|
109
|
+
return { text: run.text, options };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 四边相同时合并为 PptxGenJS 简写 border
|
|
115
|
+
* @param {object|null} border
|
|
116
|
+
*/
|
|
117
|
+
function compressCellBorder(border) {
|
|
118
|
+
if (!border) return null;
|
|
119
|
+
|
|
120
|
+
const sides = ['left', 'right', 'top', 'bottom'];
|
|
121
|
+
const present = sides.filter((s) => border[s]);
|
|
122
|
+
if (present.length === 0) return null;
|
|
123
|
+
|
|
124
|
+
if (present.length < 4) return border;
|
|
125
|
+
|
|
126
|
+
const first = border[present[0]];
|
|
127
|
+
const allSame = present.every((s) => {
|
|
128
|
+
const side = border[s];
|
|
129
|
+
return (
|
|
130
|
+
(side.color ?? null) === (first.color ?? null) &&
|
|
131
|
+
(side.pt ?? 1) === (first.pt ?? 1)
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
if (allSame) {
|
|
136
|
+
return {
|
|
137
|
+
type: 'solid',
|
|
138
|
+
color: first.color,
|
|
139
|
+
pt: first.pt ?? 1,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return border;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 表格单元格:压缩 runs → 纯文本或内联多 run
|
|
148
|
+
* @param {Array<{ text: string, options?: object }>} runs
|
|
149
|
+
*/
|
|
150
|
+
function flattenTableCellText(runs) {
|
|
151
|
+
const merged = compressTextRuns(runs);
|
|
152
|
+
if (!merged.length) return { text: '', runOptions: {} };
|
|
153
|
+
|
|
154
|
+
const allSame =
|
|
155
|
+
merged.length === 1 ||
|
|
156
|
+
merged.every((r) => optionsEqual(r.options, merged[0].options));
|
|
157
|
+
|
|
158
|
+
if (allSame) {
|
|
159
|
+
const text = merged.map((r) => r.text).join('').replace(/\r\n/g, '\n');
|
|
160
|
+
return { text, runOptions: compressRunOptions(merged[0].options) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
text: merged.map((r) => ({
|
|
165
|
+
text: String(r.text).replace(/\r\n/g, '\n'),
|
|
166
|
+
options: compressRunOptions(r.options),
|
|
167
|
+
})),
|
|
168
|
+
runOptions: {},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
compressRunOptions,
|
|
174
|
+
compressTextRuns,
|
|
175
|
+
compressCellBorder,
|
|
176
|
+
flattenTableCellText,
|
|
177
|
+
optionsEqual,
|
|
178
|
+
isNewlineOnly,
|
|
179
|
+
};
|
package/lib/table.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 表格提取(design.html §4.5)
|
|
3
3
|
*/
|
|
4
|
-
const { asArray, attr, child
|
|
4
|
+
const { asArray, attr, child } = require('./xml-utils');
|
|
5
5
|
const { getGraphicXfrm } = require('./graphic');
|
|
6
6
|
const { boundsFromXfrm } = require('./utils/bounds');
|
|
7
7
|
const { resolveFillColor } = require('./utils/color');
|
|
8
8
|
const { emuToInch } = require('./utils/emu');
|
|
9
|
+
const { compressCellBorder, flattenTableCellText } = require('./run-utils');
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* @param {object} graphicFrame
|
|
@@ -52,20 +53,40 @@ function extractTableRows(tbl, scheme) {
|
|
|
52
53
|
for (const tr of asArray(tbl['a:tr'])) {
|
|
53
54
|
const cells = [];
|
|
54
55
|
for (const tc of asArray(tr['a:tc'])) {
|
|
55
|
-
const text = extractCellText(tc);
|
|
56
56
|
const tcPr = child(tc, 'a:tcPr');
|
|
57
|
+
const txBody = child(tc, 'a:txBody');
|
|
58
|
+
let text = '';
|
|
59
|
+
/** @type {object} */
|
|
60
|
+
let runOptions = {};
|
|
61
|
+
if (txBody) {
|
|
62
|
+
const { extractTextRuns } = require('./extractor');
|
|
63
|
+
const flat = flattenTableCellText(extractTextRuns(txBody, scheme, null));
|
|
64
|
+
text = flat.text;
|
|
65
|
+
runOptions = flat.runOptions;
|
|
66
|
+
}
|
|
67
|
+
|
|
57
68
|
const fill = tcPr ? resolveFillColor(tcPr, scheme).color : null;
|
|
69
|
+
const borderRaw = extractCellBorderSides(tcPr, scheme);
|
|
70
|
+
const borderColors = borderRaw
|
|
71
|
+
? Object.values(borderRaw)
|
|
72
|
+
.map((b) => b.color)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
: [];
|
|
75
|
+
const border = compressCellBorder(borderRaw);
|
|
76
|
+
const fillIsJustBorderColor =
|
|
77
|
+
fill &&
|
|
78
|
+
borderColors.length > 0 &&
|
|
79
|
+
borderColors.every((c) => c === fill);
|
|
80
|
+
|
|
58
81
|
const merge = {};
|
|
59
82
|
const rowSpan = attr(tcPr, 'rowSpan');
|
|
60
83
|
const gridSpan = attr(tcPr, 'gridSpan');
|
|
61
84
|
if (rowSpan && rowSpan !== '1') merge.rowspan = parseInt(rowSpan, 10);
|
|
62
85
|
if (gridSpan && gridSpan !== '1') merge.colspan = parseInt(gridSpan, 10);
|
|
63
86
|
|
|
64
|
-
const cell = { text, options: {} };
|
|
65
|
-
if (fill) cell.options.fill =
|
|
87
|
+
const cell = { text, options: { ...runOptions } };
|
|
88
|
+
if (fill && !fillIsJustBorderColor) cell.options.fill = fill;
|
|
66
89
|
if (Object.keys(merge).length) Object.assign(cell.options, merge);
|
|
67
|
-
|
|
68
|
-
const border = extractCellBorder(tcPr, scheme);
|
|
69
90
|
if (border) cell.options.border = border;
|
|
70
91
|
|
|
71
92
|
cells.push(cell);
|
|
@@ -79,7 +100,7 @@ function extractTableRows(tbl, scheme) {
|
|
|
79
100
|
* @param {object|null|undefined} tcPr
|
|
80
101
|
* @param {Record<string, string>} scheme
|
|
81
102
|
*/
|
|
82
|
-
function
|
|
103
|
+
function extractCellBorderSides(tcPr, scheme) {
|
|
83
104
|
if (!tcPr) return null;
|
|
84
105
|
const border = {};
|
|
85
106
|
const sides = {
|
|
@@ -106,21 +127,6 @@ function extractCellBorder(tcPr, scheme) {
|
|
|
106
127
|
return hasAny ? border : null;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
|
-
/**
|
|
110
|
-
* @param {object} tc
|
|
111
|
-
*/
|
|
112
|
-
function extractCellText(tc) {
|
|
113
|
-
const txBody = child(tc, 'a:txBody');
|
|
114
|
-
if (!txBody) return '';
|
|
115
|
-
const parts = [];
|
|
116
|
-
for (const p of asArray(txBody['a:p'])) {
|
|
117
|
-
for (const r of asArray(p['a:r'])) {
|
|
118
|
-
parts.push(textContent(r['a:t']));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return parts.join('');
|
|
122
|
-
}
|
|
123
|
-
|
|
124
130
|
/**
|
|
125
131
|
* @param {object} tbl
|
|
126
132
|
* @returns {number[]|undefined}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pptx2js",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "将 .pptx 文件转换为可运行的 PptxGenJS 生成脚本",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "jest",
|
|
11
|
-
"test:watch": "jest --watch"
|
|
11
|
+
"test:watch": "jest --watch",
|
|
12
|
+
"test:sample": "npm run generate --prefix testPPT && node bin/pptx2js.js testppt/sample.pptx -o testppt/pptx2js-output"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
14
15
|
"pptx",
|