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
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
import { parseDeckYaml } from "./core/yaml-parse.mjs";
|
|
9
|
+
import {
|
|
10
|
+
checkDeck,
|
|
11
|
+
generateDeckFile,
|
|
12
|
+
} from "./generate.mjs";
|
|
13
|
+
|
|
14
|
+
const PPTX_MIME = "application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
|
15
|
+
const DEFAULT_BODY_LIMIT = 10 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_GENERATE_CONCURRENCY = 1;
|
|
17
|
+
const BIT_CAS_TICKETS_URL = "https://sso.bit.edu.cn/cas/v1/tickets";
|
|
18
|
+
const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
19
|
+
const WEB_APP_HTML = String.raw`<!doctype html>
|
|
20
|
+
<html lang="zh-CN">
|
|
21
|
+
<head>
|
|
22
|
+
<meta charset="utf-8" />
|
|
23
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
24
|
+
<title>BIT PPT Generator</title>
|
|
25
|
+
<style>
|
|
26
|
+
:root { color-scheme: light; --red: #9d1d22; --ink: #20242a; --muted: #68707a; --line: #d8dde3; --bg: #f5f7fa; --panel: #ffffff; }
|
|
27
|
+
* { box-sizing: border-box; }
|
|
28
|
+
body { margin: 0; font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif; color: var(--ink); background: var(--bg); }
|
|
29
|
+
header { background: var(--red); color: #fff; padding: 18px 24px; }
|
|
30
|
+
header h1 { margin: 0; font-size: 22px; font-weight: 650; letter-spacing: 0; }
|
|
31
|
+
main { max-width: 1320px; margin: 0 auto; padding: 22px; display: grid; gap: 16px; grid-template-columns: 320px 1fr; }
|
|
32
|
+
section { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; }
|
|
33
|
+
h2 { margin: 0 0 14px; font-size: 16px; }
|
|
34
|
+
h3 { margin: 16px 0 8px; font-size: 14px; }
|
|
35
|
+
label { display: block; margin: 12px 0 6px; color: var(--muted); font-size: 13px; }
|
|
36
|
+
input, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 10px 11px; font: inherit; background: #fff; color: var(--ink); }
|
|
37
|
+
textarea { min-height: 520px; resize: vertical; font-family: Consolas, "Cascadia Mono", monospace; font-size: 13px; line-height: 1.45; }
|
|
38
|
+
.mini { min-height: 132px; max-height: 220px; font-size: 12px; }
|
|
39
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
40
|
+
button { border: 1px solid var(--red); background: var(--red); color: #fff; border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; }
|
|
41
|
+
button.secondary { background: #fff; color: var(--red); }
|
|
42
|
+
button:disabled { opacity: .55; cursor: not-allowed; }
|
|
43
|
+
.status { min-height: 42px; white-space: pre-wrap; font-family: Consolas, "Cascadia Mono", monospace; font-size: 12px; color: var(--muted); }
|
|
44
|
+
.ok { color: #137333; }
|
|
45
|
+
.err { color: #b3261e; }
|
|
46
|
+
.hidden { display: none; }
|
|
47
|
+
@media (max-width: 860px) { main { grid-template-columns: 1fr; padding: 14px; } textarea { min-height: 420px; } }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<header><h1>BIT PPT Generator</h1></header>
|
|
52
|
+
<main>
|
|
53
|
+
<section>
|
|
54
|
+
<div id="authSection" class="hidden">
|
|
55
|
+
<h2>北理工登录</h2>
|
|
56
|
+
<label for="username">学号</label>
|
|
57
|
+
<input id="username" autocomplete="username" />
|
|
58
|
+
<label for="password">密码</label>
|
|
59
|
+
<input id="password" type="password" autocomplete="current-password" />
|
|
60
|
+
<div class="row" style="margin-top: 14px;">
|
|
61
|
+
<button id="loginBtn">登录</button>
|
|
62
|
+
<button id="logoutBtn" class="secondary">退出</button>
|
|
63
|
+
</div>
|
|
64
|
+
<div id="authStatus" class="status"></div>
|
|
65
|
+
</div>
|
|
66
|
+
<h2>输出</h2>
|
|
67
|
+
<label for="outputName">文件名</label>
|
|
68
|
+
<input id="outputName" value="bit-ppt" />
|
|
69
|
+
<div class="row" style="margin-top: 14px;">
|
|
70
|
+
<button id="checkBtn" class="secondary">检查</button>
|
|
71
|
+
<button id="generateBtn">生成 PPTX</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div id="runStatus" class="status"></div>
|
|
74
|
+
<h2>AI 辅助</h2>
|
|
75
|
+
<div class="row">
|
|
76
|
+
<button id="copyPromptBtn" class="secondary">复制提示词</button>
|
|
77
|
+
<button id="copyRulesBtn" class="secondary">复制语法规则</button>
|
|
78
|
+
<button id="insertExampleBtn" class="secondary">插入最小示例</button>
|
|
79
|
+
<button id="insertFullExampleBtn" class="secondary">插入完整示例</button>
|
|
80
|
+
</div>
|
|
81
|
+
<div id="copyStatus" class="status"></div>
|
|
82
|
+
<h3>提示词</h3>
|
|
83
|
+
<textarea id="aiPrompt" class="mini" readonly>你是一个 PPT 内容规划助手。请根据我给出的主题和材料,生成可直接用于 BIT PPT Generator 的 YAML。
|
|
84
|
+
|
|
85
|
+
硬性要求:
|
|
86
|
+
1. 只输出 YAML 正文,不要 Markdown 代码块,不要解释。
|
|
87
|
+
2. 顶层必须包含 meta 和 slides。
|
|
88
|
+
3. slides 中每一页必须包含 layout。
|
|
89
|
+
4. 使用北理工学术汇报风格:标题明确、要点简短、层次清楚、避免营销化表达。
|
|
90
|
+
5. 每页内容不要过满。bullets 建议 3-5 条,长内容拆成多页。
|
|
91
|
+
6. 不要编造本地图片路径。没有图片时使用 image.mode: placeholder,并写清 image.prompt。
|
|
92
|
+
7. 需要演讲稿时使用 speakerNotes;备注写演讲提示,不要重复整页正文。
|
|
93
|
+
8. 公式使用 LaTeX。行内公式写在正文中,例如 $p_{\theta}(x)$。展示公式使用 formula.latex。
|
|
94
|
+
9. YAML 中 LaTeX 推荐不用双引号;可写 plain scalar,例如 latex: \mathcal{L}=...,或用单引号。
|
|
95
|
+
10. 生成后应能通过 check:不要使用未知 layout,不要输出过长表格或过长参考文献。
|
|
96
|
+
|
|
97
|
+
优先使用的 layout:
|
|
98
|
+
- title: 封面
|
|
99
|
+
- agenda: 目录
|
|
100
|
+
- section: 章节分隔
|
|
101
|
+
- bullets: 要点页
|
|
102
|
+
- claim: 单页核心结论
|
|
103
|
+
- twoColumn: 双栏对比
|
|
104
|
+
- cards: 多卡片观点
|
|
105
|
+
- table: 表格
|
|
106
|
+
- comparison: 左右方案对比
|
|
107
|
+
- timeline: 时间线
|
|
108
|
+
- process: 流程步骤
|
|
109
|
+
- metrics: 指标页
|
|
110
|
+
- matrix: 判断矩阵
|
|
111
|
+
- quote: 引用页
|
|
112
|
+
- formula: 展示公式
|
|
113
|
+
- chart: 原生图表
|
|
114
|
+
- flowchart: 可编辑流程图
|
|
115
|
+
- architecture: 架构页
|
|
116
|
+
- experimentDesign: 实验设计
|
|
117
|
+
- resultAnalysis: 结果分析
|
|
118
|
+
- imageText: 图文页或图片占位
|
|
119
|
+
- imageGrid: 多图页
|
|
120
|
+
- code: 代码/伪代码
|
|
121
|
+
- references: 参考文献
|
|
122
|
+
- closing: 结束页
|
|
123
|
+
|
|
124
|
+
请根据材料自行选择合适布局,不必每种都用。输出要完整、可生成。
|
|
125
|
+
|
|
126
|
+
主题:
|
|
127
|
+
|
|
128
|
+
材料:</textarea>
|
|
129
|
+
<h3>语法速查</h3>
|
|
130
|
+
<textarea id="syntaxRules" class="mini" readonly>基本结构:
|
|
131
|
+
meta:
|
|
132
|
+
title: 标题
|
|
133
|
+
author: 作者
|
|
134
|
+
date: 2026.05
|
|
135
|
+
slides:
|
|
136
|
+
- layout: title
|
|
137
|
+
title: 主标题
|
|
138
|
+
subtitle: 副标题
|
|
139
|
+
speakerNotes: |
|
|
140
|
+
这里写演讲者备注。
|
|
141
|
+
备注不会出现在幻灯片画布上。
|
|
142
|
+
|
|
143
|
+
常用页面:
|
|
144
|
+
- layout: bullets
|
|
145
|
+
title: 页面标题
|
|
146
|
+
lead: 页面导语,可选
|
|
147
|
+
bullets:
|
|
148
|
+
- 要点一
|
|
149
|
+
- 要点二
|
|
150
|
+
speakerNotes:
|
|
151
|
+
- 备注也可以写成数组。
|
|
152
|
+
- 每条是一段提示。
|
|
153
|
+
|
|
154
|
+
- layout: claim
|
|
155
|
+
title: 核心结论
|
|
156
|
+
claim: 一句话结论,可包含 $p_{\theta}(x)$。
|
|
157
|
+
evidence:
|
|
158
|
+
- 证据一
|
|
159
|
+
- 证据二
|
|
160
|
+
|
|
161
|
+
- layout: twoColumn
|
|
162
|
+
title: 双栏
|
|
163
|
+
left:
|
|
164
|
+
title: 左栏
|
|
165
|
+
bullets: [A, B]
|
|
166
|
+
right:
|
|
167
|
+
title: 右栏
|
|
168
|
+
bullets: [C, D]
|
|
169
|
+
|
|
170
|
+
- layout: cards
|
|
171
|
+
title: 卡片页
|
|
172
|
+
cards:
|
|
173
|
+
- title: 卡片一
|
|
174
|
+
text: 简短说明
|
|
175
|
+
- title: 卡片二
|
|
176
|
+
text: 简短说明
|
|
177
|
+
|
|
178
|
+
- layout: table
|
|
179
|
+
title: 表格
|
|
180
|
+
columns: [符号, 含义, 示例公式]
|
|
181
|
+
rows:
|
|
182
|
+
- ['$\theta$', 模型参数, '$p_\theta(x_i)$']
|
|
183
|
+
- ['$x_i$', 第 i 个样本, '$\mathcal{L}_i=-y_i\log p_\theta(x_i)$']
|
|
184
|
+
|
|
185
|
+
- layout: comparison
|
|
186
|
+
title: 方案对比
|
|
187
|
+
left:
|
|
188
|
+
label: 方案 A
|
|
189
|
+
title: 传统做法
|
|
190
|
+
bullets: [问题一, 问题二]
|
|
191
|
+
right:
|
|
192
|
+
label: 方案 B
|
|
193
|
+
title: 推荐做法
|
|
194
|
+
bullets: [优势一, 优势二]
|
|
195
|
+
|
|
196
|
+
- layout: timeline
|
|
197
|
+
title: 路线图
|
|
198
|
+
items:
|
|
199
|
+
- date: 阶段 1
|
|
200
|
+
title: 准备
|
|
201
|
+
text: 收集材料
|
|
202
|
+
- date: 阶段 2
|
|
203
|
+
title: 生成
|
|
204
|
+
text: 导出 PPTX
|
|
205
|
+
|
|
206
|
+
- layout: process
|
|
207
|
+
title: 流程
|
|
208
|
+
steps:
|
|
209
|
+
- title: 输入
|
|
210
|
+
text: 整理材料
|
|
211
|
+
- title: 输出
|
|
212
|
+
text: 生成 PPTX
|
|
213
|
+
|
|
214
|
+
- layout: metrics
|
|
215
|
+
title: 指标页
|
|
216
|
+
metrics:
|
|
217
|
+
- value: 92%
|
|
218
|
+
label: 准确率
|
|
219
|
+
note: 实验集 A
|
|
220
|
+
- value: 0
|
|
221
|
+
label: 错误数
|
|
222
|
+
note: 校验通过
|
|
223
|
+
|
|
224
|
+
- layout: matrix
|
|
225
|
+
title: 判断矩阵
|
|
226
|
+
cells:
|
|
227
|
+
- title: 低成本 / 高控制
|
|
228
|
+
text: 推荐路线
|
|
229
|
+
- title: 高成本 / 低控制
|
|
230
|
+
text: 不推荐
|
|
231
|
+
|
|
232
|
+
- layout: formula
|
|
233
|
+
title: 展示公式
|
|
234
|
+
formula:
|
|
235
|
+
latex: \mathcal{L}(\theta)=-\sum_i y_i\log p_\theta(x_i)
|
|
236
|
+
caption: 公式说明
|
|
237
|
+
explanation:
|
|
238
|
+
- '$\theta$ 表示模型参数。'
|
|
239
|
+
- '$p_\theta(x_i)$ 表示预测概率。'
|
|
240
|
+
|
|
241
|
+
- layout: chart
|
|
242
|
+
title: 图表
|
|
243
|
+
type: bar
|
|
244
|
+
categories: [A, B, C]
|
|
245
|
+
series:
|
|
246
|
+
- name: Baseline
|
|
247
|
+
values: [80, 82, 84]
|
|
248
|
+
- name: Ours
|
|
249
|
+
values: [85, 87, 90]
|
|
250
|
+
caption: 原生 PowerPoint 图表。
|
|
251
|
+
|
|
252
|
+
- layout: flowchart
|
|
253
|
+
title: 流程图
|
|
254
|
+
nodes:
|
|
255
|
+
- id: input
|
|
256
|
+
text: 输入
|
|
257
|
+
note: 材料
|
|
258
|
+
- id: output
|
|
259
|
+
text: 输出
|
|
260
|
+
note: PPTX
|
|
261
|
+
edges:
|
|
262
|
+
- from: input
|
|
263
|
+
to: output
|
|
264
|
+
|
|
265
|
+
- layout: architecture
|
|
266
|
+
title: 架构
|
|
267
|
+
layers:
|
|
268
|
+
- title: 输入层
|
|
269
|
+
components: [论文, 数据, 图片]
|
|
270
|
+
note: 材料整理
|
|
271
|
+
- title: 生成层
|
|
272
|
+
components: [校验, 公式, PPTX]
|
|
273
|
+
note: 模板生成
|
|
274
|
+
|
|
275
|
+
- layout: experimentDesign
|
|
276
|
+
title: 实验设计
|
|
277
|
+
dataset:
|
|
278
|
+
- 数据来源
|
|
279
|
+
variables:
|
|
280
|
+
- 自变量
|
|
281
|
+
metrics:
|
|
282
|
+
- 评价指标
|
|
283
|
+
baselines:
|
|
284
|
+
- 对照方法
|
|
285
|
+
procedure: [准备数据, 运行实验, 分析结果]
|
|
286
|
+
|
|
287
|
+
- layout: resultAnalysis
|
|
288
|
+
title: 结果分析
|
|
289
|
+
finding: 一句话发现。
|
|
290
|
+
metrics:
|
|
291
|
+
- value: 18
|
|
292
|
+
label: OMath 对象
|
|
293
|
+
note: 公式可编辑
|
|
294
|
+
analysis:
|
|
295
|
+
- 分析一
|
|
296
|
+
- 分析二
|
|
297
|
+
|
|
298
|
+
- layout: imageText
|
|
299
|
+
title: 图片占位
|
|
300
|
+
image:
|
|
301
|
+
mode: placeholder
|
|
302
|
+
aspectRatio: "16:9"
|
|
303
|
+
placement: top
|
|
304
|
+
prompt: 描述后续要替换的图片。
|
|
305
|
+
text:
|
|
306
|
+
- 说明一
|
|
307
|
+
- 说明二
|
|
308
|
+
|
|
309
|
+
- layout: imageGrid
|
|
310
|
+
title: 多图占位
|
|
311
|
+
images:
|
|
312
|
+
- mode: placeholder
|
|
313
|
+
aspectRatio: "1:1"
|
|
314
|
+
prompt: 输入截图
|
|
315
|
+
caption: 输入
|
|
316
|
+
- mode: placeholder
|
|
317
|
+
aspectRatio: "1:1"
|
|
318
|
+
prompt: 输出截图
|
|
319
|
+
caption: 输出
|
|
320
|
+
|
|
321
|
+
- layout: code
|
|
322
|
+
title: 伪代码
|
|
323
|
+
language: Algorithm
|
|
324
|
+
code: |
|
|
325
|
+
Input: draft D
|
|
326
|
+
1. parse D
|
|
327
|
+
2. select layout
|
|
328
|
+
3. export PPTX
|
|
329
|
+
noteTitle: 关键约束
|
|
330
|
+
notes:
|
|
331
|
+
- 只输出结构化 YAML。
|
|
332
|
+
|
|
333
|
+
- layout: references
|
|
334
|
+
title: 参考文献
|
|
335
|
+
items:
|
|
336
|
+
- Microsoft. Office Open XML File Formats.
|
|
337
|
+
- PptxGenJS documentation.
|
|
338
|
+
|
|
339
|
+
YAML 与公式注意事项:
|
|
340
|
+
- 不要把 LaTeX 放进 YAML 双引号,除非把反斜杠写成双反斜杠。
|
|
341
|
+
- 推荐:latex: \mathcal{L}=...
|
|
342
|
+
- 推荐:'$p_\theta(x_i)$ 表示预测概率。'
|
|
343
|
+
- 表格中有公式时,建议用单引号包住单元格。
|
|
344
|
+
- 图片没有真实路径时使用 placeholder,不要写不存在的 assets 路径。
|
|
345
|
+
- 每页文字保持短;如果 check 返回 warnings,按 repairPrompt 压缩或拆页。</textarea>
|
|
346
|
+
<textarea id="exampleYaml" hidden>meta:
|
|
347
|
+
title: 北理工风格汇报
|
|
348
|
+
author: BIT PPT Generator
|
|
349
|
+
slides:
|
|
350
|
+
- layout: title
|
|
351
|
+
title: 北理工风格汇报
|
|
352
|
+
subtitle: YAML 到可编辑 PPTX
|
|
353
|
+
- layout: agenda
|
|
354
|
+
title: 目录
|
|
355
|
+
items:
|
|
356
|
+
- 背景与问题
|
|
357
|
+
- 方法设计
|
|
358
|
+
- 实验结果
|
|
359
|
+
- 总结展望
|
|
360
|
+
- layout: bullets
|
|
361
|
+
title: 背景与问题
|
|
362
|
+
bullets:
|
|
363
|
+
- 传统 PPT 制作成本高,格式一致性难以保证。
|
|
364
|
+
- 目标是用 YAML 生成可编辑、可复用的 PPTX。
|
|
365
|
+
- 生成结果保留文本、图表和公式的可编辑性。
|
|
366
|
+
- layout: twoColumn
|
|
367
|
+
title: 方案设计
|
|
368
|
+
columns:
|
|
369
|
+
- title: 输入
|
|
370
|
+
bullets:
|
|
371
|
+
- YAML 描述页面结构
|
|
372
|
+
- 使用 layout 选择版式
|
|
373
|
+
- 支持公式、图表和图片占位
|
|
374
|
+
- title: 输出
|
|
375
|
+
bullets:
|
|
376
|
+
- 可编辑 PPTX
|
|
377
|
+
- 北理工风格视觉规范
|
|
378
|
+
- 适合答辩、组会和项目汇报
|
|
379
|
+
- layout: formula
|
|
380
|
+
title: 公式示例
|
|
381
|
+
formula:
|
|
382
|
+
latex: \mathcal{L}=\sum_i (y_i-f(x_i))^2
|
|
383
|
+
caption: 均方误差目标函数
|
|
384
|
+
notes:
|
|
385
|
+
- 公式会转换为 Office Math,而不是截图。
|
|
386
|
+
- layout: chart
|
|
387
|
+
title: 图表示例
|
|
388
|
+
type: bar
|
|
389
|
+
categories: [方法A, 方法B, 本方案]
|
|
390
|
+
series:
|
|
391
|
+
- name: 效率
|
|
392
|
+
values: [62, 74, 91]
|
|
393
|
+
caption: 原生 PowerPoint 图表,可继续编辑数据。
|
|
394
|
+
- layout: closing
|
|
395
|
+
title: 谢谢
|
|
396
|
+
subtitle: 欢迎批评指正</textarea>
|
|
397
|
+
<textarea id="fullExampleYaml" hidden>meta:
|
|
398
|
+
title: BIT PPT Generator 完整功能示例
|
|
399
|
+
subtitle: 版式、公式、图表、备注与图片占位
|
|
400
|
+
author: BIT PPT Generator
|
|
401
|
+
date: 2026.05
|
|
402
|
+
slides:
|
|
403
|
+
- layout: title
|
|
404
|
+
title: BIT PPT Generator 完整功能示例
|
|
405
|
+
subtitle: YAML 到可编辑 PPTX
|
|
406
|
+
speakerNotes: |
|
|
407
|
+
开场说明:这份示例用于展示主要能力。
|
|
408
|
+
备注不会出现在幻灯片画布上,只会写入 PowerPoint/WPS 备注区。
|
|
409
|
+
|
|
410
|
+
- layout: agenda
|
|
411
|
+
title: 目录
|
|
412
|
+
items:
|
|
413
|
+
- 基础文本与行内公式
|
|
414
|
+
- 表格、图表与流程图
|
|
415
|
+
- 研究语义版式
|
|
416
|
+
- 图片占位、代码与参考文献
|
|
417
|
+
speakerNotes:
|
|
418
|
+
- 这一页展示 speakerNotes 的数组写法。
|
|
419
|
+
- 每一条会进入备注区,适合写演讲提示。
|
|
420
|
+
|
|
421
|
+
- layout: bullets
|
|
422
|
+
title: 要点页与行内公式
|
|
423
|
+
lead: 普通正文中可以写 $\theta$ 和 $p_{\theta}(x)$。
|
|
424
|
+
bullets:
|
|
425
|
+
- 参数 $\theta$ 通过梯度下降更新。
|
|
426
|
+
- 损失 $L_{train}$ 与误差 $E_{val}$ 可写正文。
|
|
427
|
+
- 长列表会在 preflight 中被检查,必要时拆页。
|
|
428
|
+
|
|
429
|
+
- layout: claim
|
|
430
|
+
title: 单页结论
|
|
431
|
+
claim: 当 $\mathcal{L}(\theta)$ 下降时,模型拟合能力通常增强。
|
|
432
|
+
evidence:
|
|
433
|
+
- 模板负责视觉规则和版式稳定性。
|
|
434
|
+
- 生成器负责 OMML 公式、表格和图表写入。
|
|
435
|
+
- AI 只需要输出结构化 YAML。
|
|
436
|
+
|
|
437
|
+
- layout: twoColumn
|
|
438
|
+
title: 两栏对比
|
|
439
|
+
left:
|
|
440
|
+
title: 传统做法
|
|
441
|
+
bullets:
|
|
442
|
+
- AI 直接写 PPT 代码。
|
|
443
|
+
- 版式容易漂移。
|
|
444
|
+
- 长文本容易溢出。
|
|
445
|
+
right:
|
|
446
|
+
title: 模板化做法
|
|
447
|
+
bullets:
|
|
448
|
+
- AI 输出 YAML。
|
|
449
|
+
- 模板统一控制视觉。
|
|
450
|
+
- 结果是可编辑 PPTX。
|
|
451
|
+
|
|
452
|
+
- layout: cards
|
|
453
|
+
title: 卡片页
|
|
454
|
+
cards:
|
|
455
|
+
- title: 内容结构
|
|
456
|
+
text: 用 YAML 限定字段,减少异常结构。
|
|
457
|
+
- title: 公式支持
|
|
458
|
+
text: 行内公式 $p_{\theta}(x_i)$ 和展示公式都写为 OMML。
|
|
459
|
+
- title: 自动检查
|
|
460
|
+
text: check 接口返回 errors、warnings 和 repairPrompt。
|
|
461
|
+
|
|
462
|
+
- layout: table
|
|
463
|
+
title: 表格与单元格公式
|
|
464
|
+
columns: [符号, 含义, 示例公式]
|
|
465
|
+
rows:
|
|
466
|
+
- ['$\theta$', 模型参数, '$p_\theta(x_i)$']
|
|
467
|
+
- ['$x_i$', 第 i 个样本, '$\mathcal{L}_i=-y_i\log p_\theta(x_i)$']
|
|
468
|
+
- ['$\eta$', 学习率, '$\theta_{t+1}=\theta_t-\eta g_t$']
|
|
469
|
+
|
|
470
|
+
- layout: formula
|
|
471
|
+
title: 展示公式
|
|
472
|
+
formula:
|
|
473
|
+
latex: \mathcal{L}(\theta) = -\frac{1}{n}\sum_{i=1}^{n}\sum_{k=1}^{K} y_{ik}\log p_{\theta,k}(x_i)
|
|
474
|
+
caption: 多分类交叉熵目标函数
|
|
475
|
+
explanation:
|
|
476
|
+
- '$n$ 表示样本数量,$K$ 表示类别数量。'
|
|
477
|
+
- '$p_{\theta,k}(x_i)$ 表示模型预测概率。'
|
|
478
|
+
|
|
479
|
+
- layout: chart
|
|
480
|
+
title: 原生柱状图
|
|
481
|
+
type: bar
|
|
482
|
+
categories: [Dataset A, Dataset B, Dataset C]
|
|
483
|
+
valueAxisTitle: Accuracy
|
|
484
|
+
series:
|
|
485
|
+
- name: Baseline
|
|
486
|
+
values: [81.2, 83.5, 84.1]
|
|
487
|
+
- name: Ours
|
|
488
|
+
values: [85.6, 87.2, 88.0]
|
|
489
|
+
caption: 图表使用 PowerPoint 原生 chart XML。
|
|
490
|
+
|
|
491
|
+
- layout: flowchart
|
|
492
|
+
title: 可编辑流程图
|
|
493
|
+
nodes:
|
|
494
|
+
- id: input
|
|
495
|
+
text: 输入材料
|
|
496
|
+
note: 论文 / 数据 / 图片
|
|
497
|
+
- id: plan
|
|
498
|
+
text: 规划页面
|
|
499
|
+
note: 选择 layout
|
|
500
|
+
- id: render
|
|
501
|
+
text: 生成 PPTX
|
|
502
|
+
note: 形状 / 表格 / OMML
|
|
503
|
+
- id: check
|
|
504
|
+
text: 校验修复
|
|
505
|
+
note: errors / warnings
|
|
506
|
+
emphasis: true
|
|
507
|
+
edges:
|
|
508
|
+
- from: input
|
|
509
|
+
to: plan
|
|
510
|
+
- from: plan
|
|
511
|
+
to: render
|
|
512
|
+
- from: render
|
|
513
|
+
to: check
|
|
514
|
+
|
|
515
|
+
- layout: architecture
|
|
516
|
+
title: 架构页
|
|
517
|
+
layers:
|
|
518
|
+
- title: 输入层
|
|
519
|
+
components: [论文草稿, 实验结果, 图片素材]
|
|
520
|
+
note: 将非结构化材料整理为内容块。
|
|
521
|
+
- title: 规划层
|
|
522
|
+
components: [章节规划, 页型选择, 字段填充]
|
|
523
|
+
note: AI 只选择 layout 并填写 YAML。
|
|
524
|
+
- title: 生成层
|
|
525
|
+
components: [溢出检测, 公式 OMML, PPTX 写入]
|
|
526
|
+
note: 模板侧保证字号、位置和颜色。
|
|
527
|
+
|
|
528
|
+
- layout: experimentDesign
|
|
529
|
+
title: 实验设计
|
|
530
|
+
dataset:
|
|
531
|
+
- 使用真实北理工 PPT 风格作为视觉参考。
|
|
532
|
+
- 使用多种 AI 生成内容作为压力测试。
|
|
533
|
+
variables:
|
|
534
|
+
- 版式类型
|
|
535
|
+
- 文本长度
|
|
536
|
+
- 公式复杂度 $\mathcal{L}$
|
|
537
|
+
metrics:
|
|
538
|
+
- validation errors
|
|
539
|
+
- preflight actions
|
|
540
|
+
- PPTX 可编辑性
|
|
541
|
+
baselines:
|
|
542
|
+
- SVG 公式方案
|
|
543
|
+
- 纯文本公式方案
|
|
544
|
+
procedure: [生成 YAML, preflight, 导出 PPTX, WPS 打开验证]
|
|
545
|
+
|
|
546
|
+
- layout: resultAnalysis
|
|
547
|
+
title: 结果分析
|
|
548
|
+
finding: OMML 方案解决了公式图片变形和行内公式割裂问题。
|
|
549
|
+
metrics:
|
|
550
|
+
- value: "0"
|
|
551
|
+
label: SVG 公式
|
|
552
|
+
note: 公式不再图片化
|
|
553
|
+
- value: "18"
|
|
554
|
+
label: OMath 对象
|
|
555
|
+
note: 来自行内公式测试
|
|
556
|
+
- value: "0"
|
|
557
|
+
label: Marker 残留
|
|
558
|
+
note: 后处理替换完成
|
|
559
|
+
analysis:
|
|
560
|
+
- 表格公式可以正常显示。
|
|
561
|
+
- 公式容错可处理 $p_\theta$ 和 $x_i$ 等常见写法。
|
|
562
|
+
|
|
563
|
+
- layout: imageText
|
|
564
|
+
title: 图片占位
|
|
565
|
+
image:
|
|
566
|
+
mode: placeholder
|
|
567
|
+
aspectRatio: "16:9"
|
|
568
|
+
placement: top
|
|
569
|
+
prompt: 一张展示 YAML 输入、结构校验、PPTX 生成和人工替换图片的流程图。
|
|
570
|
+
text:
|
|
571
|
+
- 用户暂时没有图片时,也可以先生成完整 PPT 草稿。
|
|
572
|
+
- 后续可在 WPS 或 PowerPoint 中替换占位框。
|
|
573
|
+
|
|
574
|
+
- layout: imageGrid
|
|
575
|
+
title: 多图占位
|
|
576
|
+
images:
|
|
577
|
+
- mode: placeholder
|
|
578
|
+
aspectRatio: "1:1"
|
|
579
|
+
prompt: 输入样本截图
|
|
580
|
+
caption: 输入
|
|
581
|
+
- mode: placeholder
|
|
582
|
+
aspectRatio: "1:1"
|
|
583
|
+
prompt: 中间结果截图
|
|
584
|
+
caption: 中间
|
|
585
|
+
- mode: placeholder
|
|
586
|
+
aspectRatio: "1:1"
|
|
587
|
+
prompt: 输出 PPT 页面截图
|
|
588
|
+
caption: 输出
|
|
589
|
+
|
|
590
|
+
- layout: code
|
|
591
|
+
title: 伪代码页
|
|
592
|
+
language: Algorithm
|
|
593
|
+
code: |
|
|
594
|
+
Input: draft D, template layouts T
|
|
595
|
+
1. parse D into claims, evidence and assets
|
|
596
|
+
2. select layout t in T for each content block
|
|
597
|
+
3. generate YAML fields with concise text
|
|
598
|
+
4. run preflight and formula conversion
|
|
599
|
+
5. export editable PPTX
|
|
600
|
+
noteTitle: 关键约束
|
|
601
|
+
notes:
|
|
602
|
+
- 模型只产出结构化 YAML。
|
|
603
|
+
- 公式使用 OMML 写入,避免图片变形。
|
|
604
|
+
|
|
605
|
+
- layout: references
|
|
606
|
+
title: 参考文献
|
|
607
|
+
items:
|
|
608
|
+
- Microsoft. Office Open XML File Formats specification.
|
|
609
|
+
- PptxGenJS project documentation.
|
|
610
|
+
- 北京理工大学学术答辩 PPT 模板视觉风格参考。
|
|
611
|
+
- AI content generation workflow: planner, slide writer, validator and repair loop.
|
|
612
|
+
|
|
613
|
+
- layout: closing
|
|
614
|
+
title: 谢谢
|
|
615
|
+
subtitle: 敬请批评指正
|
|
616
|
+
speakerNotes: |
|
|
617
|
+
收尾时提示听众:生成结果是可编辑 PPTX。
|
|
618
|
+
后续可以在 PowerPoint 或 WPS 中继续调整。</textarea>
|
|
619
|
+
</section>
|
|
620
|
+
<section>
|
|
621
|
+
<h2>YAML</h2>
|
|
622
|
+
<textarea id="yaml" spellcheck="false">meta:
|
|
623
|
+
title: 北理工风格 PPT
|
|
624
|
+
author: BIT PPT Generator
|
|
625
|
+
slides:
|
|
626
|
+
- layout: title
|
|
627
|
+
title: 北理工风格 PPT
|
|
628
|
+
subtitle: YAML 到可编辑 PPTX
|
|
629
|
+
- layout: bullets
|
|
630
|
+
title: 示例页面
|
|
631
|
+
bullets:
|
|
632
|
+
- 使用北理工账号登录后生成
|
|
633
|
+
- 输出为可编辑 PPTX
|
|
634
|
+
- 支持公式、图表和多种版式
|
|
635
|
+
</textarea>
|
|
636
|
+
</section>
|
|
637
|
+
</main>
|
|
638
|
+
<script>
|
|
639
|
+
const tokenKey = "bit_ppt_session_token";
|
|
640
|
+
const expiresKey = "bit_ppt_session_expires";
|
|
641
|
+
const $ = (id) => document.getElementById(id);
|
|
642
|
+
const authStatus = $("authStatus");
|
|
643
|
+
const runStatus = $("runStatus");
|
|
644
|
+
const copyStatus = $("copyStatus");
|
|
645
|
+
let authRequired = false;
|
|
646
|
+
let signedAuthRequired = false;
|
|
647
|
+
|
|
648
|
+
function token() { return localStorage.getItem(tokenKey) || ""; }
|
|
649
|
+
function setStatus(el, text, cls = "") { el.className = "status " + cls; el.textContent = text; }
|
|
650
|
+
function setBusy(busy) { ["loginBtn", "checkBtn", "generateBtn"].forEach((id) => { if ($(id)) $(id).disabled = busy; }); }
|
|
651
|
+
function authHeaders(contentType) {
|
|
652
|
+
const headers = { "content-type": contentType };
|
|
653
|
+
const t = token();
|
|
654
|
+
if (t) headers.authorization = "Bearer " + t;
|
|
655
|
+
return headers;
|
|
656
|
+
}
|
|
657
|
+
function formatDuration(seconds) {
|
|
658
|
+
const days = Math.floor(seconds / 86400);
|
|
659
|
+
if (days >= 1) return days + " 天";
|
|
660
|
+
const hours = Math.floor(seconds / 3600);
|
|
661
|
+
if (hours >= 1) return hours + " 小时";
|
|
662
|
+
return Math.ceil(seconds / 60) + " 分钟";
|
|
663
|
+
}
|
|
664
|
+
function refreshAuthStatus() {
|
|
665
|
+
if (!authRequired) return;
|
|
666
|
+
const t = token();
|
|
667
|
+
const exp = Number(localStorage.getItem(expiresKey) || 0);
|
|
668
|
+
if (!t || !exp) return setStatus(authStatus, "未登录");
|
|
669
|
+
const left = Math.max(0, exp - Math.floor(Date.now() / 1000));
|
|
670
|
+
setStatus(authStatus, left > 0 ? "已登录,剩余 " + formatDuration(left) : "登录已过期", left > 0 ? "ok" : "err");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function loadServerConfig() {
|
|
674
|
+
try {
|
|
675
|
+
const res = await fetch("/health");
|
|
676
|
+
const data = await res.json();
|
|
677
|
+
authRequired = Boolean(data.authRequired);
|
|
678
|
+
signedAuthRequired = Boolean(data.signedAuthRequired);
|
|
679
|
+
$("authSection").classList.toggle("hidden", !signedAuthRequired);
|
|
680
|
+
if (!authRequired) {
|
|
681
|
+
localStorage.removeItem(tokenKey);
|
|
682
|
+
localStorage.removeItem(expiresKey);
|
|
683
|
+
} else if (signedAuthRequired) {
|
|
684
|
+
refreshAuthStatus();
|
|
685
|
+
} else {
|
|
686
|
+
setStatus(runStatus, "此服务启用了固定 token 鉴权,网页登录不可用。", "err");
|
|
687
|
+
}
|
|
688
|
+
} catch {
|
|
689
|
+
$("authSection").classList.remove("hidden");
|
|
690
|
+
authRequired = true;
|
|
691
|
+
signedAuthRequired = true;
|
|
692
|
+
refreshAuthStatus();
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function login() {
|
|
697
|
+
setBusy(true);
|
|
698
|
+
try {
|
|
699
|
+
const res = await fetch("/auth/bit-login", {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: { "content-type": "application/json" },
|
|
702
|
+
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value })
|
|
703
|
+
});
|
|
704
|
+
const data = await res.json();
|
|
705
|
+
if (!res.ok) throw new Error(data.message || "登录失败");
|
|
706
|
+
localStorage.setItem(tokenKey, data.token);
|
|
707
|
+
localStorage.setItem(expiresKey, String(data.expiresAt));
|
|
708
|
+
$("password").value = "";
|
|
709
|
+
refreshAuthStatus();
|
|
710
|
+
} catch (error) {
|
|
711
|
+
setStatus(authStatus, error.message, "err");
|
|
712
|
+
} finally {
|
|
713
|
+
setBusy(false);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function checkDeck() {
|
|
718
|
+
setBusy(true);
|
|
719
|
+
try {
|
|
720
|
+
const res = await fetch("/check", { method: "POST", headers: authHeaders("text/yaml"), body: $("yaml").value });
|
|
721
|
+
const data = await res.json();
|
|
722
|
+
if (!res.ok) throw new Error(data.error || "检查失败");
|
|
723
|
+
setStatus(runStatus, JSON.stringify({ errors: data.validation.errors.length, warnings: data.validation.warnings.length, actions: data.actions.length }, null, 2), data.validation.errors.length ? "err" : "ok");
|
|
724
|
+
} catch (error) {
|
|
725
|
+
setStatus(runStatus, error.message, "err");
|
|
726
|
+
} finally {
|
|
727
|
+
setBusy(false);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
async function generateDeck() {
|
|
732
|
+
setBusy(true);
|
|
733
|
+
try {
|
|
734
|
+
const name = encodeURIComponent($("outputName").value || "bit-ppt");
|
|
735
|
+
const res = await fetch("/generate?outputName=" + name, { method: "POST", headers: authHeaders("text/yaml"), body: $("yaml").value });
|
|
736
|
+
if (!res.ok) {
|
|
737
|
+
const data = await res.json().catch(() => ({}));
|
|
738
|
+
throw new Error(data.error || "生成失败");
|
|
739
|
+
}
|
|
740
|
+
const blob = await res.blob();
|
|
741
|
+
const url = URL.createObjectURL(blob);
|
|
742
|
+
const a = document.createElement("a");
|
|
743
|
+
a.href = url;
|
|
744
|
+
a.download = ($("outputName").value || "bit-ppt") + ".pptx";
|
|
745
|
+
a.click();
|
|
746
|
+
URL.revokeObjectURL(url);
|
|
747
|
+
setStatus(runStatus, "生成完成", "ok");
|
|
748
|
+
} catch (error) {
|
|
749
|
+
setStatus(runStatus, error.message, "err");
|
|
750
|
+
} finally {
|
|
751
|
+
setBusy(false);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async function copyTextFrom(id, label) {
|
|
756
|
+
const value = $(id).value;
|
|
757
|
+
try {
|
|
758
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
759
|
+
await navigator.clipboard.writeText(value);
|
|
760
|
+
} else {
|
|
761
|
+
const field = document.createElement("textarea");
|
|
762
|
+
field.value = value;
|
|
763
|
+
field.setAttribute("readonly", "");
|
|
764
|
+
field.style.position = "fixed";
|
|
765
|
+
field.style.left = "-9999px";
|
|
766
|
+
field.style.top = "0";
|
|
767
|
+
document.body.appendChild(field);
|
|
768
|
+
field.select();
|
|
769
|
+
const copied = document.execCommand("copy");
|
|
770
|
+
document.body.removeChild(field);
|
|
771
|
+
if (!copied) throw new Error("copy command failed");
|
|
772
|
+
}
|
|
773
|
+
setStatus(copyStatus, label + "已复制", "ok");
|
|
774
|
+
} catch {
|
|
775
|
+
const field = $(id);
|
|
776
|
+
field.focus();
|
|
777
|
+
field.select();
|
|
778
|
+
setStatus(copyStatus, "浏览器阻止自动复制,文本已选中,请按 Ctrl+C", "err");
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function insertExample() {
|
|
783
|
+
$("yaml").value = $("exampleYaml").value;
|
|
784
|
+
setStatus(runStatus, "已插入示例 YAML", "ok");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function insertFullExample() {
|
|
788
|
+
$("yaml").value = $("fullExampleYaml").value;
|
|
789
|
+
setStatus(runStatus, "已插入完整功能示例 YAML", "ok");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
$("loginBtn").addEventListener("click", login);
|
|
793
|
+
$("logoutBtn").addEventListener("click", () => { localStorage.removeItem(tokenKey); localStorage.removeItem(expiresKey); refreshAuthStatus(); });
|
|
794
|
+
$("checkBtn").addEventListener("click", checkDeck);
|
|
795
|
+
$("generateBtn").addEventListener("click", generateDeck);
|
|
796
|
+
$("copyPromptBtn").addEventListener("click", () => copyTextFrom("aiPrompt", "提示词"));
|
|
797
|
+
$("copyRulesBtn").addEventListener("click", () => copyTextFrom("syntaxRules", "语法规则"));
|
|
798
|
+
$("insertExampleBtn").addEventListener("click", insertExample);
|
|
799
|
+
$("insertFullExampleBtn").addEventListener("click", insertFullExample);
|
|
800
|
+
loadServerConfig();
|
|
801
|
+
setInterval(refreshAuthStatus, 30000);
|
|
802
|
+
</script>
|
|
803
|
+
</body>
|
|
804
|
+
</html>`;
|
|
805
|
+
|
|
806
|
+
function parsePositiveInt(value, fallback) {
|
|
807
|
+
const number = Number(value);
|
|
808
|
+
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function jsonResponse(res, status, payload) {
|
|
812
|
+
const body = JSON.stringify(payload, null, 2);
|
|
813
|
+
res.writeHead(status, {
|
|
814
|
+
"content-type": "application/json; charset=utf-8",
|
|
815
|
+
"content-length": Buffer.byteLength(body),
|
|
816
|
+
"access-control-allow-origin": "*",
|
|
817
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
818
|
+
"access-control-allow-headers": "content-type, authorization",
|
|
819
|
+
});
|
|
820
|
+
res.end(body);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function textResponse(res, status, text) {
|
|
824
|
+
res.writeHead(status, {
|
|
825
|
+
"content-type": "text/plain; charset=utf-8",
|
|
826
|
+
"content-length": Buffer.byteLength(text),
|
|
827
|
+
"access-control-allow-origin": "*",
|
|
828
|
+
});
|
|
829
|
+
res.end(text);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function htmlResponse(res, status, html) {
|
|
833
|
+
res.writeHead(status, {
|
|
834
|
+
"content-type": "text/html; charset=utf-8",
|
|
835
|
+
"content-length": Buffer.byteLength(html),
|
|
836
|
+
"access-control-allow-origin": "*",
|
|
837
|
+
});
|
|
838
|
+
res.end(html);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function pptxResponse(res, buffer, fileName) {
|
|
842
|
+
res.writeHead(200, {
|
|
843
|
+
"content-type": PPTX_MIME,
|
|
844
|
+
"content-length": buffer.length,
|
|
845
|
+
"content-disposition": `attachment; filename="${fileName}"`,
|
|
846
|
+
"access-control-allow-origin": "*",
|
|
847
|
+
});
|
|
848
|
+
res.end(buffer);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function getAuthToken(options = {}) {
|
|
852
|
+
return options.authToken || process.env.BIT_PPT_TOKEN || "";
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function getAuthSigningSecret(options = {}) {
|
|
856
|
+
return options.authSigningSecret || process.env.BIT_PPT_AUTH_SECRET || "";
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function base64UrlDecode(value) {
|
|
860
|
+
const normalized = String(value || "").replaceAll("-", "+").replaceAll("_", "/");
|
|
861
|
+
const padded = normalized + "=".repeat((4 - normalized.length % 4) % 4);
|
|
862
|
+
return Buffer.from(padded, "base64");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function base64UrlEncode(buffer) {
|
|
866
|
+
return Buffer.from(buffer).toString("base64").replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function verifySignedAuthToken(token, secret) {
|
|
870
|
+
if (!secret) return false;
|
|
871
|
+
const parts = String(token || "").split(".");
|
|
872
|
+
if (parts.length !== 3) return false;
|
|
873
|
+
const [headerPart, payloadPart, signature] = parts;
|
|
874
|
+
const expected = base64UrlEncode(crypto.createHmac("sha256", secret).update(`${headerPart}.${payloadPart}`).digest());
|
|
875
|
+
const expectedBuffer = Buffer.from(expected);
|
|
876
|
+
const signatureBuffer = Buffer.from(signature);
|
|
877
|
+
if (expectedBuffer.length !== signatureBuffer.length || !crypto.timingSafeEqual(expectedBuffer, signatureBuffer)) return false;
|
|
878
|
+
|
|
879
|
+
let payload;
|
|
880
|
+
try {
|
|
881
|
+
payload = JSON.parse(base64UrlDecode(payloadPart).toString("utf8"));
|
|
882
|
+
} catch {
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
const now = Math.floor(Date.now() / 1000);
|
|
886
|
+
return payload?.aud === "bit-ppt" && typeof payload.sub === "string" && Number(payload.exp) > now;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function createSignedAuthToken(username, secret, ttlSeconds = DEFAULT_SESSION_TTL_SECONDS) {
|
|
890
|
+
if (!secret) throw new Error("BIT_PPT_AUTH_SECRET is not configured.");
|
|
891
|
+
const now = Math.floor(Date.now() / 1000);
|
|
892
|
+
const header = base64UrlEncode(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8"));
|
|
893
|
+
const payload = base64UrlEncode(Buffer.from(JSON.stringify({
|
|
894
|
+
sub: String(username),
|
|
895
|
+
aud: "bit-ppt",
|
|
896
|
+
iat: now,
|
|
897
|
+
exp: now + ttlSeconds,
|
|
898
|
+
}), "utf8"));
|
|
899
|
+
const input = `${header}.${payload}`;
|
|
900
|
+
const signature = base64UrlEncode(crypto.createHmac("sha256", secret).update(input).digest());
|
|
901
|
+
return { token: `${input}.${signature}`, expiresAt: now + ttlSeconds };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function requireAuth(req, options = {}) {
|
|
905
|
+
const token = getAuthToken(options);
|
|
906
|
+
const signingSecret = getAuthSigningSecret(options);
|
|
907
|
+
if (!token && !signingSecret) return true;
|
|
908
|
+
const authorization = req.headers.authorization || "";
|
|
909
|
+
if (token && authorization === `Bearer ${token}`) return true;
|
|
910
|
+
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
911
|
+
if (bearer && verifySignedAuthToken(bearer, signingSecret)) return true;
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function parseJsonBody(req, options = {}) {
|
|
916
|
+
const rawBody = await readRequestBody(req, options.bodyLimit || parsePositiveInt(process.env.BIT_PPT_BODY_LIMIT, DEFAULT_BODY_LIMIT));
|
|
917
|
+
return rawBody ? JSON.parse(rawBody) : {};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
async function verifyBitPassword(username, password, options = {}) {
|
|
921
|
+
if (options.bitAuthVerifier) return options.bitAuthVerifier(username, password);
|
|
922
|
+
const response = await fetch(BIT_CAS_TICKETS_URL, {
|
|
923
|
+
method: "POST",
|
|
924
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
925
|
+
body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
|
|
926
|
+
});
|
|
927
|
+
if (response.status === 201) return true;
|
|
928
|
+
if (response.status === 400 || response.status === 401) return false;
|
|
929
|
+
throw new Error(`BIT CAS unavailable: ${response.status}`);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function parseBool(value) {
|
|
933
|
+
return value === true || value === "true" || value === "1" || value === "yes";
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function sanitizeFileBase(value) {
|
|
937
|
+
const text = String(value || "deck").replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
938
|
+
return text || "deck";
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function readRequestBody(req, limit = DEFAULT_BODY_LIMIT) {
|
|
942
|
+
const chunks = [];
|
|
943
|
+
let total = 0;
|
|
944
|
+
for await (const chunk of req) {
|
|
945
|
+
total += chunk.length;
|
|
946
|
+
if (total > limit) {
|
|
947
|
+
const error = new Error(`Request body exceeds ${limit} bytes.`);
|
|
948
|
+
error.statusCode = 413;
|
|
949
|
+
throw error;
|
|
950
|
+
}
|
|
951
|
+
chunks.push(chunk);
|
|
952
|
+
}
|
|
953
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function parseDeckRequest(req, url, options = {}) {
|
|
957
|
+
const rawBody = await readRequestBody(req, options.bodyLimit || parsePositiveInt(process.env.BIT_PPT_BODY_LIMIT, DEFAULT_BODY_LIMIT));
|
|
958
|
+
const contentType = req.headers["content-type"] || "";
|
|
959
|
+
if (contentType.includes("application/json")) {
|
|
960
|
+
const payload = rawBody ? JSON.parse(rawBody) : {};
|
|
961
|
+
if (payload.deck && typeof payload.deck === "object") {
|
|
962
|
+
return {
|
|
963
|
+
deck: payload.deck,
|
|
964
|
+
deckYaml: YAML.stringify(payload.deck),
|
|
965
|
+
outputName: sanitizeFileBase(payload.outputName || payload.fileName || payload.deck?.meta?.title),
|
|
966
|
+
options: {
|
|
967
|
+
strict: parseBool(payload.strict) || parseBool(url.searchParams.get("strict")),
|
|
968
|
+
fontCn: payload.fontCn,
|
|
969
|
+
fontCnLight: payload.fontCnLight,
|
|
970
|
+
fontEn: payload.fontEn,
|
|
971
|
+
fontSerif: payload.fontSerif,
|
|
972
|
+
fontCode: payload.fontCode,
|
|
973
|
+
},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
const deckYaml = payload.deckYaml || payload.yaml;
|
|
977
|
+
if (typeof deckYaml === "string") {
|
|
978
|
+
return {
|
|
979
|
+
deck: parseDeckYaml(deckYaml, "deckYaml"),
|
|
980
|
+
deckYaml,
|
|
981
|
+
outputName: sanitizeFileBase(payload.outputName || payload.fileName),
|
|
982
|
+
options: {
|
|
983
|
+
strict: parseBool(payload.strict) || parseBool(url.searchParams.get("strict")),
|
|
984
|
+
fontCn: payload.fontCn,
|
|
985
|
+
fontCnLight: payload.fontCnLight,
|
|
986
|
+
fontEn: payload.fontEn,
|
|
987
|
+
fontSerif: payload.fontSerif,
|
|
988
|
+
fontCode: payload.fontCode,
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
const error = new Error("JSON body must include deck, deckYaml, or yaml.");
|
|
993
|
+
error.statusCode = 400;
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
deck: parseDeckYaml(rawBody, "request body"),
|
|
998
|
+
deckYaml: rawBody,
|
|
999
|
+
outputName: sanitizeFileBase(url.searchParams.get("outputName") || url.searchParams.get("fileName")),
|
|
1000
|
+
options: {
|
|
1001
|
+
strict: parseBool(url.searchParams.get("strict")),
|
|
1002
|
+
fontCn: url.searchParams.get("fontCn") || undefined,
|
|
1003
|
+
fontCnLight: url.searchParams.get("fontCnLight") || undefined,
|
|
1004
|
+
fontEn: url.searchParams.get("fontEn") || undefined,
|
|
1005
|
+
fontSerif: url.searchParams.get("fontSerif") || undefined,
|
|
1006
|
+
fontCode: url.searchParams.get("fontCode") || undefined,
|
|
1007
|
+
},
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function healthPayload(options = {}) {
|
|
1012
|
+
const tokenAuthEnabled = Boolean(getAuthToken(options));
|
|
1013
|
+
const signedAuthEnabled = Boolean(getAuthSigningSecret(options));
|
|
1014
|
+
return {
|
|
1015
|
+
ok: true,
|
|
1016
|
+
service: "bit-ppt-http",
|
|
1017
|
+
capabilities: {
|
|
1018
|
+
check: true,
|
|
1019
|
+
generate: true,
|
|
1020
|
+
bitLogin: true,
|
|
1021
|
+
omml: true,
|
|
1022
|
+
},
|
|
1023
|
+
authRequired: tokenAuthEnabled || signedAuthEnabled,
|
|
1024
|
+
signedAuthRequired: signedAuthEnabled,
|
|
1025
|
+
maxGenerateConcurrency: parsePositiveInt(options.maxGenerateConcurrency || process.env.BIT_PPT_MAX_GENERATE_CONCURRENCY, DEFAULT_GENERATE_CONCURRENCY),
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function handleCheck(req, res, url, options) {
|
|
1030
|
+
const { deck } = await parseDeckRequest(req, url, options);
|
|
1031
|
+
jsonResponse(res, 200, checkDeck(deck));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function handleBitLogin(req, res, options) {
|
|
1035
|
+
const { username, password } = await parseJsonBody(req, options);
|
|
1036
|
+
if (!username || !password) {
|
|
1037
|
+
jsonResponse(res, 400, { code: 400, message: "Missing username or password." });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const ok = await verifyBitPassword(String(username), String(password), options);
|
|
1041
|
+
if (!ok) {
|
|
1042
|
+
jsonResponse(res, 401, { code: 401, message: "Invalid BIT username or password." });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const ttl = parsePositiveInt(options.sessionTtlSeconds || process.env.BIT_PPT_SESSION_TTL_SECONDS, DEFAULT_SESSION_TTL_SECONDS);
|
|
1046
|
+
const session = createSignedAuthToken(String(username), getAuthSigningSecret(options), ttl);
|
|
1047
|
+
jsonResponse(res, 200, {
|
|
1048
|
+
code: 200,
|
|
1049
|
+
token: session.token,
|
|
1050
|
+
tokenType: "Bearer",
|
|
1051
|
+
expiresAt: session.expiresAt,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async function handleAuthVerify(req, res, options) {
|
|
1056
|
+
const authorization = req.headers.authorization || "";
|
|
1057
|
+
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
1058
|
+
const valid = Boolean(bearer && verifySignedAuthToken(bearer, getAuthSigningSecret(options)));
|
|
1059
|
+
jsonResponse(res, valid ? 200 : 401, { code: valid ? 200 : 401, valid });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function handleGenerate(req, res, url, options) {
|
|
1063
|
+
const maxConcurrency = parsePositiveInt(options.maxGenerateConcurrency || process.env.BIT_PPT_MAX_GENERATE_CONCURRENCY, DEFAULT_GENERATE_CONCURRENCY);
|
|
1064
|
+
if (!Number.isInteger(options.activeGenerations)) options.activeGenerations = 0;
|
|
1065
|
+
if (options.activeGenerations >= maxConcurrency) {
|
|
1066
|
+
jsonResponse(res, 429, {
|
|
1067
|
+
generated: false,
|
|
1068
|
+
error: `Too many concurrent generation requests. Limit is ${maxConcurrency}.`,
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const parsed = await parseDeckRequest(req, url, options);
|
|
1073
|
+
const precheck = checkDeck(parsed.deck);
|
|
1074
|
+
if (precheck.validation.errors.length || (parsed.options.strict && precheck.validation.warnings.length)) {
|
|
1075
|
+
jsonResponse(res, 422, {
|
|
1076
|
+
generated: false,
|
|
1077
|
+
error: parsed.options.strict && precheck.validation.warnings.length ? "Deck validation failed strict mode." : "Deck validation failed.",
|
|
1078
|
+
validation: precheck.validation,
|
|
1079
|
+
repairPrompt: precheck.repairPrompt,
|
|
1080
|
+
});
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "bit-ppt-http-"));
|
|
1085
|
+
options.activeGenerations += 1;
|
|
1086
|
+
try {
|
|
1087
|
+
const inputPath = path.join(tempDir, "deck.yaml");
|
|
1088
|
+
const outputPath = path.join(tempDir, `${parsed.outputName}.pptx`);
|
|
1089
|
+
fs.writeFileSync(inputPath, parsed.deckYaml, "utf8");
|
|
1090
|
+
const result = await generateDeckFile(inputPath, outputPath, parsed.options);
|
|
1091
|
+
const buffer = fs.readFileSync(result.output);
|
|
1092
|
+
pptxResponse(res, buffer, path.basename(result.output));
|
|
1093
|
+
} finally {
|
|
1094
|
+
options.activeGenerations -= 1;
|
|
1095
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async function handleRequest(req, res, options = {}) {
|
|
1100
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
1101
|
+
try {
|
|
1102
|
+
if (req.method === "OPTIONS") {
|
|
1103
|
+
jsonResponse(res, 200, { ok: true });
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
1107
|
+
jsonResponse(res, 200, healthPayload(options));
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
1111
|
+
htmlResponse(res, 200, WEB_APP_HTML);
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
if (req.method === "POST" && url.pathname === "/auth/bit-login") {
|
|
1115
|
+
await handleBitLogin(req, res, options);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (req.method === "POST" && url.pathname === "/auth/verify") {
|
|
1119
|
+
await handleAuthVerify(req, res, options);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (["/check", "/generate"].includes(url.pathname) && !requireAuth(req, options)) {
|
|
1123
|
+
jsonResponse(res, 401, { error: "Unauthorized." });
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
if (req.method === "POST" && url.pathname === "/check") {
|
|
1127
|
+
await handleCheck(req, res, url, options);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (req.method === "POST" && url.pathname === "/generate") {
|
|
1131
|
+
await handleGenerate(req, res, url, options);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (["/check", "/generate"].includes(url.pathname)) {
|
|
1135
|
+
jsonResponse(res, 405, { error: "Method not allowed." });
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
textResponse(res, 404, "Not found. Use GET /, GET /health, POST /auth/bit-login, POST /check, or POST /generate.");
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (error.kind === "yaml_syntax") {
|
|
1141
|
+
jsonResponse(res, error.statusCode || 400, {
|
|
1142
|
+
error: error.message || "YAML syntax error.",
|
|
1143
|
+
syntax: error.syntax,
|
|
1144
|
+
repairPrompt: error.repairPrompt,
|
|
1145
|
+
});
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (error.validation) {
|
|
1149
|
+
jsonResponse(res, 422, {
|
|
1150
|
+
generated: false,
|
|
1151
|
+
error: error.message || "Deck validation failed.",
|
|
1152
|
+
validation: error.validation,
|
|
1153
|
+
repairPrompt: error.repairPrompt,
|
|
1154
|
+
});
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
jsonResponse(res, error.statusCode || 400, { error: error.message || String(error) });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function createBitPptHttpServer(options = {}) {
|
|
1162
|
+
const state = {
|
|
1163
|
+
...options,
|
|
1164
|
+
activeGenerations: 0,
|
|
1165
|
+
};
|
|
1166
|
+
return http.createServer((req, res) => {
|
|
1167
|
+
handleRequest(req, res, state);
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function startHttpServer(options = {}) {
|
|
1172
|
+
const port = Number(options.port || process.env.PORT || 3000);
|
|
1173
|
+
const host = options.host || process.env.HOST || "127.0.0.1";
|
|
1174
|
+
const server = createBitPptHttpServer(options);
|
|
1175
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
1176
|
+
return server;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
async function main() {
|
|
1180
|
+
const portArgIndex = process.argv.findIndex((arg) => arg === "--port" || arg === "-p");
|
|
1181
|
+
const hostArgIndex = process.argv.findIndex((arg) => arg === "--host");
|
|
1182
|
+
const port = portArgIndex >= 0 ? process.argv[portArgIndex + 1] : process.env.PORT || 3000;
|
|
1183
|
+
const host = hostArgIndex >= 0 ? process.argv[hostArgIndex + 1] : process.env.HOST || "127.0.0.1";
|
|
1184
|
+
await startHttpServer({ port, host });
|
|
1185
|
+
console.log(`bit-ppt-http listening at http://${host}:${port}`);
|
|
1186
|
+
console.log("Endpoints: GET /health, POST /check, POST /generate");
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
1190
|
+
main().catch((error) => {
|
|
1191
|
+
console.error(error);
|
|
1192
|
+
process.exitCode = 1;
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export {
|
|
1197
|
+
PPTX_MIME,
|
|
1198
|
+
createBitPptHttpServer,
|
|
1199
|
+
handleRequest,
|
|
1200
|
+
startHttpServer,
|
|
1201
|
+
};
|