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.
- package/AI_CONTENT_GUIDE.md +661 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/assets/bit-campus-line.png +0 -0
- package/assets/bit-campus-photo.png +0 -0
- package/assets/bit-emblem-gray.png +0 -0
- package/assets/bit-seal-small.png +0 -0
- package/assets/bit-wordmark-white.png +0 -0
- package/bin/bit-ppt-http.mjs +58 -0
- package/bin/bit-ppt-mcp.mjs +27 -0
- package/bin/bit-ppt.mjs +480 -0
- package/content/body-layout-test.yaml +112 -0
- package/content/chart-flow-test.yaml +82 -0
- package/content/example.yaml +193 -0
- package/content/extended-layout-test.yaml +120 -0
- package/content/formula-test.yaml +31 -0
- package/content/image-layout-demo.yaml +64 -0
- package/content/inline-formula-test.yaml +62 -0
- package/content/invalid-deck-test.yaml +25 -0
- package/content/overflow-test.yaml +77 -0
- package/content/placeholder-image-demo.yaml +59 -0
- package/content/speaker-notes-demo.yaml +30 -0
- package/content/table-formula-test.yaml +21 -0
- package/package.json +42 -0
- package/src/core/layouts.mjs +58 -0
- package/src/core/preflight.mjs +263 -0
- package/src/core/validation.mjs +372 -0
- package/src/core/yaml-parse.mjs +80 -0
- package/src/generate.mjs +1708 -0
- package/src/http-server.mjs +1201 -0
- package/src/layout-guides.mjs +315 -0
- package/src/mcp-server.mjs +197 -0
package/src/generate.mjs
ADDED
|
@@ -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
|
+
};
|