pptx2js 0.4.1 → 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/extractor.js CHANGED
@@ -2,20 +2,22 @@
2
2
  * ③ 实体提取器
3
3
  */
4
4
  const path = require('path');
5
- const { asArray, attr, child, textContent } = require('./xml-utils');
6
- const { boundsFromXfrm } = require('./utils/bounds');
5
+ const { attr, child, childNodes, children, documentRoot } = require('./xml-utils');
6
+ const { boundsFromXfrm } = require('./bounds');
7
7
  const {
8
8
  loadColorScheme,
9
9
  resolveFillColor,
10
10
  resolveColorFromContainer,
11
- } = require('./utils/color');
12
- const { compressTextRuns } = require('./run-utils');
11
+ } = require('./color');
12
+ const { extractTextRuns } = require('./text-utils');
13
13
  const { getSlidePaths, getThemePath } = require('./presentation');
14
14
  const {
15
15
  buildSlideInheritance,
16
16
  getEffectiveXfrm,
17
+ getPlaceholderKey,
17
18
  mergeTxBody,
18
19
  resolvePlaceholderSps,
20
+ shouldShowMasterShapes,
19
21
  } = require('./placeholder');
20
22
  const { isTableFrame, isChartFrame, isSmartArtFrame } = require('./graphic');
21
23
  const { extractTable } = require('./table');
@@ -30,7 +32,7 @@ const { extractSmartArt } = require('./smartart');
30
32
  * @property {string} slidePath
31
33
  * @property {ConversionDecision} decision
32
34
  * @property {string} kind
33
- * @property {import('./utils/bounds').Bounds} bounds
35
+ * @property {import('./bounds').Bounds} bounds
34
36
  * @property {object} [text]
35
37
  * @property {object} [image]
36
38
  * @property {object} [shape]
@@ -51,8 +53,17 @@ const PRST_TO_SHAPE = {
51
53
  pentagon: 'PENTAGON',
52
54
  hexagon: 'HEXAGON',
53
55
  star5: 'STAR5',
56
+ leftBrace: 'LEFT_BRACE',
57
+ rightBracket: 'RIGHT_BRACKET',
58
+ rightArrow: 'RIGHT_ARROW',
59
+ downArrow: 'DOWN_ARROW',
60
+ curvedLeftArrow: 'LEFT_ARROW',
61
+ flowChartAlternateProcess: 'ROUNDED_RECTANGLE',
54
62
  };
55
63
 
64
+ /** prst → 退化为 LINE 并附 degradeReason */
65
+ const PRST_DEGRADE_LINE = new Set(['bentConnector3']);
66
+
56
67
  /**
57
68
  * @typedef {object} ExtractContext
58
69
  * @property {Record<string, object>} parsed
@@ -83,7 +94,7 @@ function extractEntities(ctx) {
83
94
  */
84
95
  function extractSlide(slidePath, slideIndex, ctx, scheme) {
85
96
  const doc = ctx.parsed[slidePath];
86
- const slide = child(doc, 'p:sld');
97
+ const slide = documentRoot(doc, 'p:sld');
87
98
  const cSld = child(slide, 'p:cSld');
88
99
  const spTree = child(cSld, 'p:spTree');
89
100
  if (!spTree) return [];
@@ -115,57 +126,91 @@ function extractSlide(slidePath, slideIndex, ctx, scheme) {
115
126
  inheritance,
116
127
  offset: { x: 0, y: 0 },
117
128
  entities,
129
+ decorLayer: false,
118
130
  };
119
131
 
132
+ if (
133
+ shouldShowMasterShapes(slide) &&
134
+ inheritance.masterSpTree &&
135
+ inheritance.masterPath
136
+ ) {
137
+ walkSpTree(inheritance.masterSpTree, {
138
+ ...walkCtx,
139
+ slidePath: inheritance.masterPath,
140
+ decorLayer: true,
141
+ });
142
+ }
143
+ if (inheritance.layoutSpTree && inheritance.layoutPath) {
144
+ walkSpTree(inheritance.layoutSpTree, {
145
+ ...walkCtx,
146
+ slidePath: inheritance.layoutPath,
147
+ decorLayer: true,
148
+ });
149
+ }
120
150
  walkSpTree(spTree, walkCtx);
121
151
  return entities;
122
152
  }
123
153
 
124
154
  function extractSlideBackground(cSld, scheme) {
125
- const bgPr = child(child(cSld, 'p:bg'), 'p:bgPr');
126
- if (!bgPr) return null;
127
- const { color, degraded } = resolveFillColor(bgPr, scheme);
128
- if (!color) return null;
129
- return { color, degraded };
130
- }
155
+ const bg = child(cSld, 'p:bg');
156
+ if (!bg) return null;
131
157
 
132
- function walkSpTree(spTree, ctx) {
133
- for (const sp of asArray(spTree['p:sp'])) {
134
- extractShape(sp, ctx);
135
- }
136
- for (const pic of asArray(spTree['p:pic'])) {
137
- extractPicture(pic, ctx);
158
+ const bgPr = child(bg, 'p:bgPr');
159
+ if (bgPr) {
160
+ const { color, degraded } = resolveFillColor(bgPr, scheme);
161
+ if (color) return { color, degraded };
138
162
  }
139
- for (const grp of asArray(spTree['p:grpSp'])) {
140
- flattenGroup(grp, ctx);
163
+
164
+ const bgRef = child(bg, 'p:bgRef');
165
+ if (bgRef) {
166
+ const color = resolveColorFromContainer(bgRef, scheme);
167
+ if (color) return { color, degraded: false };
141
168
  }
142
- for (const frame of asArray(spTree['p:graphicFrame'])) {
143
- if (isTableFrame(frame)) {
144
- const entity = extractTable(frame, ctx);
145
- if (entity) ctx.entities.push(entity);
146
- } else if (isChartFrame(frame)) {
147
- const entity = extractChart(frame, ctx);
148
- if (entity) ctx.entities.push(entity);
149
- } else if (isSmartArtFrame(frame)) {
150
- const entity = extractSmartArt(frame, ctx);
151
- if (entity) ctx.entities.push(entity);
152
- } else {
153
- skipElement(frame, ctx, '不支持的 graphicFrame 类型');
169
+
170
+ return null;
171
+ }
172
+
173
+ function walkSpTree(spTree, ctx) {
174
+ for (const node of childNodes(spTree)) {
175
+ if (node.tag === 'p:sp') {
176
+ if (ctx.decorLayer && getPlaceholderKey(node)) continue;
177
+ extractShape(node, ctx);
178
+ } else if (node.tag === 'p:pic') {
179
+ extractPicture(node, ctx);
180
+ } else if (node.tag === 'p:grpSp') {
181
+ flattenGroup(node, ctx);
182
+ } else if (node.tag === 'p:graphicFrame') {
183
+ if (ctx.decorLayer) continue;
184
+ if (isTableFrame(node)) {
185
+ const entity = extractTable(node, ctx);
186
+ if (entity) ctx.entities.push(entity);
187
+ } else if (isChartFrame(node)) {
188
+ const entity = extractChart(node, ctx);
189
+ if (entity) ctx.entities.push(entity);
190
+ } else if (isSmartArtFrame(node)) {
191
+ const entity = extractSmartArt(node, ctx);
192
+ if (entity) ctx.entities.push(entity);
193
+ } else {
194
+ skipElement(node, ctx, '不支持的 graphicFrame 类型');
195
+ }
196
+ } else if (node.tag === 'p:cxnSp') {
197
+ extractConnector(node, ctx);
154
198
  }
155
199
  }
156
- for (const cxn of asArray(spTree['p:cxnSp'])) {
157
- extractConnector(cxn, ctx);
158
- }
159
200
  }
160
201
 
161
202
  function flattenGroup(grp, ctx) {
162
203
  const grpXfrm = child(child(grp, 'p:grpSpPr'), 'a:xfrm');
163
204
  const off = readOffset(grpXfrm);
164
- const inner = child(grp, 'p:spTree');
165
- if (!inner) return;
166
- walkSpTree(inner, {
205
+ const chOff = readChOff(grpXfrm);
206
+
207
+ // p:grpSp 子节点直接挂在 grp 上,没有 p:spTree 包装
208
+ walkSpTree(grp, {
167
209
  ...ctx,
168
- offset: { x: ctx.offset.x + off.x, y: ctx.offset.y + off.y },
210
+ offset: {
211
+ x: ctx.offset.x + off.x - chOff.x,
212
+ y: ctx.offset.y + off.y - chOff.y,
213
+ },
169
214
  });
170
215
  }
171
216
 
@@ -182,26 +227,37 @@ function extractShape(sp, ctx) {
182
227
  relIndex: ctx.relIndex,
183
228
  slidePath: ctx.slidePath,
184
229
  });
185
- if (runs.length === 0) return;
186
-
187
- const hasGradient = runs.some((r) => r.degraded);
188
- ctx.entities.push({
189
- slideIndex: ctx.slideIndex,
190
- slidePath: ctx.slidePath,
191
- decision: hasGradient ? 'DEGRADE' : 'FULL',
192
- kind: 'text',
193
- bounds,
194
- text: { runs: runs.map(({ text, options }) => ({ text, options })) },
195
- degradeReason: hasGradient ? '文本渐变退化为纯色' : undefined,
196
- });
197
- return;
230
+ if (runs.length > 0) {
231
+ const hasGradient = runs.some((r) => r.degraded);
232
+ ctx.entities.push({
233
+ slideIndex: ctx.slideIndex,
234
+ slidePath: ctx.slidePath,
235
+ decision: hasGradient ? 'DEGRADE' : 'FULL',
236
+ kind: 'text',
237
+ bounds,
238
+ text: { runs: runs.map(({ text, options }) => ({ text, options })) },
239
+ degradeReason: hasGradient ? '文本渐变退化为纯色' : undefined,
240
+ });
241
+ return;
242
+ }
243
+ // 无 a:r 文本(如 a:fld 页码)或空 txBody → 若有几何则继续按形状提取
198
244
  }
199
245
 
200
246
  const spPr = child(sp, 'p:spPr');
201
247
  const prst = attr(child(spPr, 'a:prstGeom'), 'prst');
202
248
  if (!prst) return;
203
249
 
204
- const shapeName = PRST_TO_SHAPE[prst];
250
+ let shapeName = PRST_TO_SHAPE[prst];
251
+ let prstDegradeReason;
252
+ if (PRST_DEGRADE_LINE.has(prst)) {
253
+ shapeName = 'LINE';
254
+ prstDegradeReason = `连接器 "${prst}" 退化为直线`;
255
+ } else if (prst === 'curvedLeftArrow') {
256
+ prstDegradeReason = 'curvedLeftArrow 退化为 LEFT_ARROW';
257
+ } else if (prst === 'flowChartAlternateProcess') {
258
+ prstDegradeReason = 'flowChartAlternateProcess 退化为圆角矩形';
259
+ }
260
+
205
261
  const { color: fillColor, degraded: fillDegraded } = resolveFillColor(
206
262
  spPr,
207
263
  ctx.scheme
@@ -209,6 +265,8 @@ function extractShape(sp, ctx) {
209
265
  const ln = child(spPr, 'a:ln');
210
266
  const lineColor = ln ? resolveFillColor(ln, ctx.scheme).color : null;
211
267
  const lineWidth = extractLineWidthPt(ln);
268
+ const lineDash = extractLineDash(ln);
269
+ const flip = readFlip(xfrm);
212
270
 
213
271
  if (!shapeName) {
214
272
  ctx.entities.push({
@@ -217,23 +275,51 @@ function extractShape(sp, ctx) {
217
275
  decision: 'DEGRADE',
218
276
  kind: 'shape',
219
277
  bounds,
220
- shape: { type: 'RECTANGLE', fill: fillColor, line: lineColor, lineWidth },
278
+ shape: {
279
+ type: 'RECTANGLE',
280
+ fill: fillColor,
281
+ line: lineColor,
282
+ lineWidth,
283
+ lineDash,
284
+ ...flip,
285
+ },
221
286
  degradeReason: `未知预设形状 "${prst}",退化为矩形`,
222
287
  });
223
288
  return;
224
289
  }
225
290
 
291
+ const degradeReason =
292
+ prstDegradeReason ||
293
+ (fillDegraded ? '渐变/图案填充退化为纯色' : undefined);
294
+
226
295
  ctx.entities.push({
227
296
  slideIndex: ctx.slideIndex,
228
297
  slidePath: ctx.slidePath,
229
- decision: fillDegraded ? 'DEGRADE' : 'FULL',
298
+ decision: (prstDegradeReason || fillDegraded) ? 'DEGRADE' : 'FULL',
230
299
  kind: 'shape',
231
300
  bounds,
232
- shape: { type: shapeName, fill: fillColor, line: lineColor, lineWidth },
233
- degradeReason: fillDegraded ? '渐变填充退化为纯色' : undefined,
301
+ shape: {
302
+ type: shapeName,
303
+ fill: fillColor,
304
+ line: lineColor,
305
+ lineWidth,
306
+ lineDash,
307
+ ...flip,
308
+ },
309
+ degradeReason,
234
310
  });
235
311
  }
236
312
 
313
+ /**
314
+ * @param {object|null|undefined} xfrm
315
+ */
316
+ function readFlip(xfrm) {
317
+ const flip = {};
318
+ if (attr(xfrm, 'flipH') === '1') flip.flipH = true;
319
+ if (attr(xfrm, 'flipV') === '1') flip.flipV = true;
320
+ return flip;
321
+ }
322
+
237
323
  /**
238
324
  * @param {object|null|undefined} ln a:ln
239
325
  * @returns {number|undefined}
@@ -245,6 +331,31 @@ function extractLineWidthPt(ln) {
245
331
  return Math.max(1, Math.round(w / 12700));
246
332
  }
247
333
 
334
+ /** OOXML prstDash → PptxGenJS line.dashType */
335
+ const PRST_DASH_MAP = {
336
+ dash: 'dash',
337
+ dot: 'dot',
338
+ dashDot: 'dashDot',
339
+ lgDash: 'lgDash',
340
+ lgDashDot: 'lgDashDot',
341
+ lgDashDotDot: 'lgDashDotDot',
342
+ sysDash: 'sysDash',
343
+ sysDot: 'sysDot',
344
+ sysDashDot: 'sysDashDot',
345
+ sysDashDotDot: 'sysDashDotDot',
346
+ };
347
+
348
+ /**
349
+ * @param {object|null|undefined} ln a:ln
350
+ * @returns {string|undefined}
351
+ */
352
+ function extractLineDash(ln) {
353
+ if (!ln) return undefined;
354
+ const prstDash = child(ln, 'a:prstDash');
355
+ const val = attr(prstDash, 'val');
356
+ return val ? PRST_DASH_MAP[val] : undefined;
357
+ }
358
+
248
359
  function extractPicture(pic, ctx) {
249
360
  const spPr = child(pic, 'p:spPr');
250
361
  const bounds = boundsFromXfrm(child(spPr, 'a:xfrm'), ctx.offset);
@@ -271,8 +382,10 @@ function extractPicture(pic, ctx) {
271
382
 
272
383
  function extractConnector(cxn, ctx) {
273
384
  const spPr = child(cxn, 'p:spPr');
274
- const bounds = boundsFromXfrm(child(spPr, 'a:xfrm'), ctx.offset);
275
- const { color: lineColor } = resolveFillColor(child(spPr, 'a:ln'), ctx.scheme);
385
+ const xfrm = child(spPr, 'a:xfrm');
386
+ const bounds = boundsFromXfrm(xfrm, ctx.offset);
387
+ const ln = child(spPr, 'a:ln');
388
+ const { color: lineColor } = resolveFillColor(ln, ctx.scheme);
276
389
 
277
390
  ctx.entities.push({
278
391
  slideIndex: ctx.slideIndex,
@@ -280,7 +393,14 @@ function extractConnector(cxn, ctx) {
280
393
  decision: 'FULL',
281
394
  kind: 'shape',
282
395
  bounds,
283
- shape: { type: 'LINE', fill: null, line: lineColor },
396
+ shape: {
397
+ type: 'LINE',
398
+ fill: null,
399
+ line: lineColor,
400
+ lineWidth: extractLineWidthPt(ln),
401
+ lineDash: extractLineDash(ln),
402
+ ...readFlip(xfrm),
403
+ },
284
404
  });
285
405
  }
286
406
 
@@ -303,146 +423,19 @@ function readOffset(xfrm) {
303
423
  };
304
424
  }
305
425
 
306
- const PARA_ALIGN_MAP = {
307
- l: 'left',
308
- ctr: 'center',
309
- r: 'right',
310
- just: 'justify',
311
- dist: 'justify',
312
- };
313
-
314
- function extractTextRuns(txBody, scheme, linkCtx) {
315
- const runs = [];
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;
330
-
331
- const text = textContent(r['a:t']);
332
- if (!text) return;
333
- const options = extractRunOptions(r['a:rPr'], scheme, linkCtx);
334
- Object.assign(options, paraOpts);
335
- if (bullet) Object.assign(options, bullet);
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
- }
352
- }
353
- }
354
- return compressTextRuns(runs);
355
- }
356
-
357
426
  /**
358
- * 文本框级默认段落属性(a:lstStyle 在 a:txBody 下,不在 a:bodyPr 下)
359
- * @param {object|null|undefined} txBody
427
+ * 组合内部坐标系原点(EMU)
428
+ * @param {object|null|undefined} xfrm
360
429
  */
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) : {};
366
- }
367
-
368
- /**
369
- * @param {object|null|undefined} pPr
370
- */
371
- function extractParaOptions(pPr) {
372
- if (!pPr) return {};
373
- const node = Array.isArray(pPr) ? pPr[0] : pPr;
374
- const opts = {};
375
-
376
- const algn = attr(node, 'algn');
377
- if (algn && PARA_ALIGN_MAP[algn]) opts.align = PARA_ALIGN_MAP[algn];
378
-
379
- const lvl = attr(node, 'lvl');
380
- if (lvl) opts.indentLevel = parseInt(lvl, 10);
381
-
382
- // 注:a:spcPct(百分比段前/段后距)暂不处理
383
- const spcBef = attr(child(child(node, 'a:spcBef'), 'a:spcPts'), 'val');
384
- if (spcBef) opts.paraSpaceBefore = parseInt(spcBef, 10) / 100;
385
-
386
- // 注:a:spcPct(百分比段后距)暂不处理
387
- const spcAft = attr(child(child(node, 'a:spcAft'), 'a:spcPts'), 'val');
388
- if (spcAft) opts.paraSpaceAfter = parseInt(spcAft, 10) / 100;
389
-
390
- // 注:a:spcPct(百分比行距)暂不处理
391
- const lnSpc = attr(child(child(node, 'a:lnSpc'), 'a:spcPts'), 'val');
392
- if (lnSpc) opts.lineSpacing = parseInt(lnSpc, 10) / 100;
393
-
394
- return opts;
395
- }
396
-
397
- function extractBullet(pPr) {
398
- if (!pPr) return null;
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' } };
402
- return null;
403
- }
404
-
405
- function extractRunOptions(rPr, scheme, linkCtx) {
406
- const options = {};
407
- let degraded = false;
408
- if (!rPr) return options;
409
-
410
- const sz = attr(rPr, 'sz');
411
- if (sz) options.fontSize = parseInt(sz, 10) / 100;
412
- if (attr(rPr, 'b') === '1') options.bold = true;
413
- if (attr(rPr, 'i') === '1') options.italic = true;
414
-
415
- const u = attr(rPr, 'u');
416
- if (u && u !== 'none') options.underline = { style: 'sng' };
417
-
418
- const face = attr(child(rPr, 'a:latin'), 'typeface');
419
- if (face) options.fontFace = face;
420
-
421
- const solid = child(rPr, 'a:solidFill');
422
- const grad = child(rPr, 'a:gradFill');
423
- if (grad) {
424
- degraded = true;
425
- const g = resolveFillColor({ 'a:gradFill': grad }, scheme);
426
- if (g.color) options.color = g.color;
427
- } else if (solid) {
428
- const c = resolveColorFromContainer(solid, scheme);
429
- if (c) options.color = c;
430
- }
431
-
432
- const hlink = child(rPr, 'a:hlinkClick');
433
- if (hlink && linkCtx) {
434
- const relId = attr(hlink, 'r:id');
435
- const url = relId && linkCtx.relIndex.resolve(linkCtx.slidePath, relId);
436
- if (url) options.hyperlink = { url };
437
- }
438
-
439
- if (degraded) options._degraded = true;
440
- return options;
430
+ function readChOff(xfrm) {
431
+ const chOff = child(xfrm, 'a:chOff');
432
+ return {
433
+ x: parseInt(attr(chOff, 'x') ?? '0', 10),
434
+ y: parseInt(attr(chOff, 'y') ?? '0', 10),
435
+ };
441
436
  }
442
437
 
443
438
  module.exports = {
444
439
  extractEntities,
445
- extractTextRuns,
446
440
  PRST_TO_SHAPE,
447
- boundsFromXfrm,
448
441
  };
package/lib/mapper.js CHANGED
@@ -85,6 +85,9 @@ function mapToIR(entitiesBySlide, ctx) {
85
85
  fill: entity.shape.fill,
86
86
  line: entity.shape.line,
87
87
  lineWidth: entity.shape.lineWidth,
88
+ lineDash: entity.shape.lineDash,
89
+ flipH: entity.shape.flipH,
90
+ flipV: entity.shape.flipV,
88
91
  degradeReason: entity.degradeReason,
89
92
  });
90
93
  }
package/lib/packager.js CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * ⑥ 资源打包器
3
3
  * 复制 media/ 文件,写入退化生成图片。
4
- * TODO: 重名冲突处理(不同 zip 路径、同名文件时避免覆盖)
5
4
  */
6
5
  const fs = require('fs');
7
6
  const path = require('path');
@@ -12,30 +11,82 @@ const path = require('path');
12
11
  * @property {import('./mapper').IntermediateRepresentation} ir
13
12
  */
14
13
 
14
+ /**
15
+ * 为 zip 内媒体路径生成不冲突的输出文件名
16
+ * @param {string} entryPath 如 ppt/media/image1.png
17
+ * @param {Set<string>} usedDestNames
18
+ */
19
+ function uniqueMediaFileName(entryPath, usedDestNames) {
20
+ const base = path.posix.basename(entryPath);
21
+ if (!usedDestNames.has(base)) {
22
+ usedDestNames.add(base);
23
+ return base;
24
+ }
25
+ const ext = path.posix.extname(base);
26
+ const stem = ext ? base.slice(0, -ext.length) : base;
27
+ let n = 2;
28
+ let candidate;
29
+ do {
30
+ candidate = `${stem}_${n}${ext}`;
31
+ n += 1;
32
+ } while (usedDestNames.has(candidate));
33
+ usedDestNames.add(candidate);
34
+ return candidate;
35
+ }
36
+
37
+ /**
38
+ * 将 IR 中图片元素的 mediaPath 对齐到实际写入的文件名
39
+ * @param {import('./mapper').IntermediateRepresentation} ir
40
+ * @param {Map<string, string>} zipToRel zip 路径 → media/xxx.png
41
+ */
42
+ function applyMediaPathsToIR(ir, zipToRel) {
43
+ if (!ir?.slides) return;
44
+ for (const slide of ir.slides) {
45
+ for (const el of slide.elements) {
46
+ if (el.type === 'image' && el.zipPath && zipToRel.has(el.zipPath)) {
47
+ el.mediaPath = zipToRel.get(el.zipPath);
48
+ }
49
+ }
50
+ }
51
+ }
52
+
15
53
  /**
16
54
  * @param {PackagerInput} input
17
55
  * @param {string} outputDir
18
56
  * @param {{ noMedia?: boolean }} options
19
- * @returns {Promise<string[]>} 已写入的媒体文件相对路径列表
57
+ * @returns {Promise<{ written: string[], zipToRel: Map<string, string> }>}
20
58
  */
21
59
  async function packageMedia(input, outputDir, options = {}) {
22
- if (options.noMedia) return [];
60
+ /** @type {Map<string, string>} */
61
+ const zipToRel = new Map();
62
+
63
+ if (options.noMedia) {
64
+ return { written: [], zipToRel };
65
+ }
23
66
 
24
67
  const mediaDir = path.join(outputDir, 'media');
25
68
  fs.mkdirSync(mediaDir, { recursive: true });
26
69
 
27
70
  const written = [];
71
+ const usedDestNames = new Set();
28
72
  const { zip } = input.archive;
73
+
29
74
  for (const [entryPath, entry] of Object.entries(zip.files)) {
30
75
  if (entry.dir) continue;
31
76
  if (!entryPath.startsWith('ppt/media/')) continue;
32
- const name = path.basename(entryPath); // TODO: 重名冲突时生成唯一文件名
33
- const dest = path.join(mediaDir, name);
77
+
78
+ const destName = uniqueMediaFileName(entryPath, usedDestNames);
79
+ const relPath = path.posix.join('media', destName);
80
+ zipToRel.set(entryPath, relPath);
81
+
82
+ const dest = path.join(outputDir, relPath);
34
83
  const data = await entry.async('nodebuffer');
35
84
  fs.writeFileSync(dest, data);
36
- written.push(path.join('media', name));
85
+ written.push(relPath);
37
86
  }
38
- return written;
87
+
88
+ applyMediaPathsToIR(input.ir, zipToRel);
89
+ return { written, zipToRel };
39
90
  }
40
91
 
41
92
  /**
@@ -69,4 +120,10 @@ function writeOutputReadme(outputDir, meta) {
69
120
  fs.writeFileSync(readmePath, content, 'utf8');
70
121
  }
71
122
 
72
- module.exports = { packageMedia, writeConversionLog, writeOutputReadme };
123
+ module.exports = {
124
+ packageMedia,
125
+ applyMediaPathsToIR,
126
+ uniqueMediaFileName,
127
+ writeConversionLog,
128
+ writeOutputReadme,
129
+ };