bit-ppt-generator 0.3.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.
@@ -0,0 +1,1708 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import JSZip from "jszip";
6
+ import pptxgen from "pptxgenjs";
7
+ import { listLayouts as coreListLayouts } from "./core/layouts.mjs";
8
+ import {
9
+ expandSlidesWithReport,
10
+ getSpeakerNotesValue,
11
+ hasKnownPlaceholderRatio,
12
+ normalizeSpeakerNotes,
13
+ normalizeText,
14
+ resolvePlaceholderRatio,
15
+ } from "./core/preflight.mjs";
16
+ import { checkDeck as coreCheckDeck, validateDeck as coreValidateDeck } from "./core/validation.mjs";
17
+ import { parseDeckYaml } from "./core/yaml-parse.mjs";
18
+ const require = createRequire(import.meta.url);
19
+ const { latexToOMML } = require("latex-to-omml");
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const ROOT = path.resolve(__dirname, "..");
23
+ const W = 13.333;
24
+ const H = 7.5;
25
+
26
+ const theme = {
27
+ green: "006C39",
28
+ darkGreen: "004B28",
29
+ red: "A13F3D",
30
+ ink: "262626",
31
+ muted: "666666",
32
+ light: "F6F8F6",
33
+ line: "DDE7E2",
34
+ white: "FFFFFF",
35
+ };
36
+
37
+ const font = {
38
+ cn: "微软雅黑",
39
+ cnLight: "微软雅黑 Light",
40
+ serif: "SimSun",
41
+ en: "Arial",
42
+ code: "Consolas",
43
+ };
44
+
45
+ const defaultFont = { ...font };
46
+
47
+ const INCH_PT = 72;
48
+
49
+ const assets = {
50
+ campusLine: asset("bit-campus-line.png"),
51
+ seal: asset("bit-seal-small.png"),
52
+ wordmarkWhite: asset("bit-wordmark-white.png"),
53
+ campusPhoto: asset("bit-campus-photo.png"),
54
+ emblemGray: asset("bit-emblem-gray.png"),
55
+ };
56
+
57
+ const assetRatio = {
58
+ [assets.campusLine]: 449 / 166,
59
+ [assets.seal]: 1,
60
+ [assets.wordmarkWhite]: 901 / 252,
61
+ [assets.campusPhoto]: 640 / 426,
62
+ [assets.emblemGray]: 620 / 621,
63
+ };
64
+
65
+ const imageInfoCache = new Map();
66
+
67
+ function asset(name) {
68
+ return path.join(ROOT, "assets", name);
69
+ }
70
+
71
+ function resolveProjectPath(value, fallback) {
72
+ const source = normalizeText(value || fallback);
73
+ return path.isAbsolute(source) ? source : path.resolve(ROOT, source);
74
+ }
75
+
76
+ function normalizeImageSpec(value, fallback = "assets/bit-campus-photo.png") {
77
+ if (value && typeof value === "object" && !Array.isArray(value)) {
78
+ const mode = normalizeText(value.mode || value.type).toLowerCase();
79
+ const placeholder = mode === "placeholder" || value.placeholder === true || value.noImage === true;
80
+ return {
81
+ path: placeholder ? "" : resolveProjectPath(value.path || value.image || value.src, fallback),
82
+ placeholder,
83
+ prompt: normalizeText(value.prompt || value.description || value.imagePrompt || value.alt),
84
+ aspectRatio: normalizeText(value.aspectRatio || value.ratio || value.size).toLowerCase(),
85
+ fit: normalizeText(value.fit || value.sizing).toLowerCase(),
86
+ placement: normalizeText(value.placement || value.position).toLowerCase(),
87
+ alt: normalizeText(value.alt),
88
+ };
89
+ }
90
+ return {
91
+ path: resolveProjectPath(value, fallback),
92
+ placeholder: false,
93
+ prompt: "",
94
+ aspectRatio: "",
95
+ fit: "",
96
+ placement: "",
97
+ alt: "",
98
+ };
99
+ }
100
+
101
+ function readPngDimensions(buffer) {
102
+ if (buffer.length < 24) return null;
103
+ if (buffer[0] !== 0x89 || buffer.toString("ascii", 1, 4) !== "PNG") return null;
104
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), format: "png" };
105
+ }
106
+
107
+ function readGifDimensions(buffer) {
108
+ if (buffer.length < 10) return null;
109
+ const signature = buffer.toString("ascii", 0, 6);
110
+ if (signature !== "GIF87a" && signature !== "GIF89a") return null;
111
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8), format: "gif" };
112
+ }
113
+
114
+ function readJpegDimensions(buffer) {
115
+ if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
116
+ let offset = 2;
117
+ while (offset + 9 < buffer.length) {
118
+ while (buffer[offset] === 0xff) offset += 1;
119
+ const marker = buffer[offset];
120
+ offset += 1;
121
+ if (marker === 0xd9 || marker === 0xda) break;
122
+ const size = buffer.readUInt16BE(offset);
123
+ if (size < 2 || offset + size > buffer.length) break;
124
+ if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
125
+ return {
126
+ width: buffer.readUInt16BE(offset + 5),
127
+ height: buffer.readUInt16BE(offset + 3),
128
+ format: "jpeg",
129
+ };
130
+ }
131
+ offset += size;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function readWebpDimensions(buffer) {
137
+ if (buffer.length < 30 || buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP") return null;
138
+ const chunk = buffer.toString("ascii", 12, 16);
139
+ if (chunk === "VP8X" && buffer.length >= 30) {
140
+ return {
141
+ width: 1 + buffer.readUIntLE(24, 3),
142
+ height: 1 + buffer.readUIntLE(27, 3),
143
+ format: "webp",
144
+ };
145
+ }
146
+ if (chunk === "VP8 " && buffer.length >= 30) {
147
+ return {
148
+ width: buffer.readUInt16LE(26) & 0x3fff,
149
+ height: buffer.readUInt16LE(28) & 0x3fff,
150
+ format: "webp",
151
+ };
152
+ }
153
+ if (chunk === "VP8L" && buffer.length >= 25) {
154
+ const bits = buffer.readUInt32LE(21);
155
+ return {
156
+ width: 1 + (bits & 0x3fff),
157
+ height: 1 + ((bits >> 14) & 0x3fff),
158
+ format: "webp",
159
+ };
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function readImageDimensions(imagePath) {
165
+ if (imageInfoCache.has(imagePath)) return imageInfoCache.get(imagePath);
166
+ let result = null;
167
+ try {
168
+ const buffer = fs.readFileSync(imagePath);
169
+ result = readPngDimensions(buffer) || readJpegDimensions(buffer) || readGifDimensions(buffer) || readWebpDimensions(buffer);
170
+ } catch {
171
+ result = null;
172
+ }
173
+ imageInfoCache.set(imagePath, result);
174
+ return result;
175
+ }
176
+
177
+ function classifyImageRatio(ratio) {
178
+ if (!Number.isFinite(ratio) || ratio <= 0) return "unknown";
179
+ if (ratio >= 2.15) return "panoramic";
180
+ if (ratio >= 1.18) return "landscape";
181
+ if (ratio <= 0.52) return "tall";
182
+ if (ratio <= 0.84) return "portrait";
183
+ return "square";
184
+ }
185
+
186
+ function resolveImageInfo(value, fallback = "assets/bit-campus-photo.png") {
187
+ const spec = normalizeImageSpec(value, fallback);
188
+ if (spec.placeholder) {
189
+ const ratio = resolvePlaceholderRatio(spec.aspectRatio);
190
+ return {
191
+ ...spec,
192
+ exists: true,
193
+ width: undefined,
194
+ height: undefined,
195
+ format: "placeholder",
196
+ ratio,
197
+ orientation: classifyImageRatio(ratio),
198
+ uncertainRatio: !hasKnownPlaceholderRatio(spec.aspectRatio),
199
+ };
200
+ }
201
+ const dimensions = readImageDimensions(spec.path);
202
+ const ratio = dimensions?.width && dimensions?.height
203
+ ? dimensions.width / dimensions.height
204
+ : assetRatio[spec.path] || 1;
205
+ return {
206
+ ...spec,
207
+ exists: fs.existsSync(spec.path),
208
+ width: dimensions?.width,
209
+ height: dimensions?.height,
210
+ format: dimensions?.format,
211
+ ratio,
212
+ orientation: classifyImageRatio(ratio),
213
+ };
214
+ }
215
+
216
+ function fitBoxToRatio(box, ratio = 1) {
217
+ let { x, y, w, h } = box;
218
+ const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
219
+ const boxRatio = w / h;
220
+ if (boxRatio > safeRatio) {
221
+ const fittedW = h * safeRatio;
222
+ x += (w - fittedW) / 2;
223
+ w = fittedW;
224
+ } else {
225
+ const fittedH = w / safeRatio;
226
+ y += (h - fittedH) / 2;
227
+ h = fittedH;
228
+ }
229
+ return { x, y, w, h };
230
+ }
231
+
232
+ function imageFitMode(image, fallback = "cover") {
233
+ const value = normalizeText(image?.fit).toLowerCase();
234
+ if (["contain", "fit", "inside"].includes(value)) return "contain";
235
+ if (["cover", "crop", "fill"].includes(value)) return "cover";
236
+ return fallback;
237
+ }
238
+
239
+ function readDeck(inputFile) {
240
+ const raw = fs.readFileSync(inputFile, "utf8");
241
+ return parseDeckYaml(raw, inputFile);
242
+ }
243
+
244
+ function addSpeakerNotes(pptx, slideData) {
245
+ const notes = normalizeSpeakerNotes(getSpeakerNotesValue(slideData));
246
+ if (!notes) return;
247
+ const renderedSlide = pptx.slides?.[pptx.slides.length - 1];
248
+ if (renderedSlide && typeof renderedSlide.addNotes === "function") renderedSlide.addNotes(notes);
249
+ }
250
+
251
+ function normalizeLatex(value) {
252
+ return normalizeText(value)
253
+ .replace(/\\\\([A-Za-z]+)/g, "\\$1")
254
+ .replace(/([_^])\\([A-Za-z]+)(?![A-Za-z{])/g, "$1{\\$2}")
255
+ .replace(/([_^])([A-Za-z0-9])(?![A-Za-z0-9{])/g, "$1{$2}");
256
+ }
257
+
258
+ function normalizeFonts(fonts = {}) {
259
+ const normalized = {};
260
+ if (fonts.cn) normalized.cn = normalizeText(fonts.cn);
261
+ if (fonts.cjk) normalized.cn = normalizeText(fonts.cjk);
262
+ if (fonts.cnLight) normalized.cnLight = normalizeText(fonts.cnLight);
263
+ if (fonts.cjkLight) normalized.cnLight = normalizeText(fonts.cjkLight);
264
+ if (fonts.en) normalized.en = normalizeText(fonts.en);
265
+ if (fonts.latin) normalized.en = normalizeText(fonts.latin);
266
+ if (fonts.serif) normalized.serif = normalizeText(fonts.serif);
267
+ if (fonts.code) normalized.code = normalizeText(fonts.code);
268
+ return Object.fromEntries(Object.entries(normalized).filter(([, value]) => value));
269
+ }
270
+
271
+ function configureFonts(deck = {}, options = {}) {
272
+ const merged = {
273
+ ...defaultFont,
274
+ ...normalizeFonts(deck.meta?.fonts || {}),
275
+ ...normalizeFonts(options.fonts || {}),
276
+ };
277
+ if (options.fontCn) merged.cn = normalizeText(options.fontCn);
278
+ if (options.fontCjk) merged.cn = normalizeText(options.fontCjk);
279
+ if (options.fontCnLight) merged.cnLight = normalizeText(options.fontCnLight);
280
+ if (options.fontEn) merged.en = normalizeText(options.fontEn);
281
+ if (options.fontSerif) merged.serif = normalizeText(options.fontSerif);
282
+ if (options.fontCode) merged.code = normalizeText(options.fontCode);
283
+ Object.assign(font, merged);
284
+ return { ...font };
285
+ }
286
+
287
+ function isCjk(ch) {
288
+ return /[\u3400-\u9fff\uff00-\uffef]/u.test(ch);
289
+ }
290
+
291
+ function charWidthPt(ch, fontSize, bold = false) {
292
+ const factor = bold ? 1.06 : 1;
293
+ if (ch === "\n") return Infinity;
294
+ if (/\s/.test(ch)) return fontSize * 0.33;
295
+ if (isCjk(ch)) return fontSize * 1.0 * factor;
296
+ if (/[A-Z]/.test(ch)) return fontSize * 0.64 * factor;
297
+ if (/[0-9]/.test(ch)) return fontSize * 0.58 * factor;
298
+ if (/[.,;:!?()[\]{}'"`/\\|+-]/.test(ch)) return fontSize * 0.38 * factor;
299
+ return fontSize * 0.54 * factor;
300
+ }
301
+
302
+ function estimateText(text, boxW, fontSize, opts = {}) {
303
+ const maxPt = Math.max(1, boxW * INCH_PT - (opts.marginPt || 0));
304
+ const lineHeight = opts.lineHeight || 1.18;
305
+ const value = normalizeText(text);
306
+ let lines = 1;
307
+ let current = 0;
308
+ for (const ch of value) {
309
+ const width = charWidthPt(ch, fontSize, opts.bold);
310
+ if (!Number.isFinite(width)) {
311
+ lines += 1;
312
+ current = 0;
313
+ continue;
314
+ }
315
+ if (current > 0 && current + width > maxPt) {
316
+ lines += 1;
317
+ current = width;
318
+ } else {
319
+ current += width;
320
+ }
321
+ }
322
+ return {
323
+ lines,
324
+ height: (lines * fontSize * lineHeight) / INCH_PT,
325
+ };
326
+ }
327
+
328
+ function estimateBulletHeight(text, boxW, fontSize) {
329
+ return estimateText(text, Math.max(0.5, boxW - 0.34), fontSize, { lineHeight: 1.22 }).height + 0.14;
330
+ }
331
+
332
+ function addText(slide, text, x, y, w, h, opts = {}) {
333
+ slide.addText(normalizeText(text), {
334
+ x,
335
+ y,
336
+ w,
337
+ h,
338
+ margin: 0,
339
+ fontFace: opts.fontFace || font.cn,
340
+ fontSize: opts.fontSize || 18,
341
+ color: opts.color || theme.ink,
342
+ bold: opts.bold || false,
343
+ breakLine: opts.breakLine || false,
344
+ valign: opts.valign || "mid",
345
+ fit: opts.fit || "shrink",
346
+ ...opts,
347
+ });
348
+ }
349
+
350
+ function addRichText(slide, runs, x, y, w, h, opts = {}) {
351
+ slide.addText(runs, {
352
+ x,
353
+ y,
354
+ w,
355
+ h,
356
+ margin: 0,
357
+ fontFace: opts.fontFace || font.cn,
358
+ fontSize: opts.fontSize || 18,
359
+ color: opts.color || theme.ink,
360
+ valign: opts.valign || "mid",
361
+ fit: opts.fit || "shrink",
362
+ ...opts,
363
+ });
364
+ }
365
+
366
+ function registerEquation(ctx, latex, opts = {}) {
367
+ const marker = `__BIT_OMML_${ctx.equations.length + 1}__`;
368
+ ctx.equations.push({
369
+ marker,
370
+ latex: normalizeLatex(latex),
371
+ display: opts.display !== false,
372
+ });
373
+ return marker;
374
+ }
375
+
376
+ function inlineMathRuns(text, baseOptions, ctx) {
377
+ const source = normalizeText(text);
378
+ const runs = [];
379
+ const pattern = /\$([^$]+)\$|\\\((.+?)\\\)/g;
380
+ let last = 0;
381
+ let match;
382
+ while ((match = pattern.exec(source))) {
383
+ if (match.index > last) {
384
+ runs.push({ text: source.slice(last, match.index), options: baseOptions });
385
+ }
386
+ const latex = match[1] || match[2];
387
+ runs.push({
388
+ text: registerEquation(ctx, latex, { display: false }),
389
+ options: { ...baseOptions, fontFace: font.en },
390
+ });
391
+ last = pattern.lastIndex;
392
+ }
393
+ if (last < source.length) {
394
+ runs.push({ text: source.slice(last), options: baseOptions });
395
+ }
396
+ return runs.length ? runs : [{ text: source, options: baseOptions }];
397
+ }
398
+
399
+ function inlineMathTableCell(text, cellOptions, ctx) {
400
+ if (!ctx) return normalizeText(text);
401
+ const runs = inlineMathRuns(text, cellOptions, ctx);
402
+ return runs.length === 1 && runs[0].text === normalizeText(text) ? normalizeText(text) : runs;
403
+ }
404
+
405
+ function addInlineMathText(slide, text, x, y, w, h, opts, ctx) {
406
+ addRichText(slide, inlineMathRuns(text, opts, ctx), x, y, w, h, opts);
407
+ }
408
+
409
+ function addImageFit(slide, imagePath, box, opts = {}) {
410
+ const dimensions = readImageDimensions(imagePath);
411
+ const ratio = opts.ratio || (dimensions?.width && dimensions?.height ? dimensions.width / dimensions.height : assetRatio[imagePath]) || 1;
412
+ const { x, y, w, h } = fitBoxToRatio(box, ratio);
413
+ slide.addImage({ path: imagePath, x, y, w, h, transparency: opts.transparency || 0 });
414
+ }
415
+
416
+ function addImageCover(slide, imagePath, box, opts = {}) {
417
+ slide.addImage({
418
+ path: imagePath,
419
+ x: box.x,
420
+ y: box.y,
421
+ w: box.w,
422
+ h: box.h,
423
+ transparency: opts.transparency || 0,
424
+ sizing: { type: "cover", x: box.x, y: box.y, w: box.w, h: box.h },
425
+ });
426
+ }
427
+
428
+ function placeholderAspectLabel(image) {
429
+ const label = normalizeText(image.aspectRatio);
430
+ if (label && label !== "auto" && label !== "unknown") return label;
431
+ return `${image.ratio.toFixed(2)}:1`;
432
+ }
433
+
434
+ function addImagePlaceholder(slide, image, box, opts = {}) {
435
+ const fill = opts.fill || "F8FBF9";
436
+ const line = opts.line || theme.green;
437
+ slide.addShape("rect", {
438
+ x: box.x,
439
+ y: box.y,
440
+ w: box.w,
441
+ h: box.h,
442
+ fill: { color: fill },
443
+ line: { color: line, width: opts.lineWidth || 1.0, dashType: "dash" },
444
+ });
445
+ slide.addShape("line", { x: box.x, y: box.y, w: box.w, h: box.h, line: { color: theme.line, width: 0.6, transparency: 20 } });
446
+ slide.addShape("line", { x: box.x + box.w, y: box.y, w: -box.w, h: box.h, line: { color: theme.line, width: 0.6, transparency: 20 } });
447
+ addText(slide, "待补图片", box.x + 0.18, box.y + 0.22, box.w - 0.36, 0.32, {
448
+ fontSize: Math.min(16, Math.max(10, box.w * 2.25)),
449
+ bold: true,
450
+ color: theme.green,
451
+ align: "center",
452
+ fit: "shrink",
453
+ });
454
+ addText(slide, `建议比例 ${placeholderAspectLabel(image)}`, box.x + 0.18, box.y + 0.62, box.w - 0.36, 0.22, {
455
+ fontSize: 8.8,
456
+ color: theme.muted,
457
+ align: "center",
458
+ fontFace: font.en,
459
+ fit: "shrink",
460
+ });
461
+ const prompt = image.prompt || image.alt || "请替换为与本页主题匹配的图片。";
462
+ addText(slide, prompt, box.x + 0.28, box.y + Math.min(1.02, box.h * 0.38), box.w - 0.56, Math.max(0.34, box.h - 1.22), {
463
+ fontSize: Math.min(11, Math.max(7.5, box.w * 1.35)),
464
+ color: theme.ink,
465
+ align: "center",
466
+ valign: "mid",
467
+ fit: "shrink",
468
+ });
469
+ }
470
+
471
+ function addImageInPanel(slide, image, box, opts = {}) {
472
+ const mode = imageFitMode(image, opts.fit || "cover");
473
+ if (image.placeholder) {
474
+ const placeholderBox = mode === "contain" ? fitBoxToRatio(box, image.ratio) : box;
475
+ const pad = mode === "contain" ? opts.pad ?? 0.12 : 0;
476
+ const outerBox = {
477
+ x: placeholderBox.x - pad,
478
+ y: placeholderBox.y - pad,
479
+ w: placeholderBox.w + pad * 2,
480
+ h: placeholderBox.h + pad * 2,
481
+ };
482
+ addImagePlaceholder(slide, image, outerBox, opts);
483
+ return outerBox;
484
+ }
485
+ if (mode === "contain") {
486
+ const imageBox = fitBoxToRatio(box, image.ratio);
487
+ const pad = opts.pad ?? 0.12;
488
+ slide.addShape("rect", {
489
+ x: imageBox.x - pad,
490
+ y: imageBox.y - pad,
491
+ w: imageBox.w + pad * 2,
492
+ h: imageBox.h + pad * 2,
493
+ fill: { color: opts.fill || theme.light },
494
+ line: { color: opts.line || theme.line, width: opts.lineWidth || 0.8 },
495
+ });
496
+ addImageFit(slide, image.path, imageBox, { ratio: image.ratio, transparency: opts.transparency || 0 });
497
+ return imageBox;
498
+ }
499
+ slide.addShape("rect", {
500
+ x: box.x,
501
+ y: box.y,
502
+ w: box.w,
503
+ h: box.h,
504
+ fill: { color: opts.fill || theme.light },
505
+ line: { color: opts.line || theme.line, width: opts.lineWidth || 0.8 },
506
+ });
507
+ addImageCover(slide, image.path, box, { transparency: opts.transparency || 0 });
508
+ return box;
509
+ }
510
+
511
+ function addCoverBrand(slide) {
512
+ slide.background = { color: theme.green };
513
+ addImageCover(slide, assets.campusPhoto, { x: 7.25, y: 0, w: 6.08, h: 7.5 }, { transparency: 12 });
514
+ slide.addShape("rect", { x: 7.25, y: 0, w: 6.08, h: 7.5, fill: { color: theme.darkGreen, transparency: 22 }, line: { transparency: 100 } });
515
+ addImageFit(slide, assets.wordmarkWhite, { x: 0.72, y: 0.5, w: 3.95, h: 1.08 });
516
+ slide.addShape("line", { x: 0.78, y: 6.78, w: 3.8, h: 0, line: { color: theme.white, width: 1, transparency: 25 } });
517
+ }
518
+
519
+ function addPageBrand(slide, pageNo) {
520
+ slide.background = { color: theme.white };
521
+ slide.addShape("rect", { x: 0, y: 0, w: W, h: 0.18, fill: { color: theme.green }, line: { transparency: 100 } });
522
+ addImageFit(slide, assets.campusLine, { x: 10.14, y: 0.36, w: 2.42, h: 0.86 }, { transparency: 4 });
523
+ slide.addImage({ path: assets.seal, x: 12.46, y: 6.72, w: 0.32, h: 0.32, transparency: 4 });
524
+ addText(slide, String(pageNo).padStart(2, "0"), 11.94, 6.77, 0.38, 0.18, {
525
+ fontSize: 8,
526
+ color: theme.muted,
527
+ align: "right",
528
+ });
529
+ slide.addShape("line", { x: 0.72, y: 6.95, w: 10.95, h: 0, line: { color: theme.line, width: 0.75 } });
530
+ }
531
+
532
+ function addTitle(slide, title, subtitle) {
533
+ addText(slide, title, 0.72, 0.64, 8.4, 0.44, {
534
+ fontSize: 18,
535
+ bold: true,
536
+ color: theme.green,
537
+ });
538
+ if (subtitle) {
539
+ addText(slide, subtitle, 0.74, 1.04, 8.8, 0.24, {
540
+ fontSize: 8.5,
541
+ color: theme.muted,
542
+ fontFace: font.en,
543
+ });
544
+ }
545
+ slide.addShape("line", { x: 0.72, y: 1.34, w: 3.8, h: 0, line: { color: theme.green, width: 1.0 } });
546
+ }
547
+
548
+ function addBulletList(slide, bullets, x, y, w, gap = 0.58, options = {}, ctx = null) {
549
+ const size = options.fontSize || 16;
550
+ bullets.forEach((item, idx) => {
551
+ const top = y + idx * gap;
552
+ slide.addShape("ellipse", {
553
+ x,
554
+ y: top + 0.12,
555
+ w: 0.12,
556
+ h: 0.12,
557
+ fill: { color: options.color || theme.green },
558
+ line: { transparency: 100 },
559
+ });
560
+ const textOptions = {
561
+ fontSize: size,
562
+ color: options.textColor || theme.ink,
563
+ valign: "top",
564
+ fit: "shrink",
565
+ };
566
+ if (ctx) addInlineMathText(slide, item, x + 0.28, top, w - 0.28, gap - 0.03, textOptions, ctx);
567
+ else addText(slide, item, x + 0.28, top, w - 0.28, gap - 0.03, textOptions);
568
+ });
569
+ }
570
+
571
+ function addFlowBulletList(slide, bullets, x, y, w, maxH, options = {}, ctx = null) {
572
+ const size = options.fontSize || 16;
573
+ let top = y;
574
+ bullets.forEach((item) => {
575
+ const itemH = Math.min(maxH - (top - y), estimateBulletHeight(item, w, size));
576
+ if (itemH <= 0.12) return;
577
+ slide.addShape("ellipse", {
578
+ x,
579
+ y: top + 0.13,
580
+ w: 0.12,
581
+ h: 0.12,
582
+ fill: { color: options.color || theme.green },
583
+ line: { transparency: 100 },
584
+ });
585
+ const textOptions = {
586
+ fontSize: size,
587
+ color: options.textColor || theme.ink,
588
+ valign: "top",
589
+ fit: "shrink",
590
+ };
591
+ if (ctx) addInlineMathText(slide, item, x + 0.28, top, w - 0.28, itemH, textOptions, ctx);
592
+ else addText(slide, item, x + 0.28, top, w - 0.28, itemH, textOptions);
593
+ top += itemH;
594
+ });
595
+ }
596
+
597
+ function addCompactBulletList(slide, bullets, x, y, w, options = {}, ctx = null) {
598
+ const size = options.fontSize || 10;
599
+ const gap = options.gap || 0.28;
600
+ const dot = options.dotSize || 0.09;
601
+ bullets.forEach((item, idx) => {
602
+ const top = y + idx * gap;
603
+ slide.addShape("ellipse", {
604
+ x,
605
+ y: top + 0.1,
606
+ w: dot,
607
+ h: dot,
608
+ fill: { color: options.color || theme.green },
609
+ line: { transparency: 100 },
610
+ });
611
+ const textOptions = {
612
+ fontSize: size,
613
+ color: options.textColor || theme.ink,
614
+ valign: "mid",
615
+ fit: "shrink",
616
+ };
617
+ if (ctx) addInlineMathText(slide, item, x + 0.26, top, w - 0.26, 0.22, textOptions, ctx);
618
+ else addText(slide, item, x + 0.26, top, w - 0.26, 0.22, textOptions);
619
+ });
620
+ }
621
+
622
+ function layoutTitle(pptx, deck) {
623
+ const slide = pptx.addSlide();
624
+ addCoverBrand(slide);
625
+ addText(slide, deck.meta?.title, 0.78, 2.08, 7.75, 1.16, {
626
+ fontSize: 30,
627
+ bold: true,
628
+ color: theme.white,
629
+ valign: "top",
630
+ });
631
+ addText(slide, deck.meta?.subtitle, 0.82, 3.48, 7.2, 0.54, {
632
+ fontSize: 15,
633
+ color: theme.white,
634
+ transparency: 7,
635
+ });
636
+ const info = [deck.meta?.author, deck.meta?.advisor, deck.meta?.date].filter(Boolean).join(" | ");
637
+ addText(slide, info, 0.82, 6.42, 7.3, 0.28, {
638
+ fontSize: 11,
639
+ color: theme.white,
640
+ });
641
+ }
642
+
643
+ function layoutAgenda(pptx, slideData, pageNo) {
644
+ const slide = pptx.addSlide();
645
+ addPageBrand(slide, pageNo);
646
+ addTitle(slide, slideData.title || "目录", "CONTENTS");
647
+ const items = slideData.items || [];
648
+ items.forEach((item, idx) => {
649
+ const y = 1.78 + idx * 0.78;
650
+ addText(slide, String(idx + 1).padStart(2, "0"), 1.08, y, 0.64, 0.4, {
651
+ fontSize: 18,
652
+ bold: true,
653
+ color: theme.green,
654
+ fontFace: font.en,
655
+ });
656
+ slide.addShape("line", { x: 1.82, y: y + 0.22, w: 0.75, h: 0, line: { color: theme.green, width: 1.3 } });
657
+ addText(slide, item, 2.78, y - 0.03, 7.4, 0.48, {
658
+ fontSize: 19,
659
+ bold: true,
660
+ color: theme.ink,
661
+ });
662
+ });
663
+ }
664
+
665
+ function layoutSection(pptx, slideData, pageNo) {
666
+ const slide = pptx.addSlide();
667
+ slide.background = { color: theme.white };
668
+ slide.addShape("rect", { x: 0, y: 0, w: W, h: H, fill: { color: theme.green }, line: { transparency: 100 } });
669
+ slide.addShape("rect", { x: 0.52, y: 0.44, w: 12.28, h: 6.62, fill: { color: theme.white, transparency: 1 }, line: { color: "DCE9E2", width: 0.8 } });
670
+ addImageFit(slide, assets.emblemGray, { x: 8.75, y: 1.08, w: 3.72, h: 3.72 }, { transparency: 74 });
671
+ addText(slide, slideData.sectionNo || String(pageNo).padStart(2, "0"), 1.1, 1.7, 1.9, 0.72, {
672
+ fontSize: 34,
673
+ bold: true,
674
+ color: theme.green,
675
+ fontFace: font.en,
676
+ });
677
+ addText(slide, slideData.title, 1.08, 2.62, 8.2, 0.64, {
678
+ fontSize: 26,
679
+ bold: true,
680
+ color: theme.ink,
681
+ });
682
+ addText(slide, slideData.subtitle || "", 1.12, 3.38, 7.6, 0.42, {
683
+ fontSize: 13,
684
+ color: theme.muted,
685
+ });
686
+ slide.addShape("line", { x: 1.1, y: 4.18, w: 3.1, h: 0, line: { color: theme.green, width: 2.3 } });
687
+ }
688
+
689
+ function layoutBullets(pptx, slideData, pageNo, ctx) {
690
+ const slide = pptx.addSlide();
691
+ addPageBrand(slide, pageNo);
692
+ addTitle(slide, slideData.title, "KEY FINDINGS");
693
+ const startY = slideData.lead ? 2.92 : 1.72;
694
+ if (slideData.lead) {
695
+ slide.addShape("rect", { x: 0.92, y: 1.72, w: 11.35, h: 0.72, fill: { color: "FAFCFB" }, line: { color: theme.line, width: 0.6 } });
696
+ addInlineMathText(slide, slideData.lead, 1.18, 1.86, 10.8, 0.44, { fontSize: 15, color: theme.ink }, ctx);
697
+ }
698
+ addFlowBulletList(slide, slideData.bullets || [], 1.28, startY, 10.4, 6.62 - startY, { fontSize: slideData.fontSize || 16 }, ctx);
699
+ }
700
+
701
+ function layoutTable(pptx, slideData, pageNo, ctx) {
702
+ const slide = pptx.addSlide();
703
+ addPageBrand(slide, pageNo);
704
+ addTitle(slide, slideData.title, "DATA TABLE");
705
+ const columns = slideData.columns || [];
706
+ const rows = slideData.rows || [];
707
+ const table = [
708
+ columns.map((text) => {
709
+ const options = {
710
+ bold: true,
711
+ color: theme.white,
712
+ fill: { color: theme.green },
713
+ fontFace: font.cn,
714
+ };
715
+ return { text: inlineMathTableCell(text, options, ctx), options };
716
+ }),
717
+ ...rows.map((row, rIdx) =>
718
+ row.map((text) => {
719
+ const options = {
720
+ color: theme.ink,
721
+ fill: { color: rIdx % 2 === 0 ? "FFFFFF" : "F7FAF8" },
722
+ fontFace: font.cn,
723
+ };
724
+ return { text: inlineMathTableCell(text, options, ctx), options };
725
+ }),
726
+ ),
727
+ ];
728
+ slide.addTable(table, {
729
+ x: 0.76,
730
+ y: 1.68,
731
+ w: 11.82,
732
+ h: Math.min(4.95, 0.52 + rows.length * 0.68),
733
+ border: { type: "solid", color: "C9D8D0", pt: 0.6 },
734
+ margin: 0.08,
735
+ fontSize: rows.length > 5 ? 8.5 : 10,
736
+ valign: "mid",
737
+ fit: "shrink",
738
+ rowH: 0.52,
739
+ });
740
+ }
741
+
742
+ function layoutClaim(pptx, slideData, pageNo, ctx) {
743
+ const slide = pptx.addSlide();
744
+ addPageBrand(slide, pageNo);
745
+ addTitle(slide, slideData.title, "MAIN CLAIM");
746
+ addInlineMathText(slide, slideData.claim, 1.02, 1.78, 10.6, 1.16, {
747
+ fontSize: 26,
748
+ bold: true,
749
+ color: theme.green,
750
+ valign: "mid",
751
+ }, ctx);
752
+ slide.addShape("line", { x: 1.04, y: 3.12, w: 4.1, h: 0, line: { color: theme.green, width: 1.3 } });
753
+ addFlowBulletList(slide, slideData.evidence || [], 1.3, 3.58, 10.2, 2.58, { fontSize: 15 }, ctx);
754
+ }
755
+
756
+ function layoutTwoColumn(pptx, slideData, pageNo, ctx) {
757
+ const slide = pptx.addSlide();
758
+ addPageBrand(slide, pageNo);
759
+ addTitle(slide, slideData.title, "TWO COLUMNS");
760
+ addColumnBlock(slide, slideData.left || {}, 0.9, 1.72, 5.35, 4.55, theme.green, ctx);
761
+ addColumnBlock(slide, slideData.right || {}, 7.06, 1.72, 5.35, 4.55, theme.red, ctx);
762
+ slide.addShape("line", { x: 6.67, y: 1.88, w: 0, h: 4.2, line: { color: theme.line, width: 1.1 } });
763
+ }
764
+
765
+ function addColumnBlock(slide, data, x, y, w, h, accent, ctx) {
766
+ addText(slide, data.title || "", x, y, w, 0.36, { fontSize: 17, bold: true, color: accent });
767
+ slide.addShape("line", { x, y: y + 0.5, w: 1.7, h: 0, line: { color: accent, width: 1.2 } });
768
+ if (data.text) {
769
+ addInlineMathText(slide, data.text, x, y + 0.82, w, h - 0.82, { fontSize: 13.5, color: theme.ink, valign: "top" }, ctx);
770
+ } else {
771
+ addFlowBulletList(slide, data.bullets || [], x + 0.12, y + 0.82, w - 0.18, h - 0.82, { fontSize: 13.2, color: accent }, ctx);
772
+ }
773
+ }
774
+
775
+ function layoutCards(pptx, slideData, pageNo, ctx) {
776
+ const slide = pptx.addSlide();
777
+ addPageBrand(slide, pageNo);
778
+ addTitle(slide, slideData.title, "KEY POINTS");
779
+ const cards = (slideData.cards || []).slice(0, 6);
780
+ const cols = cards.length <= 3 ? cards.length || 1 : 3;
781
+ const rows = Math.ceil(cards.length / cols);
782
+ const gap = 0.25;
783
+ const totalW = 11.7;
784
+ const cardW = (totalW - gap * (cols - 1)) / cols;
785
+ const cardH = rows === 1 ? 3.6 : 2.05;
786
+ cards.forEach((card, idx) => {
787
+ const col = idx % cols;
788
+ const row = Math.floor(idx / cols);
789
+ const x = 0.82 + col * (cardW + gap);
790
+ const y = 1.78 + row * (cardH + 0.32);
791
+ slide.addShape("rect", { x, y, w: cardW, h: cardH, fill: { color: idx % 2 ? "F9FBFA" : "FFFFFF" }, line: { color: theme.line, width: 0.8 } });
792
+ addText(slide, card.title || `观点 ${idx + 1}`, x + 0.22, y + 0.2, cardW - 0.44, 0.36, { fontSize: 15, bold: true, color: theme.green });
793
+ addInlineMathText(slide, card.text || "", x + 0.22, y + 0.76, cardW - 0.44, cardH - 0.98, { fontSize: rows === 1 ? 12.5 : 10.8, color: theme.ink, valign: "top" }, ctx);
794
+ });
795
+ }
796
+
797
+ function layoutTimeline(pptx, slideData, pageNo, ctx) {
798
+ const slide = pptx.addSlide();
799
+ addPageBrand(slide, pageNo);
800
+ addTitle(slide, slideData.title, "TIMELINE");
801
+ const items = (slideData.items || []).slice(0, 6);
802
+ const x0 = 1.05;
803
+ const y = 3.2;
804
+ const step = items.length > 1 ? 10.8 / (items.length - 1) : 0;
805
+ slide.addShape("line", { x: x0, y, w: 10.8, h: 0, line: { color: theme.green, width: 2.0 } });
806
+ items.forEach((item, idx) => {
807
+ const x = x0 + idx * step;
808
+ slide.addShape("ellipse", { x: x - 0.12, y: y - 0.12, w: 0.24, h: 0.24, fill: { color: theme.green }, line: { color: theme.white, width: 1 } });
809
+ addText(slide, item.date || item.phase || String(idx + 1), x - 0.68, y - 0.72, 1.36, 0.26, { fontSize: 9, color: theme.green, bold: true, align: "center" });
810
+ addText(slide, item.title || "", x - 0.78, y + 0.32, 1.56, 0.36, { fontSize: 11.5, color: theme.ink, bold: true, align: "center" });
811
+ addInlineMathText(slide, item.text || "", x - 0.86, y + 0.82, 1.72, 1.05, { fontSize: 8.5, color: theme.muted, align: "center", valign: "top" }, ctx);
812
+ });
813
+ }
814
+
815
+ function layoutMetrics(pptx, slideData, pageNo, ctx) {
816
+ const slide = pptx.addSlide();
817
+ addPageBrand(slide, pageNo);
818
+ addTitle(slide, slideData.title, "METRICS");
819
+ const metrics = (slideData.metrics || []).slice(0, 4);
820
+ const cardW = 2.72;
821
+ metrics.forEach((metric, idx) => {
822
+ const x = 1.02 + idx * 3.02;
823
+ slide.addShape("rect", { x, y: 2.05, w: cardW, h: 2.35, fill: { color: idx % 2 ? "F9FBFA" : "FFFFFF" }, line: { color: theme.line, width: 0.8 } });
824
+ addText(slide, metric.value || "", x + 0.18, 2.42, cardW - 0.36, 0.72, { fontSize: 28, bold: true, color: theme.green, align: "center", fontFace: font.en });
825
+ addText(slide, metric.label || "", x + 0.22, 3.24, cardW - 0.44, 0.34, { fontSize: 13.5, bold: true, color: theme.ink, align: "center" });
826
+ addInlineMathText(slide, metric.note || "", x + 0.22, 3.75, cardW - 0.44, 0.38, { fontSize: 9.5, color: theme.muted, align: "center" }, ctx);
827
+ });
828
+ }
829
+
830
+ function layoutQuote(pptx, slideData, pageNo, ctx) {
831
+ const slide = pptx.addSlide();
832
+ addPageBrand(slide, pageNo);
833
+ addTitle(slide, slideData.title || "观点引用", "QUOTE");
834
+ addText(slide, "“", 1.02, 1.72, 0.7, 0.65, { fontSize: 38, color: theme.green, fontFace: font.serif });
835
+ addInlineMathText(slide, slideData.quote, 1.52, 2.08, 9.95, 2.15, { fontSize: 22, color: theme.ink, fontFace: font.serif, valign: "mid" }, ctx);
836
+ slide.addShape("line", { x: 1.54, y: 4.56, w: 2.8, h: 0, line: { color: theme.green, width: 1.2 } });
837
+ addText(slide, slideData.source || "", 1.54, 4.82, 7.6, 0.38, { fontSize: 12, color: theme.muted });
838
+ }
839
+
840
+ function layoutFormula(pptx, slideData, pageNo, ctx) {
841
+ const slide = pptx.addSlide();
842
+ addPageBrand(slide, pageNo);
843
+ addTitle(slide, slideData.title || "公式", "FORMULA");
844
+ const latex = slideData.formula?.latex || slideData.formula || "";
845
+ slide.addShape("rect", { x: 1.0, y: 1.82, w: 11.35, h: 1.85, fill: { color: "FAFCFB" }, line: { color: theme.line, width: 0.7 } });
846
+ if (latex) {
847
+ addText(slide, registerEquation(ctx, latex, { display: true }), 1.28, 2.26, 10.8, 0.9, {
848
+ fontSize: 24,
849
+ color: theme.ink,
850
+ align: "center",
851
+ fontFace: font.en,
852
+ });
853
+ }
854
+ if (slideData.caption) {
855
+ addText(slide, slideData.caption, 1.08, 3.82, 11.05, 0.32, { fontSize: 11, color: theme.muted, align: "center" });
856
+ }
857
+ const explanation = slideData.explanation || slideData.notes || [];
858
+ explanation.forEach((item, idx) => {
859
+ const y = 4.55 + idx * 0.48;
860
+ slide.addShape("ellipse", { x: 1.28, y: y + 0.13, w: 0.12, h: 0.12, fill: { color: theme.green }, line: { transparency: 100 } });
861
+ addInlineMathText(slide, item, 1.56, y, 10.08, 0.38, { fontSize: 13.2, color: theme.ink, valign: "top" }, ctx);
862
+ });
863
+ }
864
+
865
+ function layoutReferences(pptx, slideData, pageNo) {
866
+ const slide = pptx.addSlide();
867
+ addPageBrand(slide, pageNo);
868
+ addTitle(slide, slideData.title || "参考文献", "REFERENCES");
869
+ const fontSize = slideData.fontSize || 9.5;
870
+ let y = 1.72;
871
+ (slideData.items || []).forEach((item, idx) => {
872
+ const h = estimateText(item, 10.75, fontSize, { lineHeight: 1.18 }).height + 0.11;
873
+ addText(slide, `[${idx + 1}]`, 0.9, y, 0.52, 0.18, { fontSize, color: theme.green, fontFace: font.en, bold: true });
874
+ addText(slide, item, 1.48, y, 10.8, h, { fontSize, color: theme.ink, valign: "top" });
875
+ y += h;
876
+ });
877
+ }
878
+
879
+ function layoutMatrix(pptx, slideData, pageNo, ctx) {
880
+ const slide = pptx.addSlide();
881
+ addPageBrand(slide, pageNo);
882
+ addTitle(slide, slideData.title, "MATRIX");
883
+ const cells = slideData.cells || [];
884
+ const x0 = 1.28;
885
+ const y0 = 1.78;
886
+ const cellW = 5.05;
887
+ const cellH = 2.1;
888
+ for (let row = 0; row < 2; row++) {
889
+ for (let col = 0; col < 2; col++) {
890
+ const cell = cells[row * 2 + col] || {};
891
+ const x = x0 + col * (cellW + 0.48);
892
+ const y = y0 + row * (cellH + 0.34);
893
+ slide.addShape("rect", { x, y, w: cellW, h: cellH, fill: { color: row === col ? "F1F8F4" : "FFFFFF" }, line: { color: theme.line, width: 0.8 } });
894
+ addText(slide, cell.title || "", x + 0.22, y + 0.22, cellW - 0.44, 0.32, { fontSize: 14.5, bold: true, color: theme.green });
895
+ addInlineMathText(slide, cell.text || "", x + 0.22, y + 0.72, cellW - 0.44, cellH - 0.94, { fontSize: 11, color: theme.ink, valign: "top" }, ctx);
896
+ }
897
+ }
898
+ }
899
+
900
+ function layoutProcess(pptx, slideData, pageNo, ctx) {
901
+ const slide = pptx.addSlide();
902
+ addPageBrand(slide, pageNo);
903
+ addTitle(slide, slideData.title, "PROCESS");
904
+ const steps = (slideData.steps || []).slice(0, 5);
905
+ const stepW = 2.12;
906
+ steps.forEach((step, idx) => {
907
+ const x = 0.95 + idx * 2.38;
908
+ slide.addShape("rect", { x, y: 2.28, w: stepW, h: 1.92, fill: { color: "FFFFFF" }, line: { color: theme.green, width: 1.1 } });
909
+ addText(slide, String(idx + 1).padStart(2, "0"), x + 0.16, 2.48, 0.46, 0.22, { fontSize: 10, color: theme.green, bold: true, fontFace: font.en });
910
+ addText(slide, step.title || "", x + 0.24, 2.88, stepW - 0.48, 0.32, { fontSize: 13, bold: true, color: theme.ink, align: "center" });
911
+ addInlineMathText(slide, step.text || "", x + 0.18, 3.35, stepW - 0.36, 0.54, { fontSize: 8.8, color: theme.muted, align: "center", valign: "top" }, ctx);
912
+ if (idx < steps.length - 1) {
913
+ slide.addShape("chevron", { x: x + stepW + 0.14, y: 3.05, w: 0.35, h: 0.35, fill: { color: theme.green }, line: { transparency: 100 } });
914
+ }
915
+ });
916
+ }
917
+
918
+ function layoutProblemSolution(pptx, slideData, pageNo, ctx) {
919
+ const slide = pptx.addSlide();
920
+ addPageBrand(slide, pageNo);
921
+ addTitle(slide, slideData.title || "问题与方案", "PROBLEM / SOLUTION");
922
+ const blocks = [
923
+ { key: "problem", label: "问题", color: theme.red, x: 0.92 },
924
+ { key: "solution", label: "方案", color: theme.green, x: 4.78 },
925
+ { key: "impact", label: "收益", color: theme.darkGreen, x: 8.64 },
926
+ ];
927
+ blocks.forEach((block) => {
928
+ const data = slideData[block.key] || {};
929
+ slide.addShape("rect", { x: block.x, y: 1.78, w: 3.44, h: 4.48, fill: { color: "FFFFFF" }, line: { color: block.color, width: 1.0 } });
930
+ slide.addShape("rect", { x: block.x, y: 1.78, w: 3.44, h: 0.48, fill: { color: block.color }, line: { transparency: 100 } });
931
+ addText(slide, data.label || block.label, block.x + 0.22, 1.92, 2.9, 0.16, { fontSize: 9.5, bold: true, color: theme.white, fontFace: font.en });
932
+ addText(slide, data.title || "", block.x + 0.25, 2.58, 2.94, 0.46, { fontSize: 16, bold: true, color: block.color });
933
+ addFlowBulletList(slide, data.bullets || [], block.x + 0.28, 3.28, 2.88, 2.42, { fontSize: 10.8, color: block.color }, ctx);
934
+ });
935
+ }
936
+
937
+ function layoutPainOpportunity(pptx, slideData, pageNo, ctx) {
938
+ const slide = pptx.addSlide();
939
+ addPageBrand(slide, pageNo);
940
+ addTitle(slide, slideData.title || "现状痛点与机会", "PAIN / OPPORTUNITY");
941
+ addColumnBlock(slide, slideData.status || { title: "现状" }, 0.92, 1.72, 3.45, 4.45, theme.muted, ctx);
942
+ addColumnBlock(slide, slideData.pain || { title: "痛点" }, 4.92, 1.72, 3.45, 4.45, theme.red, ctx);
943
+ addColumnBlock(slide, slideData.opportunity || { title: "机会" }, 8.92, 1.72, 3.45, 4.45, theme.green, ctx);
944
+ }
945
+
946
+ function layoutExperimentDesign(pptx, slideData, pageNo, ctx) {
947
+ const slide = pptx.addSlide();
948
+ addPageBrand(slide, pageNo);
949
+ addTitle(slide, slideData.title || "实验设计", "EXPERIMENT DESIGN");
950
+ const fields = [
951
+ ["数据集", slideData.dataset],
952
+ ["变量", slideData.variables],
953
+ ["指标", slideData.metrics],
954
+ ["对照", slideData.baselines],
955
+ ];
956
+ fields.forEach(([label, value], idx) => {
957
+ const col = idx % 2;
958
+ const row = Math.floor(idx / 2);
959
+ const x = 0.92 + col * 5.95;
960
+ const y = 1.78 + row * 2.18;
961
+ slide.addShape("rect", { x, y, w: 5.35, h: 1.74, fill: { color: idx % 2 ? "F9FBFA" : "FFFFFF" }, line: { color: theme.line, width: 0.8 } });
962
+ addText(slide, label, x + 0.24, y + 0.18, 1.18, 0.28, { fontSize: 13.5, bold: true, color: theme.green });
963
+ const items = Array.isArray(value) ? value : value ? [value] : [];
964
+ addFlowBulletList(slide, items, x + 0.28, y + 0.62, 4.78, 0.92, { fontSize: 10.8 }, ctx);
965
+ });
966
+ if (slideData.procedure?.length) {
967
+ addText(slide, "流程", 0.94, 6.0, 0.8, 0.24, { fontSize: 12.5, bold: true, color: theme.green });
968
+ addText(slide, slideData.procedure.join(" -> "), 1.72, 6.0, 10.3, 0.24, { fontSize: 10.5, color: theme.muted });
969
+ }
970
+ }
971
+
972
+ function layoutResultAnalysis(pptx, slideData, pageNo, ctx) {
973
+ const slide = pptx.addSlide();
974
+ addPageBrand(slide, pageNo);
975
+ addTitle(slide, slideData.title || "结果分析", "RESULT ANALYSIS");
976
+ addInlineMathText(slide, slideData.finding || "", 0.98, 1.72, 10.9, 0.78, { fontSize: 22, bold: true, color: theme.green, valign: "mid" }, ctx);
977
+ const metrics = (slideData.metrics || []).slice(0, 3);
978
+ metrics.forEach((metric, idx) => {
979
+ const x = 1.0 + idx * 3.9;
980
+ slide.addShape("rect", { x, y: 2.88, w: 3.35, h: 1.22, fill: { color: "FAFCFB" }, line: { color: theme.line, width: 0.7 } });
981
+ addText(slide, metric.value || "", x + 0.18, 3.06, 1.2, 0.42, { fontSize: 22, bold: true, color: theme.green, fontFace: font.en });
982
+ addText(slide, metric.label || "", x + 1.48, 3.08, 1.64, 0.24, { fontSize: 11.5, bold: true, color: theme.ink });
983
+ addInlineMathText(slide, metric.note || "", x + 1.48, 3.45, 1.65, 0.28, { fontSize: 8.8, color: theme.muted }, ctx);
984
+ });
985
+ addFlowBulletList(slide, slideData.analysis || [], 1.18, 4.55, 10.4, 1.35, { fontSize: 13.2 }, ctx);
986
+ }
987
+
988
+ function layoutRiskMitigation(pptx, slideData, pageNo, ctx) {
989
+ const slide = pptx.addSlide();
990
+ addPageBrand(slide, pageNo);
991
+ addTitle(slide, slideData.title || "风险与对策", "RISK MITIGATION");
992
+ const rows = (slideData.items || []).slice(0, 5);
993
+ const table = [
994
+ ["风险", "影响", "对策"].map((text) => ({ text, options: { bold: true, color: theme.white, fill: { color: theme.green }, fontFace: font.cn } })),
995
+ ...rows.map((item, idx) => ["risk", "impact", "mitigation"].map((key) => {
996
+ const options = { color: theme.ink, fill: { color: idx % 2 === 0 ? "FFFFFF" : "F7FAF8" }, fontFace: font.cn };
997
+ return { text: inlineMathTableCell(item[key] || "", options, ctx), options };
998
+ })),
999
+ ];
1000
+ slide.addTable(table, { x: 0.86, y: 1.78, w: 11.62, h: 4.45, border: { type: "solid", color: "C9D8D0", pt: 0.6 }, margin: 0.08, fontSize: 10, valign: "mid", fit: "shrink" });
1001
+ }
1002
+
1003
+ function layoutContribution(pptx, slideData, pageNo, ctx) {
1004
+ const slide = pptx.addSlide();
1005
+ addPageBrand(slide, pageNo);
1006
+ addTitle(slide, slideData.title || "主要贡献", "CONTRIBUTIONS");
1007
+ const items = (slideData.items || []).slice(0, 4);
1008
+ items.forEach((item, idx) => {
1009
+ const y = 1.8 + idx * 1.08;
1010
+ addText(slide, String(idx + 1).padStart(2, "0"), 1.04, y, 0.62, 0.36, { fontSize: 18, bold: true, color: theme.green, fontFace: font.en });
1011
+ slide.addShape("line", { x: 1.84, y: y + 0.18, w: 0.62, h: 0, line: { color: theme.green, width: 1.1 } });
1012
+ addText(slide, item.title || "", 2.68, y - 0.03, 3.0, 0.32, { fontSize: 15, bold: true, color: theme.ink });
1013
+ addInlineMathText(slide, item.text || "", 5.86, y - 0.03, 5.9, 0.42, { fontSize: 12, color: theme.muted, valign: "top" }, ctx);
1014
+ });
1015
+ }
1016
+
1017
+ function layoutSummary(pptx, slideData, pageNo, ctx) {
1018
+ const slide = pptx.addSlide();
1019
+ addPageBrand(slide, pageNo);
1020
+ addTitle(slide, slideData.title || "章节小结", "SUMMARY");
1021
+ addInlineMathText(slide, slideData.takeaway || "", 1.02, 1.78, 10.9, 0.84, { fontSize: 23, bold: true, color: theme.green, valign: "mid" }, ctx);
1022
+ addFlowBulletList(slide, slideData.points || [], 1.28, 3.08, 10.4, 2.55, { fontSize: 15 }, ctx);
1023
+ }
1024
+
1025
+ function layoutArchitecture(pptx, slideData, pageNo, ctx) {
1026
+ const slide = pptx.addSlide();
1027
+ addPageBrand(slide, pageNo);
1028
+ addTitle(slide, slideData.title || "方法架构", "ARCHITECTURE");
1029
+ const layers = (slideData.layers || []).slice(0, 4);
1030
+ const layerH = layers.length <= 3 ? 1.28 : 1.04;
1031
+ const gap = layers.length <= 3 ? 0.26 : 0.18;
1032
+ layers.forEach((layer, idx) => {
1033
+ const y = 1.74 + idx * (layerH + gap);
1034
+ const accent = idx % 2 === 0 ? theme.green : theme.darkGreen;
1035
+ slide.addShape("rect", { x: 0.92, y, w: 11.45, h: layerH, fill: { color: idx % 2 === 0 ? "F8FBF9" : "FFFFFF" }, line: { color: theme.line, width: 0.8 } });
1036
+ slide.addShape("rect", { x: 0.92, y, w: 1.7, h: layerH, fill: { color: accent }, line: { transparency: 100 } });
1037
+ addText(slide, layer.title || `Layer ${idx + 1}`, 1.08, y + 0.18, 1.38, 0.42, { fontSize: 12.2, bold: true, color: theme.white, align: "center" });
1038
+ const components = (layer.components || []).slice(0, 5);
1039
+ const chipW = Math.min(1.62, 8.65 / Math.max(1, components.length));
1040
+ components.forEach((component, cIdx) => {
1041
+ const x = 2.94 + cIdx * (chipW + 0.18);
1042
+ slide.addShape("roundRect", { x, y: y + 0.25, w: chipW, h: 0.44, rectRadius: 0.04, fill: { color: "FFFFFF" }, line: { color: accent, width: 0.75 } });
1043
+ addInlineMathText(slide, component, x + 0.1, y + 0.36, chipW - 0.2, 0.12, { fontSize: 8.7, color: accent, bold: true, align: "center" }, ctx);
1044
+ if (cIdx < components.length - 1) {
1045
+ slide.addShape("chevron", { x: x + chipW + 0.04, y: y + 0.35, w: 0.16, h: 0.16, fill: { color: theme.line }, line: { transparency: 100 } });
1046
+ }
1047
+ });
1048
+ if (layer.note) {
1049
+ addInlineMathText(slide, layer.note, 2.94, y + 0.74, 8.75, 0.38, { fontSize: 10.2, color: theme.muted, valign: "mid" }, ctx);
1050
+ }
1051
+ });
1052
+ }
1053
+
1054
+ function layoutAblation(pptx, slideData, pageNo, ctx) {
1055
+ const slide = pptx.addSlide();
1056
+ addPageBrand(slide, pageNo);
1057
+ addTitle(slide, slideData.title || "消融实验", "ABLATION STUDY");
1058
+ if (slideData.baseline) {
1059
+ addInlineMathText(slide, slideData.baseline, 0.98, 1.64, 10.95, 0.34, { fontSize: 12.5, color: theme.muted }, ctx);
1060
+ }
1061
+ const rows = (slideData.items || []).slice(0, 6);
1062
+ const headers = ["模块", "设置", "结果变化", "结论"];
1063
+ const table = [
1064
+ headers.map((text) => ({ text, options: { bold: true, color: theme.white, fill: { color: theme.green }, fontFace: font.cn } })),
1065
+ ...rows.map((item, idx) => ["factor", "setting", "delta", "conclusion"].map((key) => {
1066
+ const options = { color: theme.ink, fill: { color: idx % 2 === 0 ? "FFFFFF" : "F7FAF8" }, fontFace: font.cn };
1067
+ return { text: inlineMathTableCell(item[key] || "", options, ctx), options };
1068
+ })),
1069
+ ];
1070
+ slide.addTable(table, {
1071
+ x: 0.82,
1072
+ y: 2.12,
1073
+ w: 11.74,
1074
+ h: Math.min(4.16, 0.52 + rows.length * 0.58),
1075
+ border: { type: "solid", color: "C9D8D0", pt: 0.6 },
1076
+ margin: 0.08,
1077
+ fontSize: rows.length > 4 ? 8.8 : 9.6,
1078
+ valign: "mid",
1079
+ fit: "shrink",
1080
+ colW: [2.0, 3.05, 2.05, 4.64],
1081
+ });
1082
+ }
1083
+
1084
+ function layoutCaseStudy(pptx, slideData, pageNo, ctx) {
1085
+ const slide = pptx.addSlide();
1086
+ addPageBrand(slide, pageNo);
1087
+ addTitle(slide, slideData.title || "案例分析", "CASE STUDY");
1088
+ const image = resolveImageInfo(slideData.image || "assets/bit-campus-photo.png");
1089
+ image.fit = normalizeText(slideData.imageFit || slideData.fit || image.fit).toLowerCase();
1090
+ addImageInPanel(slide, image, { x: 1.02, y: 1.84, w: 4.22, h: 3.16 }, { fit: "cover" });
1091
+ addInlineMathText(slide, slideData.caption || "", 1.02, 5.16, 4.22, 0.36, { fontSize: 9.5, color: theme.muted, align: "center" }, ctx);
1092
+ const blocks = [
1093
+ ["背景", slideData.context],
1094
+ ["做法", slideData.method],
1095
+ ["结果", slideData.result],
1096
+ ];
1097
+ blocks.forEach(([label, value], idx) => {
1098
+ const y = 1.76 + idx * 1.46;
1099
+ slide.addShape("rect", { x: 6.02, y, w: 5.85, h: 1.1, fill: { color: idx === 2 ? "F1F8F4" : "FFFFFF" }, line: { color: theme.line, width: 0.75 } });
1100
+ addText(slide, label, 6.26, y + 0.18, 0.74, 0.22, { fontSize: 12, bold: true, color: theme.green });
1101
+ const items = Array.isArray(value) ? value : value ? [value] : [];
1102
+ addCompactBulletList(slide, items, 7.16, y + 0.2, 4.34, { fontSize: 9.8, gap: 0.3, dotSize: 0.08 }, ctx);
1103
+ });
1104
+ }
1105
+
1106
+ function layoutImageGrid(pptx, slideData, pageNo, ctx) {
1107
+ const slide = pptx.addSlide();
1108
+ addPageBrand(slide, pageNo);
1109
+ addTitle(slide, slideData.title || "图像结果", "IMAGE GRID");
1110
+ const images = (slideData.images || []).slice(0, 6);
1111
+ const cols = images.length <= 2 ? images.length || 1 : 3;
1112
+ const rows = Math.ceil(images.length / cols);
1113
+ const gap = 0.24;
1114
+ const gridW = 11.55;
1115
+ const cellW = (gridW - gap * (cols - 1)) / cols;
1116
+ const cellH = rows === 1 ? 3.9 : 2.02;
1117
+ images.forEach((item, idx) => {
1118
+ const col = idx % cols;
1119
+ const row = Math.floor(idx / cols);
1120
+ const x = 0.9 + col * (cellW + gap);
1121
+ const y = 1.74 + row * (cellH + 0.36);
1122
+ const image = resolveImageInfo(item || "assets/bit-campus-photo.png");
1123
+ const caption = item && typeof item === "object" ? item.caption : "";
1124
+ addImageInPanel(slide, image, { x: x + 0.1, y: y + 0.1, w: cellW - 0.2, h: cellH - 0.48 }, { fit: "cover", lineWidth: 0.75, pad: 0.1 });
1125
+ addInlineMathText(slide, caption, x + 0.12, y + cellH - 0.32, cellW - 0.24, 0.18, { fontSize: 8.4, color: theme.muted, align: "center" }, ctx);
1126
+ });
1127
+ }
1128
+
1129
+ function layoutCode(pptx, slideData, pageNo, ctx) {
1130
+ const slide = pptx.addSlide();
1131
+ addPageBrand(slide, pageNo);
1132
+ addTitle(slide, slideData.title || "算法与代码", "CODE / ALGORITHM");
1133
+ const code = normalizeText(slideData.code || slideData.algorithm || "");
1134
+ slide.addShape("rect", { x: 0.88, y: 1.72, w: 7.15, h: 4.62, fill: { color: "F6F8F6" }, line: { color: theme.line, width: 0.8 } });
1135
+ slide.addShape("rect", { x: 0.88, y: 1.72, w: 7.15, h: 0.34, fill: { color: theme.green }, line: { transparency: 100 } });
1136
+ addText(slide, slideData.language || "pseudo", 1.08, 1.82, 1.8, 0.1, { fontSize: 7.5, color: theme.white, fontFace: font.en, bold: true });
1137
+ addText(slide, code, 1.08, 2.22, 6.75, 3.78, { fontSize: slideData.fontSize || 9.2, color: theme.ink, fontFace: font.code, valign: "top", fit: "shrink" });
1138
+ addColumnBlock(slide, { title: slideData.noteTitle || "解释", bullets: slideData.notes || [] }, 8.64, 1.78, 3.56, 4.42, theme.green, ctx);
1139
+ }
1140
+
1141
+ function layoutAppendix(pptx, slideData, pageNo, ctx) {
1142
+ const slide = pptx.addSlide();
1143
+ addPageBrand(slide, pageNo);
1144
+ addTitle(slide, slideData.title || "附录", "APPENDIX");
1145
+ const items = (slideData.items || []).slice(0, 8);
1146
+ const cols = items.length > 4 ? 2 : 1;
1147
+ const colW = cols === 2 ? 5.1 : 10.6;
1148
+ items.forEach((item, idx) => {
1149
+ const col = cols === 2 ? idx % 2 : 0;
1150
+ const row = cols === 2 ? Math.floor(idx / 2) : idx;
1151
+ const x = 1.0 + col * 5.82;
1152
+ const y = 1.82 + row * 0.88;
1153
+ addText(slide, item.key || String(idx + 1).padStart(2, "0"), x, y, 0.62, 0.24, { fontSize: 12, bold: true, color: theme.green, fontFace: font.en, align: "right" });
1154
+ slide.addShape("line", { x: x + 0.78, y: y + 0.12, w: 0.5, h: 0, line: { color: theme.green, width: 1.0 } });
1155
+ addText(slide, item.title || "", x + 1.48, y - 0.02, colW - 1.48, 0.24, { fontSize: 12.5, bold: true, color: theme.ink });
1156
+ addInlineMathText(slide, item.text || "", x + 1.48, y + 0.32, colW - 1.48, 0.24, { fontSize: 8.8, color: theme.muted }, ctx);
1157
+ });
1158
+ }
1159
+
1160
+ function resolveFlowNodePositions(nodes, box) {
1161
+ const count = nodes.length;
1162
+ const cols = count <= 6 ? count || 1 : 4;
1163
+ const rows = Math.ceil(count / cols);
1164
+ const nodeW = count <= 3 ? 2.28 : count <= 6 ? 1.62 : 1.76;
1165
+ const nodeH = 0.74;
1166
+ const gapX = cols > 1 ? (box.w - nodeW * cols) / (cols - 1) : 0;
1167
+ const gapY = rows > 1 ? Math.min(1.1, (box.h - nodeH * rows) / (rows - 1)) : 0;
1168
+ const startY = box.y + (box.h - rows * nodeH - (rows - 1) * gapY) / 2;
1169
+ return nodes.map((node, idx) => {
1170
+ if (Number.isFinite(node.x) && Number.isFinite(node.y)) {
1171
+ return { ...node, x: box.x + node.x, y: box.y + node.y, w: node.w || nodeW, h: node.h || nodeH };
1172
+ }
1173
+ const row = Math.floor(idx / cols);
1174
+ const logicalCol = idx % cols;
1175
+ const itemsInRow = row === rows - 1 ? count - row * cols : cols;
1176
+ const col = row % 2 === 1 ? itemsInRow - 1 - logicalCol : logicalCol;
1177
+ const rowW = itemsInRow * nodeW + Math.max(0, itemsInRow - 1) * gapX;
1178
+ const x0 = box.x + (box.w - rowW) / 2;
1179
+ return {
1180
+ ...node,
1181
+ x: x0 + col * (nodeW + gapX),
1182
+ y: startY + row * (nodeH + gapY),
1183
+ w: node.w || nodeW,
1184
+ h: node.h || nodeH,
1185
+ };
1186
+ });
1187
+ }
1188
+
1189
+ function drawFlowEdge(slide, from, to, options = {}) {
1190
+ const x1 = from.x + from.w / 2;
1191
+ const y1 = from.y + from.h / 2;
1192
+ const x2 = to.x + to.w / 2;
1193
+ const y2 = to.y + to.h / 2;
1194
+ const dx = x2 - x1;
1195
+ const dy = y2 - y1;
1196
+ const fromHalfW = from.w / 2;
1197
+ const fromHalfH = from.h / 2;
1198
+ const toHalfW = to.w / 2;
1199
+ const toHalfH = to.h / 2;
1200
+ const scaleFrom = Math.min(Math.abs(dx) > 0 ? fromHalfW / Math.abs(dx) : Infinity, Math.abs(dy) > 0 ? fromHalfH / Math.abs(dy) : Infinity);
1201
+ const scaleTo = Math.min(Math.abs(dx) > 0 ? toHalfW / Math.abs(dx) : Infinity, Math.abs(dy) > 0 ? toHalfH / Math.abs(dy) : Infinity);
1202
+ const sx = x1 + dx * Math.min(0.48, scaleFrom || 0);
1203
+ const sy = y1 + dy * Math.min(0.48, scaleFrom || 0);
1204
+ const ex = x2 - dx * Math.min(0.48, scaleTo || 0);
1205
+ const ey = y2 - dy * Math.min(0.48, scaleTo || 0);
1206
+ slide.addShape("line", {
1207
+ x: sx,
1208
+ y: sy,
1209
+ w: ex - sx,
1210
+ h: ey - sy,
1211
+ line: {
1212
+ color: options.color || theme.green,
1213
+ width: options.width || 1.15,
1214
+ endArrowType: options.arrow === false ? "none" : "triangle",
1215
+ transparency: options.transparency || 8,
1216
+ },
1217
+ });
1218
+ }
1219
+
1220
+ function layoutFlowchart(pptx, slideData, pageNo, ctx) {
1221
+ const slide = pptx.addSlide();
1222
+ addPageBrand(slide, pageNo);
1223
+ addTitle(slide, slideData.title || "流程图", "FLOWCHART");
1224
+ const nodes = resolveFlowNodePositions((slideData.nodes || []).slice(0, 10), { x: 0.98, y: 1.7, w: 11.35, h: 4.48 });
1225
+ const byId = new Map(nodes.map((node, idx) => [node.id || String(idx + 1), node]));
1226
+ const edges = slideData.edges?.length
1227
+ ? slideData.edges
1228
+ : nodes.slice(0, -1).map((node, idx) => ({ from: node.id || String(idx + 1), to: nodes[idx + 1].id || String(idx + 2) }));
1229
+ edges.forEach((edge) => {
1230
+ const from = byId.get(edge.from);
1231
+ const to = byId.get(edge.to);
1232
+ if (from && to) drawFlowEdge(slide, from, to, edge);
1233
+ });
1234
+ nodes.forEach((node, idx) => {
1235
+ const accent = node.color || (idx % 2 === 0 ? theme.green : theme.darkGreen);
1236
+ const fill = node.fill || (node.emphasis ? "F1F8F4" : "FFFFFF");
1237
+ slide.addShape(node.shape || "roundRect", {
1238
+ x: node.x,
1239
+ y: node.y,
1240
+ w: node.w,
1241
+ h: node.h,
1242
+ rectRadius: 0.05,
1243
+ fill: { color: fill },
1244
+ line: { color: accent, width: node.emphasis ? 1.35 : 0.95 },
1245
+ });
1246
+ addInlineMathText(slide, node.text || node.label || node.id || "", node.x + 0.16, node.y + 0.15, node.w - 0.32, 0.28, {
1247
+ fontSize: node.fontSize || 11.2,
1248
+ color: accent,
1249
+ bold: true,
1250
+ align: "center",
1251
+ }, ctx);
1252
+ if (node.note) {
1253
+ addInlineMathText(slide, node.note, node.x + 0.18, node.y + 0.46, node.w - 0.36, 0.18, {
1254
+ fontSize: 7.5,
1255
+ color: theme.muted,
1256
+ align: "center",
1257
+ }, ctx);
1258
+ }
1259
+ });
1260
+ if (slideData.note) {
1261
+ addInlineMathText(slide, slideData.note, 1.02, 6.02, 10.9, 0.28, { fontSize: 10, color: theme.muted, align: "center" }, ctx);
1262
+ }
1263
+ }
1264
+
1265
+ function chartTypeName(type) {
1266
+ const value = normalizeText(type || "bar").toLowerCase();
1267
+ if (["line", "pie", "doughnut", "scatter", "area"].includes(value)) return value;
1268
+ return "bar";
1269
+ }
1270
+
1271
+ function normalizeChartSeries(slideData) {
1272
+ const categories = slideData.categories || slideData.labels || [];
1273
+ const series = slideData.series?.length ? slideData.series : [{ name: slideData.name || "Series", values: slideData.values || [] }];
1274
+ return series.map((item) => ({
1275
+ name: item.name || item.label || "Series",
1276
+ labels: item.labels || categories,
1277
+ values: (item.values || []).map((value) => Number(value)),
1278
+ }));
1279
+ }
1280
+
1281
+ function layoutChart(pptx, slideData, pageNo) {
1282
+ const slide = pptx.addSlide();
1283
+ addPageBrand(slide, pageNo);
1284
+ const type = chartTypeName(slideData.type);
1285
+ addTitle(slide, slideData.title || "统计图", type.toUpperCase());
1286
+ const data = normalizeChartSeries(slideData);
1287
+ const showLegend = slideData.showLegend ?? (data.length > 1 || ["pie", "doughnut"].includes(type));
1288
+ const chartOptions = {
1289
+ x: 0.92,
1290
+ y: 1.72,
1291
+ w: 10.95,
1292
+ h: 4.45,
1293
+ chartColors: slideData.colors || [theme.green, theme.red, "577A68", "9DAA57", "4B647A", "B56B45"],
1294
+ showLegend,
1295
+ legendPos: slideData.legendPos || "b",
1296
+ showValue: slideData.showValue ?? ["pie", "doughnut"].includes(type),
1297
+ showCategoryName: slideData.showCategoryName ?? false,
1298
+ showTitle: false,
1299
+ showCatName: false,
1300
+ valAxisLabelFontFace: font.en,
1301
+ valAxisLabelFontSize: 8,
1302
+ valAxisLabelColor: theme.muted,
1303
+ catAxisLabelFontFace: font.cn,
1304
+ catAxisLabelFontSize: 8,
1305
+ catAxisLabelColor: theme.muted,
1306
+ catAxisLabelRotate: slideData.rotateLabels || 0,
1307
+ catAxisTitle: slideData.categoryAxisTitle,
1308
+ valAxisTitle: slideData.valueAxisTitle,
1309
+ valGridLine: { color: "DDE7E2", size: 0.5 },
1310
+ catAxisLineColor: "C9D8D0",
1311
+ valAxisLineColor: "C9D8D0",
1312
+ chartArea: { border: { color: "FFFFFF", pt: 0 }, roundedCorners: false },
1313
+ plotArea: { border: { color: "FFFFFF", pt: 0 }, fill: { color: "FFFFFF", transparency: 100 } },
1314
+ };
1315
+ if (type === "bar") {
1316
+ chartOptions.barDir = slideData.direction === "horizontal" ? "bar" : "col";
1317
+ chartOptions.barGrouping = slideData.grouping || "clustered";
1318
+ chartOptions.showValue = slideData.showValue ?? false;
1319
+ }
1320
+ if (type === "line") {
1321
+ chartOptions.lineDataSymbol = slideData.symbol || "circle";
1322
+ chartOptions.lineDataSymbolSize = 5;
1323
+ chartOptions.lineSize = 2.0;
1324
+ }
1325
+ slide.addChart(type, data, chartOptions);
1326
+ if (slideData.caption) {
1327
+ addText(slide, slideData.caption, 1.02, 6.22, 10.7, 0.24, { fontSize: 9.2, color: theme.muted, align: "center" });
1328
+ }
1329
+ }
1330
+
1331
+ function escapeRegExp(value) {
1332
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1333
+ }
1334
+
1335
+ function wrapOmmlForPresentation(omml, display = true) {
1336
+ const cleaned = normalizeText(omml);
1337
+ if (display) {
1338
+ return `<a14:m xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main">${cleaned}</a14:m>`;
1339
+ }
1340
+ return `<a14:m xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main">${cleaned}</a14:m>`;
1341
+ }
1342
+
1343
+ async function postprocessOmml(fileName, equations) {
1344
+ if (!equations.length) return;
1345
+ const replacements = [];
1346
+ for (const equation of equations) {
1347
+ let omml;
1348
+ try {
1349
+ omml = await latexToOMML(equation.latex);
1350
+ } catch (error) {
1351
+ throw new Error(`Formula conversion failed for "${equation.latex}": ${error.message}`);
1352
+ }
1353
+ replacements.push({
1354
+ marker: equation.marker,
1355
+ xml: wrapOmmlForPresentation(omml, equation.display),
1356
+ });
1357
+ }
1358
+ const zip = await JSZip.loadAsync(fs.readFileSync(fileName));
1359
+ const slideNames = Object.keys(zip.files).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
1360
+ for (const slideName of slideNames) {
1361
+ let xml = await zip.file(slideName).async("string");
1362
+ let changed = false;
1363
+ for (const replacement of replacements) {
1364
+ if (!xml.includes(replacement.marker)) continue;
1365
+ const marker = escapeRegExp(replacement.marker);
1366
+ const runPattern = new RegExp(`<a:r>(?:(?!<\\/a:r>)[\\s\\S])*?<a:t[^>]*>${marker}<\\/a:t>(?:(?!<\\/a:r>)[\\s\\S])*?<\\/a:r>`, "g");
1367
+ xml = xml.replace(runPattern, replacement.xml);
1368
+ changed = true;
1369
+ }
1370
+ if (changed) zip.file(slideName, xml);
1371
+ }
1372
+ const out = await zip.generateAsync({ type: "nodebuffer" });
1373
+ fs.writeFileSync(fileName, out);
1374
+ }
1375
+
1376
+ function layoutComparison(pptx, slideData, pageNo, ctx) {
1377
+ const slide = pptx.addSlide();
1378
+ addPageBrand(slide, pageNo);
1379
+ addTitle(slide, slideData.title, "COMPARISON");
1380
+ addComparePanel(slide, slideData.left, 0.9, 1.74, theme.muted, "F5F5F5", ctx);
1381
+ addComparePanel(slide, slideData.right, 6.92, 1.74, theme.green, "F1F8F4", ctx);
1382
+ addText(slide, "VS", 6.12, 3.47, 0.56, 0.3, { fontSize: 14, bold: true, color: theme.red, align: "center", fontFace: font.en });
1383
+ }
1384
+
1385
+ function addComparePanel(slide, data = {}, x, y, accent, fill, ctx) {
1386
+ slide.addShape("rect", { x, y, w: 5.25, h: 4.45, fill: { color: fill }, line: { color: accent, width: 1.3 } });
1387
+ slide.addShape("rect", { x, y, w: 5.25, h: 0.48, fill: { color: accent }, line: { transparency: 100 } });
1388
+ addText(slide, data.label || "", x + 0.22, y + 0.12, 4.8, 0.16, { fontSize: 9, color: theme.white, bold: true, fontFace: font.en });
1389
+ addText(slide, data.title || "", x + 0.32, y + 0.88, 4.52, 0.54, { fontSize: 20, color: accent, bold: true });
1390
+ addBulletList(slide, data.bullets || [], x + 0.42, y + 1.72, 4.34, 0.6, { fontSize: 13.5, color: accent }, ctx);
1391
+ }
1392
+
1393
+ function chooseImageTextPlacement(slideData, image) {
1394
+ const requested = normalizeText(slideData.imagePlacement || slideData.placement || image.placement).toLowerCase();
1395
+ if (["top", "above", "wide", "bottomText", "bottom-text"].includes(requested)) return "top";
1396
+ if (["side", "left", "right"].includes(requested)) return "side";
1397
+ if (image.ratio >= 1.9) return "top";
1398
+ return "side";
1399
+ }
1400
+
1401
+ function addBulletGrid(slide, bullets, x, y, w, options = {}, ctx = null) {
1402
+ const items = (Array.isArray(bullets) ? bullets : bullets ? [bullets] : []).slice(0, options.maxItems || 5);
1403
+ if (!items.length) return;
1404
+ const cols = Math.min(options.cols || (items.length <= 2 ? items.length : 3), items.length);
1405
+ const gapX = options.gapX || 0.36;
1406
+ const gapY = options.gapY || 0.48;
1407
+ const colW = (w - gapX * (cols - 1)) / cols;
1408
+ const size = options.fontSize || 11.4;
1409
+ items.forEach((item, idx) => {
1410
+ const col = idx % cols;
1411
+ const row = Math.floor(idx / cols);
1412
+ const left = x + col * (colW + gapX);
1413
+ const top = y + row * gapY;
1414
+ slide.addShape("ellipse", {
1415
+ x: left,
1416
+ y: top + 0.1,
1417
+ w: 0.1,
1418
+ h: 0.1,
1419
+ fill: { color: options.color || theme.green },
1420
+ line: { transparency: 100 },
1421
+ });
1422
+ const textOptions = {
1423
+ fontSize: size,
1424
+ color: options.textColor || theme.ink,
1425
+ valign: "top",
1426
+ fit: "shrink",
1427
+ };
1428
+ if (ctx) addInlineMathText(slide, item, left + 0.22, top, colW - 0.22, 0.36, textOptions, ctx);
1429
+ else addText(slide, item, left + 0.22, top, colW - 0.22, 0.36, textOptions);
1430
+ });
1431
+ }
1432
+
1433
+ function layoutImageText(pptx, slideData, pageNo, ctx) {
1434
+ const slide = pptx.addSlide();
1435
+ addPageBrand(slide, pageNo);
1436
+ addTitle(slide, slideData.title, "VISUAL EXPLANATION");
1437
+ const image = resolveImageInfo(slideData.image || "assets/bit-campus-photo.png");
1438
+ image.fit = normalizeText(slideData.imageFit || slideData.fit || image.fit).toLowerCase();
1439
+ image.placement = normalizeText(slideData.imagePlacement || slideData.placement || image.placement).toLowerCase();
1440
+ const placement = chooseImageTextPlacement(slideData, image);
1441
+
1442
+ if (placement === "top") {
1443
+ addImageInPanel(slide, image, { x: 0.98, y: 1.58, w: 11.35, h: 3.52 }, { fit: "contain" });
1444
+ if (slideData.caption) {
1445
+ addInlineMathText(slide, slideData.caption, 1.02, 5.22, 11.18, 0.22, { fontSize: 9.2, color: theme.muted, align: "center" }, ctx);
1446
+ }
1447
+ addBulletGrid(slide, slideData.text || [], 1.02, slideData.caption ? 5.66 : 5.46, 11.18, { fontSize: 11.2 }, ctx);
1448
+ return;
1449
+ }
1450
+
1451
+ const sideFit = image.orientation === "portrait" || image.orientation === "tall" ? "contain" : "cover";
1452
+ addImageInPanel(slide, image, { x: 1.02, y: 1.8, w: 4.9, h: 4.08 }, { fit: sideFit });
1453
+ if (slideData.caption) {
1454
+ addInlineMathText(slide, slideData.caption, 1.0, 6.04, 4.94, 0.22, { fontSize: 8.8, color: theme.muted, align: "center" }, ctx);
1455
+ }
1456
+ addBulletList(slide, slideData.text || [], 6.82, 2.03, 5.15, 0.76, { fontSize: 16 }, ctx);
1457
+ }
1458
+
1459
+ function layoutClosing(pptx, slideData) {
1460
+ const slide = pptx.addSlide();
1461
+ slide.background = { color: theme.green };
1462
+ addImageFit(slide, assets.emblemGray, { x: 8.6, y: 1.12, w: 4.0, h: 4.0 }, { transparency: 62 });
1463
+ addImageFit(slide, assets.wordmarkWhite, { x: 0.78, y: 0.54, w: 3.8, h: 1.02 });
1464
+ addText(slide, slideData.title || "谢谢", 0.82, 2.62, 6.4, 0.92, {
1465
+ fontSize: 36,
1466
+ bold: true,
1467
+ color: theme.white,
1468
+ });
1469
+ addText(slide, slideData.subtitle || "", 0.86, 3.58, 6.4, 0.42, {
1470
+ fontSize: 16,
1471
+ color: theme.white,
1472
+ });
1473
+ }
1474
+
1475
+ function createDeck(deck, options = {}) {
1476
+ const resolvedFonts = configureFonts(deck, options);
1477
+ const pptx = new pptxgen();
1478
+ const ctx = { equations: [] };
1479
+ pptx.layout = "LAYOUT_WIDE";
1480
+ pptx.author = deck.meta?.author || "BIT";
1481
+ pptx.company = "Beijing Institute of Technology";
1482
+ pptx.subject = deck.meta?.title || "BIT presentation";
1483
+ pptx.title = deck.meta?.title || "BIT presentation";
1484
+ pptx.lang = "zh-CN";
1485
+ pptx.theme = {
1486
+ headFontFace: font.cn,
1487
+ bodyFontFace: font.cn,
1488
+ lang: "zh-CN",
1489
+ };
1490
+ pptx.defineLayout({ name: "BIT_WIDE", width: W, height: H });
1491
+ pptx.layout = "BIT_WIDE";
1492
+
1493
+ const preflight = expandSlidesWithReport(deck.slides || []);
1494
+ let pageNo = 1;
1495
+ for (const slide of preflight.slides) {
1496
+ switch (slide.layout) {
1497
+ case "title":
1498
+ layoutTitle(pptx, deck);
1499
+ break;
1500
+ case "agenda":
1501
+ layoutAgenda(pptx, slide, pageNo++);
1502
+ break;
1503
+ case "section":
1504
+ layoutSection(pptx, slide, pageNo++);
1505
+ break;
1506
+ case "bullets":
1507
+ layoutBullets(pptx, slide, pageNo++, ctx);
1508
+ break;
1509
+ case "claim":
1510
+ layoutClaim(pptx, slide, pageNo++, ctx);
1511
+ break;
1512
+ case "twoColumn":
1513
+ layoutTwoColumn(pptx, slide, pageNo++, ctx);
1514
+ break;
1515
+ case "cards":
1516
+ layoutCards(pptx, slide, pageNo++, ctx);
1517
+ break;
1518
+ case "table":
1519
+ layoutTable(pptx, slide, pageNo++, ctx);
1520
+ break;
1521
+ case "comparison":
1522
+ layoutComparison(pptx, slide, pageNo++, ctx);
1523
+ break;
1524
+ case "timeline":
1525
+ layoutTimeline(pptx, slide, pageNo++, ctx);
1526
+ break;
1527
+ case "process":
1528
+ layoutProcess(pptx, slide, pageNo++, ctx);
1529
+ break;
1530
+ case "problemSolution":
1531
+ layoutProblemSolution(pptx, slide, pageNo++, ctx);
1532
+ break;
1533
+ case "painOpportunity":
1534
+ layoutPainOpportunity(pptx, slide, pageNo++, ctx);
1535
+ break;
1536
+ case "experimentDesign":
1537
+ layoutExperimentDesign(pptx, slide, pageNo++, ctx);
1538
+ break;
1539
+ case "resultAnalysis":
1540
+ layoutResultAnalysis(pptx, slide, pageNo++, ctx);
1541
+ break;
1542
+ case "riskMitigation":
1543
+ layoutRiskMitigation(pptx, slide, pageNo++, ctx);
1544
+ break;
1545
+ case "contribution":
1546
+ layoutContribution(pptx, slide, pageNo++, ctx);
1547
+ break;
1548
+ case "summary":
1549
+ layoutSummary(pptx, slide, pageNo++, ctx);
1550
+ break;
1551
+ case "architecture":
1552
+ layoutArchitecture(pptx, slide, pageNo++, ctx);
1553
+ break;
1554
+ case "ablation":
1555
+ layoutAblation(pptx, slide, pageNo++, ctx);
1556
+ break;
1557
+ case "caseStudy":
1558
+ layoutCaseStudy(pptx, slide, pageNo++, ctx);
1559
+ break;
1560
+ case "imageGrid":
1561
+ layoutImageGrid(pptx, slide, pageNo++, ctx);
1562
+ break;
1563
+ case "code":
1564
+ layoutCode(pptx, slide, pageNo++, ctx);
1565
+ break;
1566
+ case "appendix":
1567
+ layoutAppendix(pptx, slide, pageNo++, ctx);
1568
+ break;
1569
+ case "flowchart":
1570
+ layoutFlowchart(pptx, slide, pageNo++, ctx);
1571
+ break;
1572
+ case "chart":
1573
+ layoutChart(pptx, slide, pageNo++);
1574
+ break;
1575
+ case "metrics":
1576
+ layoutMetrics(pptx, slide, pageNo++, ctx);
1577
+ break;
1578
+ case "quote":
1579
+ layoutQuote(pptx, slide, pageNo++, ctx);
1580
+ break;
1581
+ case "formula":
1582
+ layoutFormula(pptx, slide, pageNo++, ctx);
1583
+ break;
1584
+ case "references":
1585
+ layoutReferences(pptx, slide, pageNo++);
1586
+ break;
1587
+ case "matrix":
1588
+ layoutMatrix(pptx, slide, pageNo++, ctx);
1589
+ break;
1590
+ case "imageText":
1591
+ layoutImageText(pptx, slide, pageNo++, ctx);
1592
+ break;
1593
+ case "closing":
1594
+ layoutClosing(pptx, slide);
1595
+ break;
1596
+ default:
1597
+ throw new Error(`Unknown slide layout: ${slide.layout}`);
1598
+ }
1599
+ addSpeakerNotes(pptx, slide);
1600
+ }
1601
+ return { pptx, preflight: preflight.report, equations: ctx.equations, fonts: resolvedFonts };
1602
+ }
1603
+
1604
+ function listLayouts() {
1605
+ return coreListLayouts();
1606
+ }
1607
+
1608
+ function validateDeck(deck) {
1609
+ return coreValidateDeck(deck, { resolveImageInfo });
1610
+ }
1611
+
1612
+ function checkDeck(deck) {
1613
+ return coreCheckDeck(deck, { resolveImageInfo });
1614
+ }
1615
+
1616
+ function checkDeckFile(input) {
1617
+ const deck = readDeck(input);
1618
+ return checkDeck(deck);
1619
+ }
1620
+
1621
+ async function generateDeckFile(input, output, options = {}) {
1622
+ const deck = readDeck(input);
1623
+ const check = checkDeck(deck);
1624
+ if (check.validation.errors.length) {
1625
+ const error = new Error("Deck validation failed.");
1626
+ error.validation = check.validation;
1627
+ error.repairPrompt = check.repairPrompt;
1628
+ throw error;
1629
+ }
1630
+ fs.mkdirSync(path.dirname(output), { recursive: true });
1631
+ const { pptx, preflight, equations, fonts: resolvedFonts } = createDeck(deck, options);
1632
+ let fileName = output;
1633
+ try {
1634
+ await pptx.writeFile({ fileName });
1635
+ await postprocessOmml(fileName, equations);
1636
+ } catch (error) {
1637
+ if (error?.code !== "EBUSY") throw error;
1638
+ const parsed = path.parse(output);
1639
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
1640
+ fileName = path.join(parsed.dir, `${parsed.name}-${stamp}${parsed.ext}`);
1641
+ await pptx.writeFile({ fileName });
1642
+ await postprocessOmml(fileName, equations);
1643
+ }
1644
+ return { output: fileName, preflight, validation: check.validation, repairPrompt: check.repairPrompt, fonts: resolvedFonts };
1645
+ }
1646
+
1647
+ function parseLegacyOptions(args) {
1648
+ const options = {};
1649
+ for (let idx = 0; idx < args.length; idx += 1) {
1650
+ const arg = args[idx];
1651
+ if (arg === "--font-cn" || arg === "--font-cjk") options.fontCn = args[++idx];
1652
+ else if (arg === "--font-cn-light") options.fontCnLight = args[++idx];
1653
+ else if (arg === "--font-en" || arg === "--font-latin") options.fontEn = args[++idx];
1654
+ else if (arg === "--font-serif") options.fontSerif = args[++idx];
1655
+ else if (arg === "--font-code") options.fontCode = args[++idx];
1656
+ }
1657
+ return options;
1658
+ }
1659
+
1660
+ async function main() {
1661
+ const input = process.argv[2] || path.join(ROOT, "content", "example.yaml");
1662
+ const output = process.argv[3] || path.join(ROOT, "output", "example.pptx");
1663
+ const checkOnly = process.argv.includes("--check");
1664
+ if (checkOnly) {
1665
+ const result = checkDeckFile(input);
1666
+ console.log(JSON.stringify(result, null, 2));
1667
+ if (result.validation.errors.length) process.exitCode = 1;
1668
+ return;
1669
+ }
1670
+ try {
1671
+ const result = await generateDeckFile(input, output, parseLegacyOptions(process.argv.slice(4)));
1672
+ if (result.validation.warnings.length) {
1673
+ console.warn(`Validation warning(s): ${result.validation.warnings.length}`);
1674
+ console.warn(result.repairPrompt);
1675
+ }
1676
+ if (result.preflight.length) {
1677
+ console.log(`Preflight adjusted ${result.preflight.length} slide(s): ${result.preflight.map((item) => `${item.title}->${item.parts}`).join(", ")}`);
1678
+ }
1679
+ console.log(`Generated ${result.output}`);
1680
+ } catch (error) {
1681
+ if (error.validation) {
1682
+ console.error(JSON.stringify({ error: error.message, validation: error.validation, repairPrompt: error.repairPrompt }, null, 2));
1683
+ process.exitCode = 1;
1684
+ return;
1685
+ }
1686
+ throw error;
1687
+ }
1688
+ }
1689
+
1690
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
1691
+ main().catch((error) => {
1692
+ console.error(error);
1693
+ process.exitCode = 1;
1694
+ });
1695
+ }
1696
+
1697
+ export {
1698
+ ROOT,
1699
+ checkDeck,
1700
+ checkDeckFile,
1701
+ configureFonts,
1702
+ createDeck,
1703
+ defaultFont,
1704
+ generateDeckFile,
1705
+ listLayouts,
1706
+ readDeck,
1707
+ validateDeck,
1708
+ };