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 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, plotArea);
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, plotArea) {
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 (strRef) return extractCachePoints(strRef, 'c:strCache', 'c:v');
131
- if (numRef) return extractCachePoints(numRef, 'c:numCache', 'c:v').map(String);
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 cache = child(refNode, cacheKey);
155
- const pts = asArray(child(cache, 'c:pt'));
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} plotArea
200
+ * @param {object} chartRoot c:chart
163
201
  */
164
- function extractChartTitle(plotArea) {
165
- const title = child(plotArea, 'c:title');
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) opts.push(`title: '${escapeJs(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) opts.push(`line: { color: '${escapeJs(el.line)}', width: 1 }`);
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 parts = [`text: '${escapeJs(cell.text ?? '')}'`];
104
- const optKeys = Object.keys(cell.options ?? {});
105
- if (optKeys.length) {
106
- parts.push(`options: ${JSON.stringify(cell.options)}`);
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
- for (const p of asArray(txBody['a:p'])) {
304
- const pPr = child(p, 'a:pPr');
305
- const bullet = extractBullet(pPr);
306
- const paraOpts = extractParaOptions(pPr);
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) continue;
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(pPr, 'algn');
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(pPr, 'lvl');
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(pPr, 'a:spcBef'), 'a:spcPts'), 'val');
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(pPr, 'a:spcAft'), 'a:spcPts'), 'val');
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(pPr, 'a:lnSpc'), 'a:spcPts'), 'val');
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
- if (child(pPr, 'a:buChar')) return { bullet: true };
351
- if (child(pPr, 'a:buAutoNum')) return { bullet: { type: 'number' } };
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
@@ -84,6 +84,7 @@ function mapToIR(entitiesBySlide, ctx) {
84
84
  shape: entity.shape.type,
85
85
  fill: entity.shape.fill,
86
86
  line: entity.shape.line,
87
+ lineWidth: entity.shape.lineWidth,
87
88
  degradeReason: entity.degradeReason,
88
89
  });
89
90
  }
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(target);
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, target));
71
+ return path.posix.normalize(path.posix.join(ownerDir, normalized));
59
72
  }
60
73
 
61
74
  /**
@@ -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, textContent } = require('./xml-utils');
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 = { color: 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 extractCellBorder(tcPr, scheme) {
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.0",
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",