pptx2js 0.4.0 → 0.4.3

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/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,13 @@ 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 linePt = el.lineWidth ?? 1;
99
+ const dash = el.lineDash ? `, dashType: '${escapeJs(el.lineDash)}'` : '';
100
+ opts.push(`line: { color: '${escapeJs(el.line)}', width: ${linePt}${dash} }`);
101
+ }
102
+ if (el.flipH) opts.push('flipH: true');
103
+ if (el.flipV) opts.push('flipV: true');
94
104
  return [`${indent(1)}${slideVar}.addShape(${shapeRef}, { ${opts.join(', ')} });`];
95
105
  }
96
106
 
@@ -100,10 +110,13 @@ function emitElement(el, slideVar, indent, options) {
100
110
  function formatTableRows(rows) {
101
111
  const mapped = rows.map((row) =>
102
112
  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)}`);
113
+ const textLit = Array.isArray(cell.text)
114
+ ? formatTextRuns(cell.text)
115
+ : `'${escapeJs(cell.text ?? '')}'`;
116
+ const parts = [`text: ${textLit}`];
117
+ const opts = compressTableCellOptions(cell.options ?? {});
118
+ if (Object.keys(opts).length) {
119
+ parts.push(`options: ${formatOptionsObject(opts)}`);
107
120
  }
108
121
  return `{ ${parts.join(', ')} }`;
109
122
  })
@@ -111,6 +124,47 @@ function formatTableRows(rows) {
111
124
  return `[\n ${mapped.map((r) => `[${r.join(', ')}]`).join(',\n ')}\n ]`;
112
125
  }
113
126
 
127
+ /**
128
+ * @param {object} opts
129
+ */
130
+ function compressTableCellOptions(opts) {
131
+ const copy = compressRunOptions({ ...opts });
132
+ return copy;
133
+ }
134
+
135
+ /**
136
+ * 生成可读的 options 字面量(避免 JSON 双引号风格)
137
+ * @param {object} opts
138
+ */
139
+ function formatBorderObject(border) {
140
+ if (border.type === 'solid' && border.color) {
141
+ const parts = [`type: 'solid'`, `color: '${escapeJs(border.color)}'`];
142
+ if (border.pt != null) parts.push(`pt: ${border.pt}`);
143
+ return `{ ${parts.join(', ')} }`;
144
+ }
145
+ return JSON.stringify(border);
146
+ }
147
+
148
+ function formatOptionsObject(opts) {
149
+ const entries = [];
150
+ for (const [key, val] of Object.entries(opts)) {
151
+ if (key === 'border' && val && typeof val === 'object') {
152
+ entries.push(`border: ${formatBorderObject(val)}`);
153
+ } else if (val === true) {
154
+ entries.push(`${key}: true`);
155
+ } else if (val === false) {
156
+ entries.push(`${key}: false`);
157
+ } else if (typeof val === 'number') {
158
+ entries.push(`${key}: ${val}`);
159
+ } else if (typeof val === 'string') {
160
+ entries.push(`${key}: '${escapeJs(val)}'`);
161
+ } else {
162
+ entries.push(`${key}: ${JSON.stringify(val)}`);
163
+ }
164
+ }
165
+ return `{ ${entries.join(', ')} }`;
166
+ }
167
+
114
168
  function formatTextRuns(runs) {
115
169
  if (!runs?.length) return "''";
116
170
 
@@ -131,7 +185,7 @@ function formatTextRuns(runs) {
131
185
  }
132
186
 
133
187
  function formatRunOptions(options) {
134
- const copy = { ...options };
188
+ const copy = compressRunOptions({ ...options });
135
189
  delete copy._degraded;
136
190
  const entries = [];
137
191
  if (copy.fontSize != null) entries.push(`fontSize: ${copy.fontSize}`);
@@ -146,6 +200,11 @@ function formatRunOptions(options) {
146
200
  if (copy.hyperlink?.url) {
147
201
  entries.push(`hyperlink: { url: '${escapeJs(copy.hyperlink.url)}' }`);
148
202
  }
203
+ if (copy.underline === true) {
204
+ entries.push('underline: true');
205
+ } else if (copy.underline && typeof copy.underline === 'object') {
206
+ entries.push(`underline: { style: '${escapeJs(copy.underline.style ?? 'sng')}' }`);
207
+ }
149
208
  if (copy.align) entries.push(`align: '${escapeJs(String(copy.align))}'`);
150
209
  if (copy.indentLevel != null && copy.indentLevel > 0) {
151
210
  entries.push(`indentLevel: ${copy.indentLevel}`);
package/lib/color.js ADDED
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 颜色规范化(srgbClr、schemeClr、渐变首色标)
3
+ */
4
+ const { attr, child, children, documentRoot } = require('./xml-utils');
5
+
6
+ /** OOXML a:prstClr val → 6 位 RGB(源 PPT 主要使用 black / white) */
7
+ const PRST_COLOR_MAP = {
8
+ black: '000000',
9
+ white: 'FFFFFF',
10
+ red: 'FF0000',
11
+ green: '00FF00',
12
+ blue: '0000FF',
13
+ yellow: 'FFFF00',
14
+ gray: '808080',
15
+ grey: '808080',
16
+ silver: 'C0C0C0',
17
+ navy: '000080',
18
+ teal: '008080',
19
+ lime: '00FF00',
20
+ olive: '808000',
21
+ maroon: '800000',
22
+ aqua: '00FFFF',
23
+ fuchsia: 'FF00FF',
24
+ orange: 'FFA500',
25
+ purple: '800080',
26
+ brown: 'A52A2A',
27
+ pink: 'FFC0CB',
28
+ dkGreen: '006400',
29
+ dkBlue: '00008B',
30
+ dkRed: '8B0000',
31
+ dkGray: 'A9A9A9',
32
+ dkGrey: 'A9A9A9',
33
+ ltGray: 'D3D3D3',
34
+ ltGrey: 'D3D3D3',
35
+ };
36
+
37
+ /** @type {Record<string, string>} */
38
+ const DEFAULT_SCHEME = {
39
+ dk1: '000000',
40
+ lt1: 'FFFFFF',
41
+ dk2: '44546A',
42
+ lt2: 'E7E6E6',
43
+ accent1: '4472C4',
44
+ accent2: 'ED7D31',
45
+ accent3: 'A5A5A5',
46
+ accent4: 'FFC000',
47
+ accent5: '5B9BD5',
48
+ accent6: '70AD47',
49
+ hlink: '0563C1',
50
+ folHlink: '954F72',
51
+ };
52
+
53
+ /**
54
+ * @param {Record<string, object>|null} parsed
55
+ * @param {string|null} themePath
56
+ * @returns {Record<string, string>}
57
+ */
58
+ function loadColorScheme(parsed, themePath) {
59
+ if (!themePath || !parsed[themePath]) return { ...DEFAULT_SCHEME };
60
+
61
+ const theme = documentRoot(parsed[themePath], 'a:theme');
62
+ const clrScheme = child(child(theme, 'a:themeElements'), 'a:clrScheme');
63
+ if (!clrScheme) return { ...DEFAULT_SCHEME };
64
+
65
+ const scheme = { ...DEFAULT_SCHEME };
66
+ for (const name of Object.keys(DEFAULT_SCHEME)) {
67
+ const slot = child(clrScheme, `a:${name}`);
68
+ const rgb = extractRgbFromColorNode(slot);
69
+ if (rgb) scheme[name] = rgb;
70
+ }
71
+ return scheme;
72
+ }
73
+
74
+ /**
75
+ * @param {object|null|undefined} colorNode a:srgbClr / a:schemeClr 的父级
76
+ * @returns {string|null}
77
+ */
78
+ function extractRgbFromColorNode(colorNode) {
79
+ if (!colorNode) return null;
80
+
81
+ const srgb = child(colorNode, 'a:srgbClr');
82
+ if (srgb) {
83
+ const val = attr(srgb, 'val');
84
+ return val ? val.toUpperCase() : null;
85
+ }
86
+
87
+ const sys = child(colorNode, 'a:sysClr');
88
+ if (sys) {
89
+ const last = attr(sys, 'lastClr');
90
+ return last ? applyColorModifiers(last.toUpperCase(), sys) : null;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * @param {object|null|undefined} fillNode a:solidFill | a:gradFill 等
98
+ * @param {Record<string, string>} scheme
99
+ * @returns {{ color: string|null, degraded: boolean }}
100
+ */
101
+ function resolveFillColor(fillNode, scheme) {
102
+ if (!fillNode) return { color: null, degraded: false };
103
+
104
+ const solid =
105
+ fillNode.tag === 'a:solidFill' ? fillNode : child(fillNode, 'a:solidFill');
106
+ if (solid) {
107
+ return { color: resolveColorFromContainer(solid, scheme), degraded: false };
108
+ }
109
+
110
+ const grad =
111
+ fillNode.tag === 'a:gradFill' ? fillNode : child(fillNode, 'a:gradFill');
112
+ if (grad) {
113
+ const gs = firstGradientStop(grad);
114
+ return {
115
+ color: gs ? resolveColorFromContainer(gs, scheme) : null,
116
+ degraded: true,
117
+ };
118
+ }
119
+
120
+ const ptn =
121
+ fillNode.tag === 'a:ptnFill' ? fillNode : child(fillNode, 'a:ptnFill');
122
+ if (ptn) {
123
+ const fgClr = child(ptn, 'a:fgClr');
124
+ return {
125
+ color: fgClr ? resolveColorFromContainer(fgClr, scheme) : null,
126
+ degraded: true,
127
+ };
128
+ }
129
+
130
+ return { color: null, degraded: false };
131
+ }
132
+
133
+ /**
134
+ * 渐变色标按 pos 升序,取最小 pos 对应色标(OOXML 0–100000)
135
+ * @param {object} grad a:gradFill
136
+ * @returns {object|null}
137
+ */
138
+ function firstGradientStop(grad) {
139
+ const gsLst = child(grad, 'a:gsLst');
140
+ if (!gsLst) return null;
141
+ const stops = children(gsLst, 'a:gs');
142
+ if (!stops.length) return null;
143
+ return [...stops].sort(
144
+ (a, b) => parseInt(attr(a, 'pos') ?? '0', 10) - parseInt(attr(b, 'pos') ?? '0', 10)
145
+ )[0];
146
+ }
147
+
148
+ /**
149
+ * @param {object|null|undefined} container 含 a:srgbClr / a:schemeClr
150
+ * @param {Record<string, string>} scheme
151
+ * @returns {string|null}
152
+ */
153
+ function resolveColorFromContainer(container, scheme) {
154
+ if (!container) return null;
155
+
156
+ const srgb = child(container, 'a:srgbClr');
157
+ if (srgb) {
158
+ const val = attr(srgb, 'val');
159
+ if (!val) return null;
160
+ return applyColorModifiers(val.toUpperCase(), srgb);
161
+ }
162
+
163
+ const schemeClr = child(container, 'a:schemeClr');
164
+ if (schemeClr) {
165
+ const name = attr(schemeClr, 'val');
166
+ const base = name ? scheme[name] ?? DEFAULT_SCHEME[name] ?? null : null;
167
+ return base ? applyColorModifiers(base, schemeClr) : null;
168
+ }
169
+
170
+ const prstClr = child(container, 'a:prstClr');
171
+ if (prstClr) {
172
+ const val = attr(prstClr, 'val');
173
+ const base = val ? PRST_COLOR_MAP[val] ?? PRST_COLOR_MAP[val.toLowerCase()] ?? '000000' : null;
174
+ return base ? applyColorModifiers(base, prstClr) : null;
175
+ }
176
+
177
+ const sysClr = child(container, 'a:sysClr');
178
+ if (sysClr) {
179
+ const last = attr(sysClr, 'lastClr');
180
+ return last ? applyColorModifiers(last.toUpperCase(), sysClr) : null;
181
+ }
182
+
183
+ return extractRgbFromColorNode(container);
184
+ }
185
+
186
+ /**
187
+ * @param {string} hex 6 位 RGB
188
+ */
189
+ function hexToRgb(hex) {
190
+ const h = String(hex).replace(/^#/, '');
191
+ if (h.length !== 6) return { r: 0, g: 0, b: 0 };
192
+ return {
193
+ r: parseInt(h.slice(0, 2), 16),
194
+ g: parseInt(h.slice(2, 4), 16),
195
+ b: parseInt(h.slice(4, 6), 16),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * @param {{ r: number, g: number, b: number }} rgb
201
+ */
202
+ function rgbToHex(rgb) {
203
+ const clamp = (n) => Math.max(0, Math.min(255, Math.round(n)));
204
+ return [rgb.r, rgb.g, rgb.b]
205
+ .map((c) => clamp(c).toString(16).padStart(2, '0'))
206
+ .join('')
207
+ .toUpperCase();
208
+ }
209
+
210
+ /**
211
+ * srgbClr / schemeClr 上的 lumMod / shade / tint 等修饰(OOXML 比例 1/100000)
212
+ * @param {string} hex
213
+ * @param {object} clrNode
214
+ */
215
+ function applyColorModifiers(hex, clrNode) {
216
+ let { r, g, b } = hexToRgb(hex);
217
+
218
+ const lumMod = child(clrNode, 'a:lumMod');
219
+ if (lumMod) {
220
+ const pct = parseInt(attr(lumMod, 'val') ?? '100000', 10) / 100000;
221
+ r *= pct;
222
+ g *= pct;
223
+ b *= pct;
224
+ }
225
+
226
+ const lumOff = child(clrNode, 'a:lumOff');
227
+ if (lumOff) {
228
+ const pct = parseInt(attr(lumOff, 'val') ?? '0', 10) / 100000;
229
+ const delta = 255 * pct;
230
+ r += delta;
231
+ g += delta;
232
+ b += delta;
233
+ }
234
+
235
+ const shade = child(clrNode, 'a:shade');
236
+ if (shade) {
237
+ const pct = parseInt(attr(shade, 'val') ?? '0', 10) / 100000;
238
+ r *= pct;
239
+ g *= pct;
240
+ b *= pct;
241
+ }
242
+
243
+ const tint = child(clrNode, 'a:tint');
244
+ if (tint) {
245
+ const pct = parseInt(attr(tint, 'val') ?? '0', 10) / 100000;
246
+ r += (255 - r) * pct;
247
+ g += (255 - g) * pct;
248
+ b += (255 - b) * pct;
249
+ }
250
+
251
+ return rgbToHex({ r, g, b });
252
+ }
253
+
254
+ /** @deprecated 使用 applyColorModifiers */
255
+ const applySchemeClrModifiers = applyColorModifiers;
256
+
257
+ /**
258
+ * @param {Record<string, string>} scheme
259
+ * @param {object|null|undefined} clrNode
260
+ * @returns {string|null}
261
+ */
262
+ function resolveColor(scheme, clrNode) {
263
+ return resolveColorFromContainer(clrNode, scheme);
264
+ }
265
+
266
+ module.exports = {
267
+ DEFAULT_SCHEME,
268
+ loadColorScheme,
269
+ resolveFillColor,
270
+ resolveColor,
271
+ resolveColorFromContainer,
272
+ firstGradientStop,
273
+ applyColorModifiers,
274
+ applySchemeClrModifiers,
275
+ };
package/lib/convert.js CHANGED
@@ -46,8 +46,11 @@ async function convert(filePath, options = {}) {
46
46
  }
47
47
 
48
48
  const stat = fs.statSync(resolvedPath);
49
- if (stat.size > (options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE)) {
50
- // 大文件流式解析 实现阶段补充
49
+ const maxSize = options.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
50
+ if (stat.size > maxSize) {
51
+ throw new Error(
52
+ `源文件超过大小限制 (${stat.size} > ${maxSize} 字节)。请拆分 PPT 或使用 --max-file-size 提高上限。`
53
+ );
51
54
  }
52
55
 
53
56
  const outputDir = path.resolve(options.outputDir ?? DEFAULT_OUTPUT);
@@ -59,8 +62,8 @@ async function convert(filePath, options = {}) {
59
62
  const parsed = {};
60
63
  for (const [entryPath, content] of archive.files) {
61
64
  if (content === '__binary__') continue;
62
- if (entryPath.endsWith('.xml') || entryPath.endsWith('.rels')) {
63
- parsed[entryPath] = await parseXml(content);
65
+ if (/\.(xml|rels)$/i.test(entryPath)) {
66
+ parsed[entryPath] = parseXml(content);
64
67
  }
65
68
  }
66
69
 
@@ -68,15 +71,16 @@ async function convert(filePath, options = {}) {
68
71
  const ctx = { relIndex, parsed, sourcePath: resolvedPath };
69
72
  const entities = extractEntities(ctx);
70
73
  const ir = mapToIR(entities, ctx);
71
- const script = generateScript(ir, options);
72
-
73
- const scriptPath = path.join(outputDir, 'output.js');
74
- fs.writeFileSync(scriptPath, script, 'utf8');
75
74
 
75
+ // packageMedia 会就地更新 ir 中的 mediaPath,generateScript 必须在其之后调用
76
76
  await packageMedia({ archive, ir }, outputDir, {
77
77
  noMedia: options.noMedia,
78
78
  });
79
79
 
80
+ const script = generateScript(ir, options);
81
+ const scriptPath = path.join(outputDir, 'output.js');
82
+ fs.writeFileSync(scriptPath, script, 'utf8');
83
+
80
84
  const log = buildConversionLog(resolvedPath, stat, entities, ir);
81
85
  writeConversionLog(outputDir, log);
82
86
  writeOutputReadme(outputDir, {