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/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bit-ppt-generator",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "LaTeX-free Beijing Institute of Technology style PPTX generator with Web UI, CLI, and MCP entrypoints.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "src/generate.mjs",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"registry": "https://registry.npmjs.org/"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"src",
|
|
14
|
+
"assets",
|
|
15
|
+
"content",
|
|
16
|
+
"README.md",
|
|
17
|
+
"AI_CONTENT_GUIDE.md"
|
|
18
|
+
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"bit-ppt-generator": "bin/bit-ppt-http.mjs",
|
|
21
|
+
"bit-ppt": "bin/bit-ppt.mjs",
|
|
22
|
+
"bit-ppt-mcp": "bin/bit-ppt-mcp.mjs",
|
|
23
|
+
"bit-ppt-http": "bin/bit-ppt-http.mjs"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test",
|
|
27
|
+
"serve": "node bin/bit-ppt-http.mjs",
|
|
28
|
+
"build:ppt": "node bin/bit-ppt.mjs generate content/example.yaml output/example.pptx",
|
|
29
|
+
"check:ppt": "node bin/bit-ppt.mjs check content/example.yaml --json",
|
|
30
|
+
"build:body-layouts": "node bin/bit-ppt.mjs generate content/body-layout-test.yaml output/body-layout-test.pptx",
|
|
31
|
+
"check:body-layouts": "node bin/bit-ppt.mjs check content/body-layout-test.yaml --json",
|
|
32
|
+
"build:charts": "node bin/bit-ppt.mjs generate content/chart-flow-test.yaml output/chart-flow-test.pptx",
|
|
33
|
+
"check:charts": "node bin/bit-ppt.mjs check content/chart-flow-test.yaml --json"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
|
+
"latex-to-omml": "^2.1.1",
|
|
38
|
+
"pptxgenjs": "^4.0.1",
|
|
39
|
+
"yaml": "^2.8.4",
|
|
40
|
+
"zod": "^4.4.3"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const SUPPORTED_LAYOUTS = new Set([
|
|
2
|
+
"title",
|
|
3
|
+
"agenda",
|
|
4
|
+
"section",
|
|
5
|
+
"bullets",
|
|
6
|
+
"claim",
|
|
7
|
+
"twoColumn",
|
|
8
|
+
"cards",
|
|
9
|
+
"table",
|
|
10
|
+
"comparison",
|
|
11
|
+
"timeline",
|
|
12
|
+
"process",
|
|
13
|
+
"architecture",
|
|
14
|
+
"ablation",
|
|
15
|
+
"caseStudy",
|
|
16
|
+
"imageGrid",
|
|
17
|
+
"code",
|
|
18
|
+
"appendix",
|
|
19
|
+
"flowchart",
|
|
20
|
+
"chart",
|
|
21
|
+
"problemSolution",
|
|
22
|
+
"painOpportunity",
|
|
23
|
+
"experimentDesign",
|
|
24
|
+
"resultAnalysis",
|
|
25
|
+
"riskMitigation",
|
|
26
|
+
"contribution",
|
|
27
|
+
"summary",
|
|
28
|
+
"metrics",
|
|
29
|
+
"matrix",
|
|
30
|
+
"quote",
|
|
31
|
+
"formula",
|
|
32
|
+
"references",
|
|
33
|
+
"imageText",
|
|
34
|
+
"closing",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const CHART_TYPES = new Set(["bar", "line", "pie", "doughnut", "scatter", "area"]);
|
|
38
|
+
|
|
39
|
+
const PLACEHOLDER_ASPECT_RATIOS = {
|
|
40
|
+
"16:9": 16 / 9,
|
|
41
|
+
"4:3": 4 / 3,
|
|
42
|
+
"3:2": 3 / 2,
|
|
43
|
+
"1:1": 1,
|
|
44
|
+
"4:5": 4 / 5,
|
|
45
|
+
"3:4": 3 / 4,
|
|
46
|
+
"9:16": 9 / 16,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function listLayouts() {
|
|
50
|
+
return [...SUPPORTED_LAYOUTS];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
CHART_TYPES,
|
|
55
|
+
PLACEHOLDER_ASPECT_RATIOS,
|
|
56
|
+
SUPPORTED_LAYOUTS,
|
|
57
|
+
listLayouts,
|
|
58
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { PLACEHOLDER_ASPECT_RATIOS } from "./layouts.mjs";
|
|
2
|
+
|
|
3
|
+
const INCH_PT = 72;
|
|
4
|
+
const OVERFLOW_GUARD = 0.9;
|
|
5
|
+
|
|
6
|
+
function normalizeText(value) {
|
|
7
|
+
if (value === null || value === undefined) return "";
|
|
8
|
+
if (typeof value === "string") return value;
|
|
9
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
10
|
+
if (Array.isArray(value)) return value.map(normalizeText).join(" ");
|
|
11
|
+
if (typeof value === "object") {
|
|
12
|
+
return Object.entries(value)
|
|
13
|
+
.map(([key, val]) => `${key}: ${normalizeText(val)}`)
|
|
14
|
+
.join(" ");
|
|
15
|
+
}
|
|
16
|
+
return String(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clone(value) {
|
|
20
|
+
return JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasKnownPlaceholderRatio(value) {
|
|
24
|
+
const ratio = normalizeText(value).toLowerCase();
|
|
25
|
+
if (!ratio || ratio === "auto" || ratio === "unknown") return false;
|
|
26
|
+
if (PLACEHOLDER_ASPECT_RATIOS[ratio]) return true;
|
|
27
|
+
const colon = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
|
28
|
+
if (colon) return Number(colon[1]) > 0 && Number(colon[2]) > 0;
|
|
29
|
+
const numeric = Number(ratio);
|
|
30
|
+
return Number.isFinite(numeric) && numeric > 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolvePlaceholderRatio(value, fallback = 16 / 9) {
|
|
34
|
+
const ratio = normalizeText(value).toLowerCase();
|
|
35
|
+
if (PLACEHOLDER_ASPECT_RATIOS[ratio]) return PLACEHOLDER_ASPECT_RATIOS[ratio];
|
|
36
|
+
const colon = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
|
|
37
|
+
if (colon) {
|
|
38
|
+
const w = Number(colon[1]);
|
|
39
|
+
const h = Number(colon[2]);
|
|
40
|
+
if (w > 0 && h > 0) return w / h;
|
|
41
|
+
}
|
|
42
|
+
const numeric = Number(ratio);
|
|
43
|
+
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSpeakerNotesValue(slide) {
|
|
48
|
+
if (!slide || typeof slide !== "object") return undefined;
|
|
49
|
+
return slide.speakerNotes ?? slide.speaker_notes ?? slide.speakerScript ?? slide.speaker_script;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeSpeakerNotes(value) {
|
|
53
|
+
if (value === null || value === undefined) return "";
|
|
54
|
+
if (Array.isArray(value)) return value.map(normalizeText).filter(Boolean).join("\n");
|
|
55
|
+
return normalizeText(value).replace(/\r\n?/g, "\n").trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlaceholderImage(value) {
|
|
59
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
60
|
+
const mode = normalizeText(value.mode || value.type).toLowerCase();
|
|
61
|
+
return mode === "placeholder" || value.placeholder === true || value.noImage === true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function imagePlaceholderNeedsVariants(slide) {
|
|
65
|
+
if (!slide || slide.layout !== "imageText" || !isPlaceholderImage(slide.image)) return false;
|
|
66
|
+
if (slide.placeholderVariants === false) return false;
|
|
67
|
+
if (slide.image && typeof slide.image === "object" && slide.image.variants === false) return false;
|
|
68
|
+
const ratio = normalizeText(slide.image.aspectRatio || slide.image.ratio || slide.image.size).toLowerCase();
|
|
69
|
+
return !hasKnownPlaceholderRatio(ratio);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function splitImagePlaceholderSlide(slide) {
|
|
73
|
+
if (!imagePlaceholderNeedsVariants(slide)) return [slide];
|
|
74
|
+
const baseImage = slide.image && typeof slide.image === "object" && !Array.isArray(slide.image) ? slide.image : { mode: "placeholder" };
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
...clone(slide),
|
|
78
|
+
title: `${slide.title || "图文说明"}(横图方案)`,
|
|
79
|
+
image: { ...baseImage, mode: "placeholder", aspectRatio: "16:9", placement: "top", variants: false },
|
|
80
|
+
placement: "top",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
...clone(slide),
|
|
84
|
+
title: `${slide.title || "图文说明"}(侧图方案)`,
|
|
85
|
+
image: { ...baseImage, mode: "placeholder", aspectRatio: "4:3", placement: "side", variants: false },
|
|
86
|
+
placement: "side",
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isCjk(ch) {
|
|
92
|
+
return /[\u3400-\u9fff\uff00-\uffef]/u.test(ch);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function charWidthPt(ch, fontSize, bold = false) {
|
|
96
|
+
const factor = bold ? 1.06 : 1;
|
|
97
|
+
if (ch === "\n") return Infinity;
|
|
98
|
+
if (/\s/.test(ch)) return fontSize * 0.33;
|
|
99
|
+
if (isCjk(ch)) return fontSize * 1.0 * factor;
|
|
100
|
+
if (/[A-Z]/.test(ch)) return fontSize * 0.64 * factor;
|
|
101
|
+
if (/[0-9]/.test(ch)) return fontSize * 0.58 * factor;
|
|
102
|
+
if (/[.,;:!?()[\]{}'"`/\\|+-]/.test(ch)) return fontSize * 0.38 * factor;
|
|
103
|
+
return fontSize * 0.54 * factor;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function estimateText(text, boxW, fontSize, opts = {}) {
|
|
107
|
+
const maxPt = Math.max(1, boxW * INCH_PT - (opts.marginPt || 0));
|
|
108
|
+
const lineHeight = opts.lineHeight || 1.18;
|
|
109
|
+
const value = normalizeText(text);
|
|
110
|
+
let lines = 1;
|
|
111
|
+
let current = 0;
|
|
112
|
+
for (const ch of value) {
|
|
113
|
+
const width = charWidthPt(ch, fontSize, opts.bold);
|
|
114
|
+
if (!Number.isFinite(width)) {
|
|
115
|
+
lines += 1;
|
|
116
|
+
current = 0;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (current > 0 && current + width > maxPt) {
|
|
120
|
+
lines += 1;
|
|
121
|
+
current = width;
|
|
122
|
+
} else {
|
|
123
|
+
current += width;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
lines,
|
|
128
|
+
height: (lines * fontSize * lineHeight) / INCH_PT,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function estimateBulletHeight(text, boxW, fontSize) {
|
|
133
|
+
return estimateText(text, Math.max(0.5, boxW - 0.34), fontSize, { lineHeight: 1.22 }).height + 0.14;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function splitBulletsSlide(slide) {
|
|
137
|
+
const bullets = slide.bullets || [];
|
|
138
|
+
const fontSize = slide.fontSize || 16;
|
|
139
|
+
const startY = slide.lead ? 2.92 : 1.72;
|
|
140
|
+
const available = 6.62 - startY;
|
|
141
|
+
const chunks = [];
|
|
142
|
+
let chunk = [];
|
|
143
|
+
let used = 0;
|
|
144
|
+
for (const bullet of bullets) {
|
|
145
|
+
const h = estimateBulletHeight(bullet, 10.4, fontSize);
|
|
146
|
+
if (chunk.length && used + h > available * OVERFLOW_GUARD) {
|
|
147
|
+
chunks.push(chunk);
|
|
148
|
+
chunk = [];
|
|
149
|
+
used = 0;
|
|
150
|
+
}
|
|
151
|
+
chunk.push(bullet);
|
|
152
|
+
used += h;
|
|
153
|
+
}
|
|
154
|
+
if (chunk.length) chunks.push(chunk);
|
|
155
|
+
if (chunks.length <= 1) return [slide];
|
|
156
|
+
return chunks.map((items, idx) => ({
|
|
157
|
+
...clone(slide),
|
|
158
|
+
title: `${slide.title || "要点"}(${idx + 1}/${chunks.length})`,
|
|
159
|
+
lead: idx === 0 ? slide.lead : undefined,
|
|
160
|
+
bullets: items,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function estimateTableRowHeight(row, colW, fontSize) {
|
|
165
|
+
const heights = row.map((cell, idx) => estimateText(cell, colW[idx] || colW[0], fontSize, { lineHeight: 1.16, marginPt: 10 }).height + 0.16);
|
|
166
|
+
return Math.max(0.42, ...heights);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function splitTableSlide(slide) {
|
|
170
|
+
const columns = slide.columns || [];
|
|
171
|
+
const rows = slide.rows || [];
|
|
172
|
+
const fontSize = rows.length > 5 ? 8.5 : 10;
|
|
173
|
+
const tableW = 11.82;
|
|
174
|
+
const colW = columns.map(() => tableW / Math.max(1, columns.length));
|
|
175
|
+
const available = 4.95 - 0.5;
|
|
176
|
+
const chunks = [];
|
|
177
|
+
let chunk = [];
|
|
178
|
+
let used = 0;
|
|
179
|
+
for (const row of rows) {
|
|
180
|
+
const h = estimateTableRowHeight(row, colW, fontSize);
|
|
181
|
+
if (chunk.length && used + h > available * OVERFLOW_GUARD) {
|
|
182
|
+
chunks.push(chunk);
|
|
183
|
+
chunk = [];
|
|
184
|
+
used = 0;
|
|
185
|
+
}
|
|
186
|
+
chunk.push(row);
|
|
187
|
+
used += h;
|
|
188
|
+
}
|
|
189
|
+
if (chunk.length) chunks.push(chunk);
|
|
190
|
+
if (chunks.length <= 1) return [slide];
|
|
191
|
+
return chunks.map((items, idx) => ({
|
|
192
|
+
...clone(slide),
|
|
193
|
+
title: `${slide.title || "表格"}(${idx + 1}/${chunks.length})`,
|
|
194
|
+
rows: items,
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function splitReferencesSlide(slide) {
|
|
199
|
+
const items = slide.items || [];
|
|
200
|
+
const fontSize = slide.fontSize || 9.5;
|
|
201
|
+
const available = 4.95;
|
|
202
|
+
const chunks = [];
|
|
203
|
+
let chunk = [];
|
|
204
|
+
let used = 0;
|
|
205
|
+
for (const item of items) {
|
|
206
|
+
const h = estimateText(item, 11.1, fontSize, { lineHeight: 1.18 }).height + 0.11;
|
|
207
|
+
if (chunk.length && used + h > available * OVERFLOW_GUARD) {
|
|
208
|
+
chunks.push(chunk);
|
|
209
|
+
chunk = [];
|
|
210
|
+
used = 0;
|
|
211
|
+
}
|
|
212
|
+
chunk.push(item);
|
|
213
|
+
used += h;
|
|
214
|
+
}
|
|
215
|
+
if (chunk.length) chunks.push(chunk);
|
|
216
|
+
if (chunks.length <= 1) return [slide];
|
|
217
|
+
return chunks.map((itemsChunk, idx) => ({
|
|
218
|
+
...clone(slide),
|
|
219
|
+
title: `${slide.title || "参考文献"}(${idx + 1}/${chunks.length})`,
|
|
220
|
+
items: itemsChunk,
|
|
221
|
+
}));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function expandSlidesWithReport(slides = []) {
|
|
225
|
+
const report = [];
|
|
226
|
+
const expanded = [];
|
|
227
|
+
slides.forEach((slide, index) => {
|
|
228
|
+
let parts;
|
|
229
|
+
if (slide.layout === "bullets") parts = splitBulletsSlide(slide);
|
|
230
|
+
else if (slide.layout === "table") parts = splitTableSlide(slide);
|
|
231
|
+
else if (slide.layout === "references") parts = splitReferencesSlide(slide);
|
|
232
|
+
else if (slide.layout === "imageText") parts = splitImagePlaceholderSlide(slide);
|
|
233
|
+
else parts = [slide];
|
|
234
|
+
if (parts.length > 1) {
|
|
235
|
+
report.push({
|
|
236
|
+
slideIndex: index + 1,
|
|
237
|
+
layout: slide.layout,
|
|
238
|
+
title: slide.title || slide.layout,
|
|
239
|
+
action: imagePlaceholderNeedsVariants(slide) ? "placeholderVariants" : "split",
|
|
240
|
+
parts: parts.length,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
expanded.push(...parts);
|
|
244
|
+
});
|
|
245
|
+
return { slides: expanded, report };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function expandSlides(slides = []) {
|
|
249
|
+
return expandSlidesWithReport(slides).slides;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export {
|
|
253
|
+
clone,
|
|
254
|
+
expandSlides,
|
|
255
|
+
expandSlidesWithReport,
|
|
256
|
+
getSpeakerNotesValue,
|
|
257
|
+
hasKnownPlaceholderRatio,
|
|
258
|
+
imagePlaceholderNeedsVariants,
|
|
259
|
+
isPlaceholderImage,
|
|
260
|
+
normalizeSpeakerNotes,
|
|
261
|
+
normalizeText,
|
|
262
|
+
resolvePlaceholderRatio,
|
|
263
|
+
};
|