@evandu/super-pi 0.1.130 → 0.1.132

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.
Files changed (88) hide show
  1. package/README.md +18 -0
  2. package/agents/super-pi-documents/index.ts +92 -0
  3. package/agents/super-pi-feishu/index.ts +88 -0
  4. package/agents/super-pi-knowledge/index.ts +32 -1
  5. package/agents/super-pi-task/core.ts +217 -0
  6. package/agents/super-pi-task/index.ts +74 -1
  7. package/agents/super-pi-web/index.ts +32 -0
  8. package/assets/fonts/LICENSE +92 -0
  9. package/assets/fonts/NotoSansSC-Bold.otf +0 -0
  10. package/assets/fonts/NotoSansSC-Regular.otf +0 -0
  11. package/dist/client/.super-pi-client-sources.json +13 -10
  12. package/dist/client/assets/CodeMirrorEditor-ktyL2PcX.js +2 -0
  13. package/dist/client/assets/{dist-BKp4AWEJ.js → dist--igDAzRS.js} +1 -1
  14. package/dist/client/assets/{dist-BBW2SE_R.js → dist-60usx__o.js} +1 -1
  15. package/dist/client/assets/{dist-GkIV1LTX.js → dist-8pdzh6h1.js} +1 -1
  16. package/dist/client/assets/{dist-B2nPVdKh.js → dist-B8OZ-n_C.js} +1 -1
  17. package/dist/client/assets/{dist-BDygQgZD.js → dist-BAJCrbnX.js} +1 -1
  18. package/dist/client/assets/{dist-CiSGSVBZ.js → dist-BJi5HmH4.js} +1 -1
  19. package/dist/client/assets/{dist-CRzRB9k3.js → dist-BRjGckmQ.js} +1 -1
  20. package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Bg9zLfex.js} +1 -1
  21. package/dist/client/assets/{dist-C90MPTnk.js → dist-BjZUYxLd.js} +1 -1
  22. package/dist/client/assets/{dist-DmAOqr0M.js → dist-Bv6P2Rcp.js} +1 -1
  23. package/dist/client/assets/{dist-BhJGK8-b.js → dist-C6iAHGvb.js} +1 -1
  24. package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-CMscy2Nn.js} +1 -1
  25. package/dist/client/assets/{dist-Ur37340K.js → dist-CPLCQDqy.js} +1 -1
  26. package/dist/client/assets/{dist-DZnUERoW.js → dist-CgmISAMr.js} +1 -1
  27. package/dist/client/assets/{dist-CWYRjnj-.js → dist-CoszOk5k.js} +1 -1
  28. package/dist/client/assets/{dist-CMnvChDX.js → dist-Cu75NyQ0.js} +1 -1
  29. package/dist/client/assets/{dist-HdeIBl2J.js → dist-DNZMXYNq.js} +1 -1
  30. package/dist/client/assets/{dist-BykVFExd.js → dist-D_Zdnhm8.js} +1 -1
  31. package/dist/client/assets/{dist-ChszWGDv.js → dist-DvHghuGd.js} +1 -1
  32. package/dist/client/assets/{dist-CZ9I2JCd.js → dist-Hj_dWgPr.js} +1 -1
  33. package/dist/client/assets/{dist-B61RSa-K.js → dist-Zhdkfvng.js} +1 -1
  34. package/dist/client/assets/{dist-DB5M5fZG.js → dist-vsMBLoxV.js} +1 -1
  35. package/dist/client/assets/{index-D_c-CYAr.js → index-ChyemF2I.js} +23 -21
  36. package/dist/client/assets/{index-CXB6U5mz.css → index-D3MEG8uH.css} +1 -1
  37. package/dist/client/index.html +2 -2
  38. package/dist/server/application/document-service.d.ts +68 -0
  39. package/dist/server/application/document-service.js +120 -0
  40. package/dist/server/application/feishu-service.d.ts +132 -0
  41. package/dist/server/application/feishu-service.js +264 -0
  42. package/dist/server/application/run-service.js +49 -7
  43. package/dist/server/application/scheduled-task-service.d.ts +164 -0
  44. package/dist/server/application/scheduled-task-service.js +350 -0
  45. package/dist/server/command-handler.d.ts +25 -1
  46. package/dist/server/command-handler.js +60 -1
  47. package/dist/server/documents.d.ts +152 -0
  48. package/dist/server/documents.js +998 -0
  49. package/dist/server/feishu-cli.d.ts +207 -0
  50. package/dist/server/feishu-cli.js +419 -0
  51. package/dist/server/file-changes.js +6 -4
  52. package/dist/server/files.d.ts +32 -0
  53. package/dist/server/files.js +31 -3
  54. package/dist/server/http-server.js +324 -2
  55. package/dist/server/index.js +41 -2
  56. package/dist/server/inline-thinking.d.ts +90 -0
  57. package/dist/server/inline-thinking.js +181 -0
  58. package/dist/server/knowledge.d.ts +24 -14
  59. package/dist/server/knowledge.js +57 -34
  60. package/dist/server/persistence/app-state-repository.d.ts +13 -1
  61. package/dist/server/persistence/sqlite-state-repository.js +3 -0
  62. package/dist/server/pi-session-pool.js +2 -1
  63. package/dist/server/pi-session.d.ts +4 -0
  64. package/dist/server/pi-session.js +14 -0
  65. package/dist/server/presentations/schema.d.ts +120 -0
  66. package/dist/server/presentations/schema.js +128 -0
  67. package/dist/server/runtime.d.ts +67 -0
  68. package/dist/server/runtime.js +204 -18
  69. package/dist/server/scheduled-tasks.d.ts +101 -0
  70. package/dist/server/scheduled-tasks.js +138 -0
  71. package/dist/server/server-context.d.ts +19 -0
  72. package/dist/server/store.d.ts +30 -0
  73. package/dist/server/store.js +81 -2
  74. package/dist/server/subagents.js +33 -6
  75. package/dist/server/task-tool.d.ts +18 -0
  76. package/dist/server/task-tool.js +18 -0
  77. package/dist/server/types.d.ts +56 -0
  78. package/dist/server/web.d.ts +24 -0
  79. package/dist/server/web.js +180 -0
  80. package/package.json +45 -6
  81. package/skills/presentation/SKILL.md +30 -0
  82. package/skills/presentation/references/data.md +10 -0
  83. package/skills/presentation/references/quality.md +12 -0
  84. package/skills/presentation/references/story.md +12 -0
  85. package/skills/presentation/references/templates-and-editing.md +11 -0
  86. package/skills/presentation/references/visual-system.md +10 -0
  87. package/dist/.super-pi-check-inputs.json +0 -4
  88. package/dist/client/assets/CodeMirrorEditor-BNVBZZSS.js +0 -2
@@ -0,0 +1,998 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, extname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ /**
6
+ * The tools the agent sees.
7
+ *
8
+ * Duplicated as literals in `agents/super-pi-documents/index.ts`, the way `KNOWLEDGE_TOOL_NAME` is:
9
+ * the extension runs in Pi's process and cannot import from the server, and a name that exists in
10
+ * only one of the two places is a name one of them will get wrong.
11
+ */
12
+ export const DOCUMENT_READ_TOOL_NAME = "document_read";
13
+ export const DOCUMENT_WRITE_TOOL_NAME = "document_write";
14
+ /** The failure of a document that could not be read, in a form a person can act on. */
15
+ export class DocumentError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = "DocumentError";
21
+ }
22
+ }
23
+ /** The extension-to-format map. Kept as a map so the reverse direction (a format's extension) is derivable. */
24
+ const FORMAT_BY_EXTENSION = {
25
+ ".pdf": "pdf",
26
+ ".docx": "docx",
27
+ ".xlsx": "xlsx",
28
+ ".pptx": "pptx",
29
+ ".ppt": "ppt",
30
+ };
31
+ /** The extension each format is written with. */
32
+ const EXTENSION_BY_FORMAT = {
33
+ pdf: ".pdf",
34
+ docx: ".docx",
35
+ xlsx: ".xlsx",
36
+ pptx: ".pptx",
37
+ ppt: ".ppt",
38
+ };
39
+ /**
40
+ * Whether an extension names a document this layer can read.
41
+ *
42
+ * The name is checked BEFORE the bytes are sniffed, because the caller needs to know whether to
43
+ * attempt a parse at all - and because a `.pdf` that is really a text file is a different situation
44
+ * from a file with no extension that happens to be a PDF.
45
+ */
46
+ export function isDocumentFormat(extension) {
47
+ return Object.prototype.hasOwnProperty.call(FORMAT_BY_EXTENSION, extension.toLocaleLowerCase());
48
+ }
49
+ /** The format an extension names, or null when it names none. */
50
+ export function documentFormatOf(extension) {
51
+ return FORMAT_BY_EXTENSION[extension.toLocaleLowerCase()] ?? null;
52
+ }
53
+ /** The extension a format is written with. */
54
+ export function extensionForFormat(format) {
55
+ return EXTENSION_BY_FORMAT[format];
56
+ }
57
+ /**
58
+ * The package each format needs, named the way a person would install it.
59
+ *
60
+ * These are the names on npm, and they are what the error message tells the person to run.
61
+ */
62
+ const PACKAGE_BY_FORMAT = {
63
+ pdf: "pdfjs-dist",
64
+ docx: "mammoth",
65
+ xlsx: "exceljs",
66
+ pptx: "fflate",
67
+ ppt: "cfb",
68
+ };
69
+ /**
70
+ * Reads a document's text.
71
+ *
72
+ * The file is read here rather than handed in as a buffer because the parsers differ in what they
73
+ * want: pdfjs wants a path or a typed array, mammoth wants a buffer, exceljs wants a stream or a
74
+ * buffer. Reading once here keeps that difference from leaking into the callers.
75
+ */
76
+ export async function extractText(path, format) {
77
+ const bytes = await readFile(path);
78
+ return extractTextFromBytes(bytes, format);
79
+ }
80
+ /**
81
+ * Reads a document's text from bytes already in hand.
82
+ *
83
+ * Split out from `extractText` so the parsers can be exercised without touching the filesystem, and
84
+ * so a caller that already has the bytes (an upload, an API request) does not write them to a
85
+ * temporary file to read them back.
86
+ */
87
+ export async function extractTextFromBytes(bytes, format) {
88
+ if (format === "pdf")
89
+ return extractPdfText(bytes);
90
+ if (format === "docx")
91
+ return extractDocxText(bytes);
92
+ if (format === "pptx")
93
+ return extractPptxText(bytes);
94
+ if (format === "ppt")
95
+ return extractPptText(bytes);
96
+ return extractXlsxText(bytes);
97
+ }
98
+ /**
99
+ * PDF TEXT EXTRACTION.
100
+ *
101
+ * WHY THE LEGACY BUILD. pdfjs-dist's default entry is built for a browser and reaches for DOM APIs
102
+ * that do not exist in Node. The legacy build is the same parser without them, and it is the entry
103
+ * point that works under plain `node`.
104
+ */
105
+ async function extractPdfText(bytes) {
106
+ let pdfjs;
107
+ try {
108
+ pdfjs = (await import("pdfjs-dist/legacy/build/pdf.mjs"));
109
+ }
110
+ catch (error) {
111
+ throw new DocumentError("missing_dependency", `读 PDF 需要安装 ${PACKAGE_BY_FORMAT.pdf}(npm install ${PACKAGE_BY_FORMAT.pdf})`);
112
+ }
113
+ let document;
114
+ try {
115
+ // The bytes are copied into a fresh Uint8Array because pdfjs takes ownership of the buffer it is
116
+ // given, and a Buffer that something else still holds would be detached underneath it.
117
+ //
118
+ // The COPY IS NOT OPTIONAL. A Node Buffer is a VIEW onto a pooled ArrayBuffer, and the view
119
+ // usually starts partway into the pool. Handing pdfjs the buffer itself would make it read from
120
+ // the pool's offset - the wrong bytes, or a truncated file - so the bytes are copied into a
121
+ // fresh array that starts where it says it does.
122
+ document = await pdfjs.getDocument({
123
+ data: new Uint8Array(bytes),
124
+ // The standard fonts and the CJK character maps live in the package rather than in the
125
+ // document, and pdfjs will not find them on its own. Without this a PDF that uses a standard
126
+ // font logs a warning and its text comes out garbled - a document that reads as nonsense
127
+ // rather than as an error, which is the worst of both.
128
+ ...(await standardFontPaths()),
129
+ }).promise;
130
+ }
131
+ catch (error) {
132
+ throw translatePdfError(error);
133
+ }
134
+ const pageCount = document.numPages;
135
+ const pages = [];
136
+ const warnings = [];
137
+ for (let page = 1; page <= pageCount; page++) {
138
+ const content = await document.getPage(page);
139
+ const textContent = await content.getTextContent();
140
+ // The text items arrive as positioned runs, not lines. A PDF has no concept of a line: it has
141
+ // glyphs at coordinates, and reconstructing a line means deciding when the y position changed.
142
+ // The `hasEOL` flag the parser sets on the last item of a line is that decision, already made.
143
+ let pageText = "";
144
+ for (const item of textContent.items) {
145
+ if (!("str" in item))
146
+ continue;
147
+ pageText += item.str;
148
+ if (item.hasEOL)
149
+ pageText += "\n";
150
+ else
151
+ pageText += " ";
152
+ }
153
+ pages.push(pageText);
154
+ }
155
+ const text = pages.join("\n\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
156
+ if (!text) {
157
+ // A PDF WITH NO TEXT LAYER IS A SCAN, NOT AN EMPTY DOCUMENT. It has pages - pdfjs counted them -
158
+ // and the person needs to be told the difference, because "this document is empty" would send
159
+ // them looking for content that is there, as pixels.
160
+ throw new DocumentError("no_text_layer", pageCount > 0
161
+ ? `这份 PDF 有 ${pageCount} 页但没有文本层(很可能是扫描件)。扫描件需要 OCR,Super Pi 不做 OCR;你可以让 agent 用图片的方式看它。`
162
+ : "这份 PDF 没有任何可提取的内容。");
163
+ }
164
+ if (/\uFFFD/.test(text))
165
+ warnings.push("PDF 里有一些字符无法解码,可能使用了特殊编码的字体。");
166
+ return { text, format: "pdf", pages: pageCount, warnings };
167
+ }
168
+ /**
169
+ * The paths pdfjs needs to find the standard fonts and the character maps.
170
+ *
171
+ * WHY THEY ARE RESOLVED RATHER THAN GUESSED. The package's layout differs between the source
172
+ * checkout and the published tarball, and a hard-coded relative path breaks in one of them - which
173
+ * is the failure mode that the installed-layout test exists to catch. `import.meta.resolve` asks
174
+ * Node where the package actually is, so the path is right wherever the code runs.
175
+ *
176
+ * THE PATHS ARE PATHS, NOT URLS. pdfjs's Node build reads these files with `fs.readFile(url)` - it
177
+ * does not go through `fetch` - so a `file://` URL is read as the literal string "file:///F:/...",
178
+ * which is not a file, and every font load fails. A bare filesystem path is what that reader wants.
179
+ */
180
+ async function standardFontPaths() {
181
+ try {
182
+ const fonts = await import.meta.resolve("pdfjs-dist/package.json");
183
+ const root = dirname(fileURLToPath(fonts));
184
+ return {
185
+ standardFontDataUrl: join(root, "standard_fonts") + "/",
186
+ cMapUrl: join(root, "cmaps") + "/",
187
+ cMapPacked: true,
188
+ };
189
+ }
190
+ catch {
191
+ // WITHOUT THESE PATHS THE PARSE STILL HAPPENS - the fonts are only needed for documents that
192
+ // use them, and the CJK maps only for CJK documents. A PDF with neither parses fine, so a
193
+ // failure to locate the package is not a reason to refuse the document.
194
+ return { cMapPacked: true };
195
+ }
196
+ }
197
+ /**
198
+ * The error pdfjs throws for a document it cannot open, translated into a code.
199
+ *
200
+ * pdfjs's own errors carry a `name` (`PasswordException`, `InvalidPDFException`, ...), which is
201
+ * stable across versions in a way its message text is not.
202
+ */
203
+ function translatePdfError(error) {
204
+ const name = error?.name ?? "";
205
+ if (name === "PasswordException")
206
+ return new DocumentError("encrypted", "这份 PDF 有密码保护,Super Pi 无法读取加密文档。");
207
+ if (name === "InvalidPDFException" || name === "MissingPDFException")
208
+ return new DocumentError("corrupt", "这个文件不是有效的 PDF,或者已经损坏。");
209
+ return new DocumentError("corrupt", `无法解析这个 PDF:${error instanceof Error ? error.message : "未知错误"}`);
210
+ }
211
+ /**
212
+ * DOCX TEXT EXTRACTION.
213
+ *
214
+ * mammoth converts the document to HTML, which preserves the STRUCTURE - headings stay headings,
215
+ * lists stay lists, tables stay tables - and the HTML is then flattened to text.
216
+ */
217
+ async function extractDocxText(bytes) {
218
+ let mammoth;
219
+ try {
220
+ mammoth = (await import("mammoth"));
221
+ }
222
+ catch {
223
+ throw new DocumentError("missing_dependency", `读 Word 文档需要安装 ${PACKAGE_BY_FORMAT.docx}(npm install ${PACKAGE_BY_FORMAT.docx})`);
224
+ }
225
+ let result;
226
+ try {
227
+ result = await mammoth.extractRawText({ buffer: bytes });
228
+ }
229
+ catch (error) {
230
+ throw new DocumentError("corrupt", `无法解析这个 Word 文档:${error instanceof Error ? error.message : "未知错误"}`);
231
+ }
232
+ const text = result.value.replace(/\n{3,}/g, "\n\n").trim();
233
+ if (!text)
234
+ throw new DocumentError("empty", "这个 Word 文档里没有可提取的文字。");
235
+ const warnings = [];
236
+ // mammoth reports what it had to drop. Those messages are the only warning a caller will get about
237
+ // a lost image or an unsupported element, so they are passed through rather than discarded.
238
+ for (const message of result.messages ?? []) {
239
+ if (message.type === "warning")
240
+ warnings.push(`Word 转换提示:${message.message}`);
241
+ }
242
+ if (warnings.length === 0)
243
+ warnings.push("Word 文档里的图片不会转成文字;表格被拍平成一行行文本。");
244
+ return { text, format: "docx", warnings };
245
+ }
246
+ /**
247
+ * XLSX TEXT EXTRACTION.
248
+ *
249
+ * A spreadsheet is read as a table and written out as CSV, with every sheet in the workbook present
250
+ * and headed by its name. The alternative - one flat list of all the cells - loses which sheet a
251
+ * number came from, and a row of numbers without its sheet is not a fact.
252
+ */
253
+ async function extractXlsxText(bytes) {
254
+ let ExcelJS;
255
+ try {
256
+ ExcelJS = (await import("exceljs"));
257
+ }
258
+ catch {
259
+ throw new DocumentError("missing_dependency", `读 Excel 需要安装 ${PACKAGE_BY_FORMAT.xlsx}(npm install ${PACKAGE_BY_FORMAT.xlsx})`);
260
+ }
261
+ const workbook = new ExcelJS.default.Workbook();
262
+ try {
263
+ await workbook.xlsx.load(bytes);
264
+ }
265
+ catch (error) {
266
+ throw new DocumentError("corrupt", `无法解析这个 Excel 文件:${error instanceof Error ? error.message : "未知错误"}`);
267
+ }
268
+ const sections = [];
269
+ for (const sheet of workbook.worksheets) {
270
+ const rows = [];
271
+ for (let index = 1; index <= sheet.rowCount; index++) {
272
+ const values = sheet.getRow(index).values;
273
+ // exceljs's row values are 1-INDEXED, with a hole at 0. Dropping the hole is what makes the
274
+ // CSV columns line up with what a person sees in the spreadsheet.
275
+ const cells = values.slice(1).map(cell => {
276
+ if (cell === null || cell === undefined)
277
+ return "";
278
+ if (cell instanceof Date)
279
+ return cell.toISOString();
280
+ if (typeof cell === "object") {
281
+ const rich = cell;
282
+ if (rich.hyperlink)
283
+ return rich.text ?? rich.hyperlink;
284
+ if (rich.text !== undefined)
285
+ return rich.text;
286
+ if (rich.result !== undefined)
287
+ return String(rich.result);
288
+ return "";
289
+ }
290
+ return String(cell);
291
+ });
292
+ while (cells.length && cells[cells.length - 1] === "")
293
+ cells.pop();
294
+ if (cells.length)
295
+ rows.push(cells.map(escapeCsvCell).join(","));
296
+ }
297
+ if (!rows.length)
298
+ continue;
299
+ sections.push(`# 工作表:${sheet.name}\n${rows.join("\n")}`);
300
+ }
301
+ const text = sections.join("\n\n").trim();
302
+ if (!text)
303
+ throw new DocumentError("empty", "这个 Excel 文件里没有可提取的数据。");
304
+ return { text, format: "xlsx", warnings: ["Excel 的公式保留的是计算结果;图表、格式和单元格样式不会出现在文本里。"] };
305
+ }
306
+ /** A CSV cell that contains a comma, a quote or a newline has to be quoted, and the quotes doubled. */
307
+ function escapeCsvCell(value) {
308
+ if (!/[",\n\r]/.test(value))
309
+ return value;
310
+ return `"${value.replaceAll('"', '""')}"`;
311
+ }
312
+ /**
313
+ * Builds a document's bytes.
314
+ *
315
+ * The output is a REAL document - it opens in Word, Excel and a PDF reader - not a renamed text
316
+ * file. The structure is simple on purpose: headings, paragraphs, lists and tables for Word;
317
+ * sheets of cells for Excel; lines of text for a PDF.
318
+ */
319
+ export async function buildDocument(spec) {
320
+ if (spec.format === "docx")
321
+ return { bytes: await buildDocx(spec), format: "docx" };
322
+ if (spec.format === "xlsx")
323
+ return { bytes: await buildXlsx(spec), format: "xlsx" };
324
+ if (spec.format === "pptx")
325
+ return { bytes: await buildPptx(spec), format: "pptx" };
326
+ // THE LEGACY BINARY FORMAT HAS NO WRITER, and this guard is what keeps the fall-through from
327
+ // quietly producing a PDF inside a .ppt file.
328
+ if (spec.format === "ppt")
329
+ throw new DocumentError("unsupported", "旧版 .ppt 只支持读取,不支持生成;请写成 .pptx。");
330
+ return { bytes: await buildPdf(spec), format: "pdf" };
331
+ }
332
+ /** Splits Markdown into the blocks a document is built from. Exported for the tests to exercise directly. */
333
+ export function parseMarkdownBlocks(markdown) {
334
+ const blocks = [];
335
+ const lines = markdown.replace(/\r\n/g, "\n").split("\n");
336
+ let index = 0;
337
+ while (index < lines.length) {
338
+ const line = lines[index];
339
+ if (!line.trim()) {
340
+ index++;
341
+ continue;
342
+ }
343
+ // A FENCE IS A UNIT. The block between the fences is code, and it is not scanned for headings or
344
+ // list markers - a `#` inside a shell script is a comment, not a heading.
345
+ if (/^\s*(```|~~~)/.test(line)) {
346
+ const fence = /^\s*(```|~~~)/.exec(line)[1];
347
+ const body = [];
348
+ index++;
349
+ while (index < lines.length && !lines[index].trim().startsWith(fence)) {
350
+ body.push(lines[index]);
351
+ index++;
352
+ }
353
+ index++;
354
+ blocks.push({ kind: "code", text: body.join("\n") });
355
+ continue;
356
+ }
357
+ const heading = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
358
+ if (heading) {
359
+ blocks.push({ kind: "heading", level: heading[1].length, text: heading[2] });
360
+ index++;
361
+ continue;
362
+ }
363
+ // A TABLE IS A UNIT TOO. A pipe-separated line followed by a separator line is a table, and the
364
+ // rows after it belong to it however many there are.
365
+ if (line.includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[index + 1] ?? "")) {
366
+ const header = splitTableRow(line);
367
+ const rows = [];
368
+ index += 2;
369
+ while (index < lines.length && lines[index].includes("|") && lines[index].trim()) {
370
+ rows.push(splitTableRow(lines[index]));
371
+ index++;
372
+ }
373
+ blocks.push({ kind: "table", header, rows });
374
+ continue;
375
+ }
376
+ if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
377
+ const items = [];
378
+ while (index < lines.length && /^\s*([-*+]|\d+[.)])\s+/.test(lines[index])) {
379
+ items.push(lines[index].replace(/^\s*([-*+]|\d+[.)])\s+/, ""));
380
+ index++;
381
+ }
382
+ blocks.push({ kind: "list", items });
383
+ continue;
384
+ }
385
+ const paragraph = [];
386
+ // THE PARAGRAPH ENDS WHERE THE NEXT BLOCK BEGINS, and the block starts it must recognize are the
387
+ // SAME ones the outer loop recognizes - heading, list item, AND now fence and table. A fence or a
388
+ // table that follows a line of prose (no blank line between) is still a fence or a table:
389
+ // CommonMark allows a fence to interrupt a paragraph, and the module's own rule is "a fence is a
390
+ // unit". The terminator used to check only headings and list items, so the fence - markers
391
+ // included - was swallowed into the paragraph text and reached buildDocx/buildPdf/buildPptx as
392
+ // literal ``` and | characters (measured by scripts/document-block-parse.test.mjs).
393
+ while (index < lines.length && lines[index].trim() && !/^(#{1,6})\s/.test(lines[index]) && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[index]) && !/^\s*(```|~~~)/.test(lines[index]) && !(lines[index].includes("|") && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(lines[index + 1] ?? ""))) {
394
+ paragraph.push(lines[index]);
395
+ index++;
396
+ }
397
+ blocks.push({ kind: "paragraph", text: paragraph.join("\n") });
398
+ }
399
+ return blocks;
400
+ }
401
+ /** Splits one pipe-separated table row into its cells, dropping the empty edge cells a leading or trailing pipe creates. */
402
+ function splitTableRow(line) {
403
+ const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
404
+ return trimmed.split("|").map(cell => cell.trim());
405
+ }
406
+ /** Builds a Word document from Markdown. */
407
+ async function buildDocx(spec) {
408
+ let docx;
409
+ try {
410
+ docx = (await import("docx"));
411
+ }
412
+ catch {
413
+ throw new DocumentError("missing_dependency", `写 Word 文档需要安装 docx(npm install docx)`);
414
+ }
415
+ const children = [];
416
+ if (spec.title)
417
+ children.push(new docx.Paragraph({ children: [new docx.TextRun({ text: spec.title, bold: true, size: 32 })] }));
418
+ for (const block of parseMarkdownBlocks(spec.content)) {
419
+ if (block.kind === "heading") {
420
+ const level = Math.min(Math.max(block.level, 1), 6);
421
+ children.push(new docx.Paragraph({ heading: docx.HeadingLevel[`HEADING_${level}`], children: [new docx.TextRun({ text: block.text })] }));
422
+ continue;
423
+ }
424
+ if (block.kind === "paragraph") {
425
+ children.push(new docx.Paragraph({ children: [new docx.TextRun({ text: block.text })] }));
426
+ continue;
427
+ }
428
+ if (block.kind === "list") {
429
+ for (const item of block.items)
430
+ children.push(new docx.Paragraph({ bullet: { level: 0 }, children: [new docx.TextRun({ text: item })] }));
431
+ continue;
432
+ }
433
+ if (block.kind === "code") {
434
+ for (const line of block.text.split("\n"))
435
+ children.push(new docx.Paragraph({ children: [new docx.TextRun({ text: line, font: "Courier New" })] }));
436
+ continue;
437
+ }
438
+ // A table becomes a real Word table. The header row is bold, and the column count is the widest
439
+ // row's, so a ragged table does not lose a cell.
440
+ const width = Math.max(block.header.length, ...block.rows.map(row => row.length), 1);
441
+ const border = { style: docx.BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
442
+ const borders = { top: border, bottom: border, left: border, right: border };
443
+ const row = (cells, bold) => new docx.TableRow({
444
+ children: Array.from({ length: width }, (_, cell) => new docx.TableCell({
445
+ borders,
446
+ children: [new docx.Paragraph({ children: [new docx.TextRun({ text: cells[cell] ?? "", bold })] })],
447
+ })),
448
+ });
449
+ children.push(new docx.Table({
450
+ width: { size: 100, type: docx.WidthType.PERCENTAGE },
451
+ rows: [row(block.header, true), ...block.rows.map(cells => row(cells, false))],
452
+ }));
453
+ }
454
+ const document = new docx.Document({ sections: [{ children }] });
455
+ return docx.Packer.toBuffer(document);
456
+ }
457
+ /** Builds an Excel workbook from CSV. */
458
+ async function buildXlsx(spec) {
459
+ let ExcelJS;
460
+ try {
461
+ ExcelJS = (await import("exceljs"));
462
+ }
463
+ catch {
464
+ throw new DocumentError("missing_dependency", `写 Excel 需要安装 exceljs(npm install exceljs)`);
465
+ }
466
+ const workbook = new ExcelJS.default.Workbook();
467
+ // A SHEET PER SECTION. The content is CSV, and a CSV cannot hold more than one table - so the
468
+ // sections are separated by a sheet marker and each becomes its own sheet.
469
+ const sections = splitCsvSections(spec.content);
470
+ for (const section of sections) {
471
+ const sheet = workbook.addWorksheet(section.name);
472
+ for (const row of section.rows)
473
+ sheet.addRow(row);
474
+ }
475
+ const buffer = await workbook.xlsx.writeBuffer();
476
+ return Buffer.from(buffer);
477
+ }
478
+ /**
479
+ * Splits a CSV document into sheets.
480
+ *
481
+ * The separator is a line naming the sheet: `## Sheet name`. A CSV with no separator is one sheet,
482
+ * named after the document's title.
483
+ */
484
+ function splitCsvSections(content, fallbackName = "Sheet1") {
485
+ const lines = content.replace(/\r\n/g, "\n").split("\n");
486
+ const sections = [];
487
+ let name = fallbackName;
488
+ let rows = [];
489
+ const flush = () => {
490
+ if (rows.length)
491
+ sections.push({ name, rows });
492
+ rows = [];
493
+ };
494
+ for (const line of lines) {
495
+ const marker = /^##\s+(.*\S)\s*$/.exec(line);
496
+ if (marker) {
497
+ flush();
498
+ name = marker[1];
499
+ continue;
500
+ }
501
+ if (!line.trim())
502
+ continue;
503
+ rows.push(parseCsvRow(line));
504
+ }
505
+ flush();
506
+ if (!sections.length)
507
+ sections.push({ name: fallbackName, rows: [] });
508
+ return sections;
509
+ }
510
+ /** Parses one CSV line into its cells, honouring the doubled-quote escape. */
511
+ function parseCsvRow(line) {
512
+ const cells = [];
513
+ let current = "";
514
+ let quoted = false;
515
+ for (let index = 0; index < line.length; index++) {
516
+ const character = line[index];
517
+ if (quoted) {
518
+ if (character === '"') {
519
+ if (line[index + 1] === '"') {
520
+ current += '"';
521
+ index++;
522
+ }
523
+ else
524
+ quoted = false;
525
+ }
526
+ else
527
+ current += character;
528
+ continue;
529
+ }
530
+ if (character === '"') {
531
+ quoted = true;
532
+ continue;
533
+ }
534
+ if (character === ",") {
535
+ cells.push(current);
536
+ current = "";
537
+ continue;
538
+ }
539
+ current += character;
540
+ }
541
+ cells.push(current);
542
+ return cells;
543
+ }
544
+ /**
545
+ * Builds a PDF.
546
+ *
547
+ * WHY PDFKIT AND NOT PDF-LIB. Two reasons, both about Chinese. The PDF standard fonts pdf-lib
548
+ * embeds are WinAnsi only - they have no Chinese characters at all, so a Chinese PDF came out as a
549
+ * document full of replacement characters, which is worse than no PDF. And pdfkit is built on
550
+ * fontkit, which does three things pdf-lib does not: it embeds a real CJK font, it subsets that
551
+ * font to the glyphs actually used (so a one-page Chinese note does not carry a multi-megabyte
552
+ * font), and it wraps lines the way CJK text needs - by character, since Chinese has no spaces.
553
+ * Hand-rolling any of those three was the alternative, and all three are exactly the kind of thing
554
+ * that looks done and fails on someone else's document.
555
+ *
556
+ * THE FONT FILES SHIP IN THE PACKAGE, at `assets/fonts/`, under the SIL Open Font License - the
557
+ * same arrangement as the knowledge base's embedding weights: no download, no network, present
558
+ * after `npm install`. They are loaded only when the text actually contains CJK, so an English PDF
559
+ * still uses the PDF standard fonts and stays tiny.
560
+ */
561
+ async function buildPdf(spec) {
562
+ if (!spec.content.trim() && !spec.title?.trim())
563
+ throw new DocumentError("empty", "文档内容为空。");
564
+ let PDFDocument;
565
+ try {
566
+ PDFDocument = (await import("pdfkit")).default;
567
+ }
568
+ catch {
569
+ throw new DocumentError("missing_dependency", "生成 PDF 需要安装 pdfkit(npm install pdfkit)");
570
+ }
571
+ const hasCjk = containsCjk(spec.content) || containsCjk(spec.title ?? "");
572
+ const doc = new PDFDocument({ size: "A4", margins: { top: 56, bottom: 56, left: 56, right: 56 } });
573
+ const chunks = [];
574
+ doc.on("data", (chunk) => { if (chunk)
575
+ chunks.push(chunk); });
576
+ const finished = new Promise((resolve) => doc.on("end", () => resolve()));
577
+ let boldCjkAvailable = false;
578
+ if (hasCjk) {
579
+ const regular = cjkFontPath("NotoSansSC-Regular.otf");
580
+ if (!regular) {
581
+ // REFUSED, NOT DRAWN AS BOXES. A PDF without the glyphs is a file that looks produced and
582
+ // reads as garbage; the person finds out only after opening it. Saying what is missing is
583
+ // the only honest output here.
584
+ throw new DocumentError("missing_asset", "生成中文 PDF 需要 Noto Sans SC 字体(应随 Super Pi 一起安装在 assets/fonts/ 下)。文件缺失时请重新安装 Super Pi。");
585
+ }
586
+ doc.registerFont("CJK", regular);
587
+ const bold = cjkFontPath("NotoSansSC-Bold.otf");
588
+ if (bold) {
589
+ doc.registerFont("CJKBold", bold);
590
+ boldCjkAvailable = true;
591
+ }
592
+ }
593
+ // ONE FONT DECISION FOR THE WHOLE DOCUMENT. A CJK font carries Latin glyphs too, so a mixed
594
+ // Chinese-and-English document uses it throughout rather than switching fonts mid-line - which
595
+ // is also what keeps line wrapping uniform.
596
+ const font = (boldWanted) => {
597
+ if (!hasCjk)
598
+ return boldWanted ? "Helvetica-Bold" : "Helvetica";
599
+ return boldWanted && boldCjkAvailable ? "CJKBold" : "CJK";
600
+ };
601
+ const say = (text, options = { size: 11 }) => {
602
+ if (!text)
603
+ return;
604
+ doc.font(font(options.bold ?? false));
605
+ doc.fontSize(options.size);
606
+ doc.text(text);
607
+ };
608
+ if (spec.title) {
609
+ say(spec.title, { bold: true, size: 18 });
610
+ doc.moveDown(1);
611
+ }
612
+ for (const block of parseMarkdownBlocks(spec.content)) {
613
+ if (block.kind === "heading") {
614
+ say(block.text, { bold: true, size: block.level <= 2 ? 15 : 12.5 });
615
+ doc.moveDown(0.5);
616
+ continue;
617
+ }
618
+ if (block.kind === "paragraph") {
619
+ say(block.text);
620
+ doc.moveDown(0.75);
621
+ continue;
622
+ }
623
+ if (block.kind === "list") {
624
+ for (const item of block.items)
625
+ say(`• ${item}`);
626
+ doc.moveDown(0.75);
627
+ continue;
628
+ }
629
+ if (block.kind === "code") {
630
+ for (const line of block.text.split("\n"))
631
+ say(line, { size: 9.5 });
632
+ doc.moveDown(0.75);
633
+ continue;
634
+ }
635
+ for (const row of [block.header, ...block.rows])
636
+ say(row.join(" | "), { size: 10 });
637
+ doc.moveDown(0.75);
638
+ }
639
+ doc.end();
640
+ await finished;
641
+ return Buffer.concat(chunks);
642
+ }
643
+ /**
644
+ * Where a shipped font lives, or null when it is missing.
645
+ *
646
+ * Resolved from `import.meta.dirname` the way the extension paths are: two `..` segments reach the
647
+ * package root both after `tsc -b` (dist/server/) and under `tsx watch` (src/server/).
648
+ */
649
+ function cjkFontPath(fileName) {
650
+ try {
651
+ const path = resolve(import.meta.dirname, "../../assets/fonts", fileName);
652
+ return existsSync(path) ? path : null;
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ }
658
+ /** Whether a string contains a character the PDF standard fonts cannot represent. */
659
+ function containsCjk(text) {
660
+ return /[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef]/.test(text);
661
+ }
662
+ /**
663
+ * PPTX TEXT EXTRACTION.
664
+ *
665
+ * A `.pptx` is a zip of XML parts. The slide ORDER comes from the presentation's slide list mapped
666
+ * through its relationships - the file names `slide1.xml`, `slide2.xml` are not the display order,
667
+ * and a deck reordered in PowerPoint is exactly where the numeric sort lies. Titles are picked out
668
+ * by the shape's placeholder type, which is how PowerPoint itself knows what a title is; the title
669
+ * becomes a Markdown heading, which is what the knowledge base's chunker treats as a section
670
+ * boundary, so a deck imported into the knowledge base chunks per slide rather than as one block.
671
+ */
672
+ async function extractPptxText(bytes) {
673
+ let fflate;
674
+ try {
675
+ fflate = (await import("fflate"));
676
+ }
677
+ catch {
678
+ throw new DocumentError("missing_dependency", `读 PowerPoint 需要 ${PACKAGE_BY_FORMAT.pptx}(npm install ${PACKAGE_BY_FORMAT.pptx})`);
679
+ }
680
+ let files;
681
+ try {
682
+ files = fflate.unzipSync(new Uint8Array(bytes));
683
+ }
684
+ catch {
685
+ throw new DocumentError("corrupt", "这个文件不是有效的 PowerPoint 文件(无法解压)。");
686
+ }
687
+ const decoder = new TextDecoder("utf8");
688
+ const readEntry = (name) => {
689
+ const data = files[name];
690
+ return data ? decoder.decode(data) : null;
691
+ };
692
+ const order = [];
693
+ const presentationXml = readEntry("ppt/presentation.xml");
694
+ if (presentationXml) {
695
+ const relationships = new Map();
696
+ const relsXml = readEntry("ppt/_rels/presentation.xml.rels");
697
+ if (relsXml) {
698
+ for (const match of relsXml.matchAll(/<Relationship\b[^>]*>/g)) {
699
+ const id = /\bId="([^"]+)"/.exec(match[0])?.[1];
700
+ const target = /\bTarget="([^"]+)"/.exec(match[0])?.[1];
701
+ if (id && target)
702
+ relationships.set(id, target);
703
+ }
704
+ }
705
+ for (const match of presentationXml.matchAll(/<p:sldId\b[^>]*r:id="([^"]+)"/g)) {
706
+ const target = relationships.get(match[1]);
707
+ if (!target || !target.endsWith(".xml"))
708
+ continue;
709
+ // A rel target is relative to ppt/ (`slides/slide1.xml`) or to the package root (`/ppt/...`);
710
+ // both are normalised to the part name the zip uses.
711
+ order.push(target.replace(/^\/+/, "").replace(/^ppt\/ppt\//, "ppt/").startsWith("ppt/") ? target.replace(/^\/+/, "").replace(/^ppt\/ppt\//, "ppt/") : `ppt/${target.replace(/^\/+/, "")}`);
712
+ }
713
+ }
714
+ if (!order.length) {
715
+ // No slide list to trust (or none present): fall back to the numeric order of the files. This
716
+ // is the approximation, not the rule - the relationship map above is what usually runs.
717
+ order.push(...Object.keys(files)
718
+ .filter(name => /^ppt\/slides\/slide\d+\.xml$/.test(name))
719
+ .sort((a, b) => slideIndexOf(a) - slideIndexOf(b)));
720
+ }
721
+ if (!order.length)
722
+ throw new DocumentError("corrupt", "这个 PowerPoint 文件里没有幻灯片。");
723
+ const sections = [];
724
+ for (const slidePath of order) {
725
+ const xml = readEntry(slidePath);
726
+ if (!xml)
727
+ continue;
728
+ const slide = slideText(xml);
729
+ const lines = [];
730
+ if (slide.title)
731
+ lines.push(`# ${slide.title}`);
732
+ lines.push(...slide.lines);
733
+ const notes = slideNotes(files, slidePath, decoder);
734
+ if (notes)
735
+ lines.push(`备注:${notes.replace(/\n+/g, " ")}`);
736
+ if (lines.length)
737
+ sections.push(lines.join("\n"));
738
+ }
739
+ const text = sections.join("\n\n").trim();
740
+ if (!text) {
741
+ throw new DocumentError("empty", "这个 PPT 里没有可提取的文字(可能整份都是图片)。图形、SmartArt 和图表里的文字不在提取范围内。");
742
+ }
743
+ return { text, format: "pptx", warnings: ["图形、SmartArt 和图表里的文字不在提取范围内;版式、配色和动画不保留。"] };
744
+ }
745
+ /** A slide's text: its title (from the title placeholder) and every other paragraph and table row. */
746
+ function slideText(xml) {
747
+ let title = "";
748
+ const lines = [];
749
+ for (const shape of xml.match(/<p:sp>[\s\S]*?<\/p:sp>/g) ?? []) {
750
+ const isTitle = /<p:ph[^>]*type="(?:ctrTitle|title)"/.test(shape);
751
+ for (const paragraph of shape.match(/<a:p\/>|<a:p>[\s\S]*?<\/a:p>/g) ?? []) {
752
+ const text = [...paragraph.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map(run => unescapeXml(run[1])).join("");
753
+ if (!text.trim())
754
+ continue;
755
+ if (isTitle && !title)
756
+ title = text.trim();
757
+ else
758
+ lines.push(text);
759
+ }
760
+ }
761
+ // TABLES LIVE IN graphicFrame, NOT in p:sp, so the shape pass never sees them. Each row becomes
762
+ // a pipe-joined line - the same flattening the Word and Excel extractors do and warn about.
763
+ for (const table of xml.match(/<a:tbl>[\s\S]*?<\/a:tbl>/g) ?? []) {
764
+ for (const row of table.match(/<a:tr[\s\S]*?<\/a:tr>/g) ?? []) {
765
+ const cells = [...row.matchAll(/<a:tc[\s\S]*?<\/a:tc>/g)]
766
+ .map(cell => [...cell[0].matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map(run => unescapeXml(run[1])).join("").trim());
767
+ if (cells.some(cell => cell))
768
+ lines.push(cells.join(" | "));
769
+ }
770
+ }
771
+ return { title, lines };
772
+ }
773
+ /** The speaker notes one slide points at, resolved through the slide's own relationships. */
774
+ function slideNotes(files, slidePath, decoder) {
775
+ const directory = slidePath.replace(/\/[^/]+$/, "");
776
+ const name = slidePath.split("/").pop() ?? "";
777
+ const relsXml = files[`${directory}/_rels/${name}.rels`];
778
+ if (!relsXml)
779
+ return null;
780
+ for (const match of decoder.decode(relsXml).matchAll(/<Relationship\b[^>]*>/g)) {
781
+ const target = /\bTarget="([^"]+)"/.exec(match[0])?.[1];
782
+ if (!target?.includes("notesSlide"))
783
+ continue;
784
+ const notesPath = target.replace(/^\/+/, "").replace(/^\.\.\//, "ppt/");
785
+ const data = files[notesPath];
786
+ if (!data)
787
+ continue;
788
+ // ONLY THE BODY PLACEHOLDER IS THE NOTE. A notes slide also carries the slide-number
789
+ // placeholder, and a writer that creates empty notes pages (pptxgenjs does, for every slide)
790
+ // puts the slide's ordinal in it - without this filter every slide would grow a bogus
791
+ // 「备注:2」 line that is the page number, not what anyone said.
792
+ const xml = decoder.decode(data);
793
+ const lines = [];
794
+ for (const shape of xml.match(/<p:sp>[\s\S]*?<\/p:sp>/g) ?? []) {
795
+ if (!/<p:ph[^>]*type="body"/.test(shape))
796
+ continue;
797
+ for (const paragraph of shape.match(/<a:p\/>|<a:p>[\s\S]*?<\/a:p>/g) ?? []) {
798
+ const text = [...paragraph.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map(run => unescapeXml(run[1])).join("");
799
+ if (text.trim())
800
+ lines.push(text);
801
+ }
802
+ }
803
+ const text = lines.join("\n").trim();
804
+ if (text)
805
+ return text;
806
+ }
807
+ return null;
808
+ }
809
+ /** Turns the XML entities a text run may carry back into characters. `&amp;` is decoded LAST, so an escaped `&lt;` stays literal. */
810
+ function unescapeXml(text) {
811
+ return text
812
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
813
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
814
+ .replace(/&quot;/g, '"')
815
+ .replace(/&apos;/g, "'")
816
+ .replace(/&lt;/g, "<")
817
+ .replace(/&gt;/g, ">")
818
+ .replace(/&amp;/g, "&");
819
+ }
820
+ /** A slide file's numeric index, for the fallback ordering. */
821
+ function slideIndexOf(path) {
822
+ return Number(/slide(\d+)\.xml$/.exec(path)?.[1] ?? 0);
823
+ }
824
+ /**
825
+ * LEGACY .PPT TEXT EXTRACTION.
826
+ *
827
+ * A PowerPoint 97 file is an OLE compound file whose "PowerPoint Document" stream holds a tree of
828
+ * binary records; the text lives in TextCharsAtom (UTF-16) and TextBytesAtom (ANSI) records at
829
+ * varying nesting depths. The walk descends into containers and collects every text atom in stream
830
+ * order - which is the slide order, because PowerPoint writes them that way - but the format
831
+ * promises no headings, so the output is flat lines.
832
+ */
833
+ async function extractPptText(bytes) {
834
+ let CFB;
835
+ try {
836
+ CFB = (await import("cfb"));
837
+ }
838
+ catch {
839
+ throw new DocumentError("missing_dependency", `读旧版 PPT 需要 ${PACKAGE_BY_FORMAT.ppt}(npm install ${PACKAGE_BY_FORMAT.ppt})`);
840
+ }
841
+ let container;
842
+ try {
843
+ container = CFB.read(new Uint8Array(bytes), { type: "buffer" });
844
+ }
845
+ catch {
846
+ throw new DocumentError("corrupt", "这个文件不是有效的 PowerPoint 文件。");
847
+ }
848
+ const entry = CFB.find(container, "PowerPoint Document");
849
+ if (!entry?.content)
850
+ throw new DocumentError("corrupt", "这个文件不是有效的 PowerPoint 文件。");
851
+ const stream = Buffer.from(entry.content);
852
+ const pieces = [];
853
+ walkPptRecords(stream, (type, body) => {
854
+ if (type === PPT_TEXT_CHARS_ATOM)
855
+ pieces.push(body.toString("utf16le"));
856
+ else if (type === PPT_TEXT_BYTES_ATOM)
857
+ pieces.push(body.toString("latin1"));
858
+ });
859
+ const text = pieces.join("\n").replace(/\r\n?/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
860
+ if (!text)
861
+ throw new DocumentError("empty", "这个 PPT 文件里没有可提取的文字。");
862
+ return { text, format: "ppt", warnings: ["旧版 .ppt 按文件内的出现顺序提取文字,不分页、不区分标题和正文;图片和版式不保留。另存为 .pptx 可以得到更好的效果。"] };
863
+ }
864
+ const PPT_TEXT_CHARS_ATOM = 0x0fa0;
865
+ const PPT_TEXT_BYTES_ATOM = 0x0fa8;
866
+ /**
867
+ * Walks a PowerPoint record stream, descending into containers.
868
+ *
869
+ * THE CONTAINER RULE IS IN THE HEADER'S LOW NIBBLE, NOT THE TYPE: a record's first short packs a
870
+ * version into the low 4 bits, and version 0xF marks a container whose body is more records.
871
+ * A truncated or lying length stops the walk rather than reading past the buffer.
872
+ */
873
+ function walkPptRecords(stream, visit) {
874
+ const walk = (start, end) => {
875
+ let position = start;
876
+ while (position + 8 <= end) {
877
+ const version = stream.readUInt16LE(position) & 0x0f;
878
+ const type = stream.readUInt16LE(position + 2);
879
+ const length = stream.readUInt32LE(position + 4);
880
+ const bodyStart = position + 8;
881
+ if (bodyStart + length > end)
882
+ return;
883
+ if (version === 0x0f)
884
+ walk(bodyStart, bodyStart + length);
885
+ else
886
+ visit(type, stream.subarray(bodyStart, bodyStart + length));
887
+ position = bodyStart + length;
888
+ }
889
+ };
890
+ walk(0, stream.length);
891
+ }
892
+ /**
893
+ * Builds a PowerPoint deck from Markdown.
894
+ *
895
+ * THE MAPPING, stated once: a first- or second-level heading starts a new slide (its text is the
896
+ * slide title); everything between headings is that slide's body - paragraphs, bullets, code lines
897
+ * and pipe-joined table rows. A title with no slide bodies still produces its title slide.
898
+ *
899
+ * THE CANVAS IS SET EXPLICITLY, and that is not a detail. pptxgenjs DEFAULTS to a 10 × 5.625 inch
900
+ * canvas, and a deck laid out on that canvas while its type sizes assume a 13.33 × 7.5 one comes out
901
+ * systematically wrong - a body box that ends 1.2 inches below the slide edge silently loses the
902
+ * text that falls there, with no error anywhere. `LAYOUT_WIDE` is the 13.33 × 7.5 canvas that the
903
+ * sizes below are written for.
904
+ *
905
+ * THE FONT IS NAMED, and for CJK it is named as 微软雅黑 - a face the VIEWER's PowerPoint actually
906
+ * ships. This is the opposite of the PDF's rule, and the reason is the mechanism: a PDF EMBEDS its
907
+ * fonts, so a build machine's font is safe there, while a `.pptx` only stores the font's NAME and
908
+ * the user's PowerPoint resolves it. A name that exists only on the build machine (Noto Sans SC
909
+ * among them) silently substitutes on the user's machine, which is why the PDF font is not reused
910
+ * here. pptxgenjs writes the name into `<a:latin>`, `<a:ea>` and `<a:cs>` alike, so the Chinese
911
+ * glyphs get it too and not just the Latin ones.
912
+ */
913
+ async function buildPptx(spec) {
914
+ let PptxGenJS;
915
+ try {
916
+ PptxGenJS = (await import("pptxgenjs")).default;
917
+ }
918
+ catch {
919
+ throw new DocumentError("missing_dependency", "写 PowerPoint 需要 pptxgenjs(npm install pptxgenjs)");
920
+ }
921
+ const blocks = parseMarkdownBlocks(spec.content);
922
+ if (!blocks.length && !spec.title?.trim())
923
+ throw new DocumentError("empty", "PPT 内容为空。");
924
+ const pptx = new PptxGenJS();
925
+ pptx.layout = "LAYOUT_WIDE";
926
+ if (spec.title)
927
+ pptx.title = spec.title;
928
+ const slides = [];
929
+ let current = null;
930
+ const start = (title) => {
931
+ current = { title, runs: [] };
932
+ slides.push(current);
933
+ return current;
934
+ };
935
+ for (const block of blocks) {
936
+ if (block.kind === "heading" && block.level <= 2) {
937
+ start(block.text);
938
+ continue;
939
+ }
940
+ current ??= start();
941
+ if (block.kind === "paragraph")
942
+ current.runs.push({ text: block.text, options: { breakLine: true } });
943
+ else if (block.kind === "list") {
944
+ for (const item of block.items) {
945
+ // A REAL BULLET, not a literal "- " in the text: `bullet` is a paragraph property, and a
946
+ // dash typed into the text is a dash, not a bullet.
947
+ current.runs.push({ text: item, options: { bullet: { code: "2022", indent: 14 }, breakLine: true } });
948
+ }
949
+ }
950
+ else if (block.kind === "code") {
951
+ for (const line of block.text.split("\n"))
952
+ current.runs.push({ text: line, options: { breakLine: true } });
953
+ }
954
+ else if (block.kind === "table") {
955
+ current.runs.push({ text: block.header.join(" | "), options: { breakLine: true, bold: true } });
956
+ for (const row of block.rows)
957
+ current.runs.push({ text: row.join(" | "), options: { breakLine: true } });
958
+ }
959
+ else if (block.kind === "heading")
960
+ current.runs.push({ text: block.text, options: { bold: true, breakLine: true } });
961
+ }
962
+ if (!slides.length)
963
+ start(spec.title);
964
+ // THE CANVAS, as constants. Every position is derived from these, so changing the layout is one
965
+ // edit rather than a hunt through magic numbers.
966
+ const W = 13.33, H = 7.5, M = 0.6;
967
+ for (const slide of slides) {
968
+ const page = pptx.addSlide();
969
+ if (slide.title) {
970
+ page.addText(slide.title, { x: M, y: 0.45, w: W - M * 2, h: 0.9, fontSize: 28, bold: true, fontFace: DECK_FONT, color: "1F2937" });
971
+ }
972
+ if (slide.runs.length) {
973
+ // The body box ends a margin ABOVE the slide's bottom edge. A box that runs to the edge is a
974
+ // box whose overflow is invisible rather than obviously wrong.
975
+ page.addText(slide.runs, {
976
+ x: M, y: slide.title ? 1.6 : M, w: W - M * 2, h: H - (slide.title ? 1.6 : M) - M,
977
+ fontSize: 16, fontFace: DECK_FONT, color: "374151", valign: "top", paraSpaceAfter: 6,
978
+ });
979
+ }
980
+ }
981
+ const output = await pptx.write({ outputType: "nodebuffer" });
982
+ return Buffer.from(output);
983
+ }
984
+ /**
985
+ * The font a generated deck asks for.
986
+ *
987
+ * 微软雅黑 because it ships with Windows and with Office on macOS, so the name in the file
988
+ * resolves on the machine that opens it. It is a SUGGESTION, not an embedding: a viewer without it
989
+ * substitutes its own, which is why nothing about the deck's correctness may depend on this face -
990
+ * the layout is sized with room to spare for a substitute with different glyph widths.
991
+ */
992
+ const DECK_FONT = "微软雅黑";
993
+ /**
994
+ * The format a file's extension names, for the callers that hold a path rather than a format.
995
+ */
996
+ export function formatOfPath(path) {
997
+ return documentFormatOf(extname(path));
998
+ }