bit-ppt-generator 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };