texlite 0.7.3 → 0.7.6

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 (71) hide show
  1. package/README.md +20 -9
  2. package/README.zh-CN.md +9 -9
  3. package/dist/client/assets/{GitDialog-BmRzMv-C.js → GitDialog-W3hLOWrF.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
  5. package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
  6. package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-BVQb4qdD.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
  9. package/dist/client/assets/index-DRShM2ob.js +64 -0
  10. package/dist/client/assets/index-DzcH7hBu.css +1 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-BfqMIeUg.js +1 -0
  13. package/dist/client/assets/pdf-BB1XvBIF.js +55 -0
  14. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  15. package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
  16. package/dist/client/assets/tex_fmt_bg-VEAUIuFz.wasm +0 -0
  17. package/dist/client/index.html +3 -3
  18. package/dist/server/anchors.js +22 -1
  19. package/dist/server/app.js +103 -2375
  20. package/dist/server/auth.js +3 -2
  21. package/dist/server/cli.js +8 -2
  22. package/dist/server/collaboration.js +250 -2
  23. package/dist/server/compileArtifacts.js +10 -7
  24. package/dist/server/compiler.js +9 -4
  25. package/dist/server/config.js +25 -1
  26. package/dist/server/environment.js +5 -1
  27. package/dist/server/files.js +7 -6
  28. package/dist/server/git.js +28 -39
  29. package/dist/server/harper.js +144 -0
  30. package/dist/server/harperRuntime.js +38 -0
  31. package/dist/server/harperWorker.js +15 -0
  32. package/dist/server/history.js +11 -9
  33. package/dist/server/http.js +33 -13
  34. package/dist/server/i18n.js +276 -0
  35. package/dist/server/latexRoot.js +78 -0
  36. package/dist/server/limits.js +9 -0
  37. package/dist/server/projectSearch.js +3 -4
  38. package/dist/server/routes/auth.js +77 -0
  39. package/dist/server/routes/citations.js +331 -0
  40. package/dist/server/routes/collaboration.js +17 -0
  41. package/dist/server/routes/comments.js +176 -0
  42. package/dist/server/routes/compile.js +71 -50
  43. package/dist/server/routes/projectFiles.js +572 -0
  44. package/dist/server/routes/projectGit.js +145 -0
  45. package/dist/server/routes/projectHistory.js +150 -0
  46. package/dist/server/routes/projectMembers.js +112 -0
  47. package/dist/server/routes/projectShared.js +238 -0
  48. package/dist/server/routes/projects.js +473 -0
  49. package/dist/server/routes/system.js +37 -0
  50. package/dist/server/routes/users.js +163 -0
  51. package/dist/server/security.js +2 -4
  52. package/dist/server/synctex.js +7 -4
  53. package/dist/server/zip.js +29 -13
  54. package/package.json +4 -2
  55. package/texlite.config.example.json +5 -1
  56. package/dist/client/assets/BinaryModule-Aj1vLnwf-_qIOaefD.js +0 -4
  57. package/dist/client/assets/HistoryDialog-V0IkSDtJ.js +0 -1
  58. package/dist/client/assets/LatexEditor-CokkLLxi.js +0 -59
  59. package/dist/client/assets/PdfPreview-Cs1WYPsC.js +0 -1
  60. package/dist/client/assets/SystemMetricsDialog-DBrendnp.js +0 -1
  61. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  62. package/dist/client/assets/binary-BQKEW09b.js +0 -1
  63. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  64. package/dist/client/assets/index-BS1IMOaB.js +0 -62
  65. package/dist/client/assets/index-Lw53KTaT.css +0 -1
  66. package/dist/client/assets/index-QyVe7I1s.js +0 -3456
  67. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  68. package/dist/client/assets/minus-yEV4SLvG.js +0 -1
  69. package/dist/client/assets/pdf-DPnwNQHb.js +0 -55
  70. package/dist/client/assets/spellCheck-OZGa6ljx.js +0 -5
  71. package/dist/server/latexFormatter.js +0 -118
@@ -8,19 +8,34 @@ import { resolveSourcePath, safeRelativePath, texFileStem } from "../files.js";
8
8
  import { captureCompileSnapshot, cleanCompileArtifacts, cleanCompileCache, compileProject, discardCompileSnapshot, hasCompileCache, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
9
9
  import { parseCompileDiagnostics } from "../compileDiagnostics.js";
10
10
  import { pdfToSource, sourceToPdf } from "../synctex.js";
11
- import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, syncArtifacts } from "../compileArtifacts.js";
12
- import { apiError, contentDisposition } from "../http.js";
11
+ import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, pdfLoadingMode, syncArtifacts } from "../compileArtifacts.js";
12
+ import { MAX_TEXT_PREVIEW_BYTES } from "../limits.js";
13
+ import { apiError, contentDisposition, httpError } from "../http.js";
13
14
  const now = () => new Date().toISOString();
14
15
  const timingDuration = (milliseconds) => Math.round(milliseconds * 10) / 10;
15
16
  const MAX_SNAPSHOT_ATTEMPTS = 3;
16
17
  const SNAPSHOT_RETRY_AFTER_SECONDS = 1;
18
+ function pdfResponseMetadata(config, pdfPath) {
19
+ if (!pdfPath)
20
+ return { pdfSizeBytes: null, pdfLoadingMode: null };
21
+ const stat = regularFileStat(pdfPath);
22
+ if (!stat)
23
+ return {
24
+ pdfSizeBytes: null,
25
+ pdfLoadingMode: config.pdfLoadingStrategy === "range" ? "range" : "full"
26
+ };
27
+ return {
28
+ pdfSizeBytes: stat.size,
29
+ pdfLoadingMode: pdfLoadingMode(config, stat.size)
30
+ };
31
+ }
17
32
  function mainDocumentChangedError() {
18
- return Object.assign(new Error("项目主文档在编译排队期间发生变化,请重新发起编译"), { statusCode: 409, code: "MAIN_DOCUMENT_CHANGED" });
33
+ return httpError(409, "MAIN_DOCUMENT_CHANGED");
19
34
  }
20
35
  class CompileSnapshotBusyError extends Error {
21
36
  code = "COMPILE_SNAPSHOT_BUSY";
22
37
  constructor() {
23
- super("项目源码尚未完成稳定保存,暂时无法编译,请稍后重试");
38
+ super("COMPILE_SNAPSHOT_BUSY");
24
39
  this.name = "CompileSnapshotBusyError";
25
40
  }
26
41
  }
@@ -33,11 +48,11 @@ export function registerCompileRoutes(app, context) {
33
48
  const { id } = request.params;
34
49
  const project = accessibleProject(db, id, user);
35
50
  if (!project)
36
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
51
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
37
52
  const query = request.query;
38
53
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
39
54
  if (!mainFile)
40
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
55
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
41
56
  const latest = db.prepare(`SELECT run.id, run.status, run.log, run.created_at, run.finished_at,
42
57
  run.requested_by, user.username AS requested_by_username, user.display_name AS requested_by_name
43
58
  FROM compile_runs run LEFT JOIN users user ON user.id = run.requested_by
@@ -52,6 +67,7 @@ export function registerCompileRoutes(app, context) {
52
67
  // A retained legacy PDF has no run bundle, so its file version (mtime) is
53
68
  // the only stable token that can be resolved back to those bytes.
54
69
  const pdfVersion = published?.runId ?? pdf?.version;
70
+ const pdfMetadata = pdfResponseMetadata(config, pdf?.path ?? null);
55
71
  return {
56
72
  mainFile,
57
73
  latestRun: latest ? {
@@ -66,7 +82,8 @@ export function registerCompileRoutes(app, context) {
66
82
  } : null,
67
83
  hasPdf: Boolean(pdf),
68
84
  pdfUrl: pdf ? compilePdfUrl(id, mainFile, pdfVersion ?? pdf.version) : null,
69
- pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null
85
+ pdfCompiledAt: publishedRun?.finished_at ?? latestSuccess?.finished_at ?? null,
86
+ ...pdfMetadata
70
87
  };
71
88
  });
72
89
  app.get("/api/projects/:id/compile/artifacts", async (request, reply) => {
@@ -76,11 +93,11 @@ export function registerCompileRoutes(app, context) {
76
93
  const { id } = request.params;
77
94
  const project = accessibleProject(db, id, user);
78
95
  if (!project)
79
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
96
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
80
97
  const query = request.query;
81
98
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
82
99
  if (!mainFile)
83
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
100
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
84
101
  const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
85
102
  if (!query.path) {
86
103
  if (!published)
@@ -98,23 +115,23 @@ export function registerCompileRoutes(app, context) {
98
115
  }
99
116
  }
100
117
  if (!published)
101
- return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND", "尚无可用的编译产物");
118
+ return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND");
102
119
  const relative = safeRelativePath(query.path);
103
120
  const absolute = path.join(published.output, relative);
104
121
  const outputDirectory = path.resolve(published.output);
105
122
  const resolved = path.resolve(absolute);
106
123
  if (!resolved.startsWith(`${outputDirectory}${path.sep}`)) {
107
- return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
124
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND");
108
125
  }
109
126
  let stat;
110
127
  try {
111
128
  stat = fs.statSync(resolved);
112
129
  if (!stat.isFile())
113
- return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
130
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND");
114
131
  }
115
132
  catch (error) {
116
133
  if (isMissingFileError(error))
117
- return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
134
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND");
118
135
  throw error;
119
136
  }
120
137
  if (query.download === "1") {
@@ -127,7 +144,7 @@ export function registerCompileRoutes(app, context) {
127
144
  catch (error) {
128
145
  await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
129
146
  if (isMissingFileError(error))
130
- return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
147
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND");
131
148
  throw error;
132
149
  }
133
150
  reply.header("Content-Type", "application/octet-stream");
@@ -139,15 +156,15 @@ export function registerCompileRoutes(app, context) {
139
156
  stream.once("error", cleanup);
140
157
  return reply.send(stream);
141
158
  }
142
- if (stat.size > 2 * 1024 * 1024 || !isTextCompileArtifact(relative)) {
143
- return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED", "该产物不能作为文本预览,请下载后查看");
159
+ if (stat.size > MAX_TEXT_PREVIEW_BYTES || !isTextCompileArtifact(relative)) {
160
+ return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED");
144
161
  }
145
162
  try {
146
163
  return { path: relative, content: fs.readFileSync(resolved, "utf8") };
147
164
  }
148
165
  catch (error) {
149
166
  if (isMissingFileError(error))
150
- return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
167
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND");
151
168
  throw error;
152
169
  }
153
170
  });
@@ -158,19 +175,19 @@ export function registerCompileRoutes(app, context) {
158
175
  const { id } = request.params;
159
176
  const project = accessibleProject(db, id, user);
160
177
  if (!project || !canEdit(project))
161
- return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有清理编译产物的权限");
178
+ return apiError(reply, 403, "COMPILE_FORBIDDEN");
162
179
  const body = (request.body ?? {});
163
180
  const mainFile = compileMainFile(config, id, project.main_file, body.mainFile);
164
181
  if (!mainFile)
165
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
182
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
166
183
  if (body.mode !== "cache" && body.mode !== "artifacts") {
167
- return apiError(reply, 400, "REQUEST_INVALID", "清理模式无效");
184
+ return apiError(reply, 400, "REQUEST_INVALID");
168
185
  }
169
186
  return await projectMutations.runCompileExclusive(id, () => {
170
187
  const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
171
188
  WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
172
189
  if (activeRun)
173
- return apiError(reply, 409, "COMPILE_CLEAN_BUSY", "当前主文档正在编译,请稍后再清理");
190
+ return apiError(reply, 409, "COMPILE_CLEAN_BUSY");
174
191
  const requestedBy = { id: user.id, username: user.username, name: user.display_name };
175
192
  if (body.mode === "cache") {
176
193
  cleanCompileCache(config, id, mainFile);
@@ -186,7 +203,7 @@ export function registerCompileRoutes(app, context) {
186
203
  }
187
204
  const currentProject = accessibleProject(db, id, user);
188
205
  if (!currentProject || !canEdit(currentProject))
189
- return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有清理编译产物的权限");
206
+ return apiError(reply, 403, "COMPILE_FORBIDDEN");
190
207
  const runs = db.prepare(`SELECT id FROM compile_runs
191
208
  WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')`).all(id, mainFile);
192
209
  cleanCompileArtifacts(config, id, mainFile, currentProject.main_file, runs.map((run) => run.id));
@@ -204,10 +221,10 @@ export function registerCompileRoutes(app, context) {
204
221
  }, { preflight: () => {
205
222
  const current = accessibleProject(db, id, user);
206
223
  if (!current || !canEdit(current))
207
- throw Object.assign(new Error("没有清理编译产物的权限"), { statusCode: 403, code: "COMPILE_FORBIDDEN" });
224
+ throw httpError(403, "COMPILE_FORBIDDEN");
208
225
  const selected = compileMainFile(config, id, current.main_file, body.mainFile);
209
226
  if (!selected || selected !== mainFile)
210
- throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
227
+ throw httpError(400, "MAIN_DOCUMENT_INVALID");
211
228
  } });
212
229
  });
213
230
  app.get("/api/projects/:id/sync/pdf", async (request, reply) => {
@@ -217,28 +234,28 @@ export function registerCompileRoutes(app, context) {
217
234
  const { id } = request.params;
218
235
  const project = accessibleProject(db, id, user);
219
236
  if (!project)
220
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
237
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
221
238
  const query = request.query;
222
239
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
223
240
  if (!mainFile)
224
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
241
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
225
242
  const sourcePath = safeRelativePath(query.path ?? "");
226
243
  const line = Number(query.line);
227
244
  const column = Number(query.column ?? 1);
228
245
  if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
229
- return apiError(reply, 400, "SYNC_SOURCE_INVALID", "源码位置无效");
246
+ return apiError(reply, 400, "SYNC_SOURCE_INVALID");
230
247
  }
231
248
  return await projectMutations.runConsistentRead(id, async () => {
232
249
  if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
233
- return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
250
+ return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND");
234
251
  }
235
252
  const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
236
253
  if (!artifacts)
237
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
254
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE");
238
255
  return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
239
256
  }, { preflight: () => {
240
257
  if (!accessibleProject(db, id, user))
241
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
258
+ throw httpError(404, "PROJECT_NOT_FOUND");
242
259
  } });
243
260
  });
244
261
  app.get("/api/projects/:id/sync/source", async (request, reply) => {
@@ -248,31 +265,31 @@ export function registerCompileRoutes(app, context) {
248
265
  const { id } = request.params;
249
266
  const project = accessibleProject(db, id, user);
250
267
  if (!project)
251
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
268
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
252
269
  const query = request.query;
253
270
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
254
271
  if (!mainFile)
255
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
272
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
256
273
  const page = Number(query.page);
257
274
  const x = Number(query.x);
258
275
  const y = Number(query.y);
259
276
  if (!Number.isInteger(page) || page < 1 || !Number.isFinite(x) || x < 0 || !Number.isFinite(y) || y < 0) {
260
- return apiError(reply, 400, "SYNC_PDF_INVALID", "PDF 位置无效");
277
+ return apiError(reply, 400, "SYNC_PDF_INVALID");
261
278
  }
262
279
  return await projectMutations.runConsistentRead(id, async () => {
263
280
  const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
264
281
  if (!artifacts)
265
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
282
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE");
266
283
  const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
267
284
  const sourceDirectory = path.resolve(artifacts.source);
268
285
  const relative = path.relative(sourceDirectory, path.resolve(location.input));
269
286
  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
270
- return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
287
+ return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH");
271
288
  }
272
289
  return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
273
290
  }, { preflight: () => {
274
291
  if (!accessibleProject(db, id, user))
275
- throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
292
+ throw httpError(404, "PROJECT_NOT_FOUND");
276
293
  } });
277
294
  });
278
295
  app.post("/api/projects/:id/compile", async (request, reply) => {
@@ -283,12 +300,12 @@ export function registerCompileRoutes(app, context) {
283
300
  const { id } = request.params;
284
301
  const initialProject = accessibleProject(db, id, user);
285
302
  if (!initialProject || !canEdit(initialProject))
286
- return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有编译权限");
303
+ return apiError(reply, 403, "COMPILE_FORBIDDEN");
287
304
  const body = (request.body ?? {});
288
305
  const requestedMainFile = typeof body.mainFile === "string" && body.mainFile ? body.mainFile : null;
289
306
  const initialMainFile = compileMainFile(config, id, initialProject.main_file, body.mainFile);
290
307
  if (!initialMainFile)
291
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
308
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
292
309
  const runId = randomUUID();
293
310
  // Admission must stay cheap. In particular, do not call
294
311
  // captureCompileSnapshot here: several collaborators can reach this
@@ -537,8 +554,9 @@ export function registerCompileRoutes(app, context) {
537
554
  // archive/read or another output mutation. The long latexmk
538
555
  // process above remains outside that queue.
539
556
  await projectMutations.runSerialized(id, () => {
540
- if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
541
- throw new Error("项目已被删除");
557
+ if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id)) {
558
+ throw new Error("Project was deleted before compile artifacts could be published.");
559
+ }
542
560
  publishCompileArtifacts(config, id, snapshot, compiled);
543
561
  }, { flush: false });
544
562
  refreshSnapshotStale();
@@ -620,7 +638,7 @@ export function registerCompileRoutes(app, context) {
620
638
  `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
621
639
  `total;dur=${timingDuration(requestMs)}`
622
640
  ].join(", "));
623
- return apiError(reply, 409, result.errorCode ?? "COMPILE_SNAPSHOT_BUSY", result.log, { retryable: true, retryAfterSeconds: SNAPSHOT_RETRY_AFTER_SECONDS });
641
+ return apiError(reply, 409, result.errorCode ?? "COMPILE_SNAPSHOT_BUSY", { retryable: true, retryAfterSeconds: SNAPSHOT_RETRY_AFTER_SECONDS });
624
642
  }
625
643
  if (result.timings) {
626
644
  metrics.record("compile.cacheSync", result.timings.cacheSyncMs);
@@ -631,6 +649,8 @@ export function registerCompileRoutes(app, context) {
631
649
  const timings = result.timings
632
650
  ? { snapshotMs: result.snapshotMs ?? 0, ...result.timings, requestMs }
633
651
  : { snapshotMs: result.snapshotMs ?? 0, requestMs };
652
+ const responsePdf = result.ok ? availablePdf(config, id, mainFile, project.main_file) : null;
653
+ const pdfMetadata = pdfResponseMetadata(config, responsePdf?.path ?? null);
634
654
  reply.header("Server-Timing", [
635
655
  `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
636
656
  result.timings ? `cache;dur=${timingDuration(result.timings.cacheSyncMs)}` : "",
@@ -643,6 +663,7 @@ export function registerCompileRoutes(app, context) {
643
663
  mainFile, runId: result.runId, ok: result.ok, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
644
664
  pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
645
665
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
666
+ ...pdfMetadata,
646
667
  timings
647
668
  };
648
669
  });
@@ -658,36 +679,36 @@ export function registerCompileRoutes(app, context) {
658
679
  const { id } = request.params;
659
680
  const project = accessibleProject(db, id, user);
660
681
  if (!project)
661
- return apiError(reply, 404, "PROJECT_NOT_FOUND", "项目不存在");
682
+ return apiError(reply, 404, "PROJECT_NOT_FOUND");
662
683
  const query = request.query;
663
684
  const mainFile = compileMainFile(config, id, project.main_file, query.mainFile);
664
685
  if (!mainFile)
665
- return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
686
+ return apiError(reply, 400, "MAIN_DOCUMENT_INVALID");
666
687
  const downloading = query.download === "1";
667
688
  let artifact = availablePdf(config, id, mainFile, project.main_file);
668
689
  if (!artifact)
669
- return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
690
+ return apiError(reply, 404, "PDF_NOT_FOUND");
670
691
  let versioned = false;
671
692
  if (query.run) {
672
693
  if (/^[a-f0-9-]{36}$/i.test(query.run)) {
673
694
  const run = db.prepare(`SELECT main_file, status FROM compile_runs
674
695
  WHERE id = ? AND project_id = ?`).get(query.run, id);
675
696
  if (!run || run.status !== "succeeded" || run.main_file !== mainFile) {
676
- return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 不存在");
697
+ return apiError(reply, 404, "PDF_NOT_FOUND");
677
698
  }
678
699
  artifact = compileRunPdf(config, id, mainFile, query.run);
679
700
  if (!artifact)
680
- return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 已被清理");
701
+ return apiError(reply, 404, "PDF_NOT_FOUND");
681
702
  versioned = true;
682
703
  }
683
704
  else if (artifact.version !== query.run) {
684
- return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 不存在");
705
+ return apiError(reply, 404, "PDF_NOT_FOUND");
685
706
  }
686
707
  }
687
708
  const pdf = artifact.path;
688
709
  const stat = regularFileStat(pdf);
689
710
  if (!stat)
690
- return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
711
+ return apiError(reply, 404, "PDF_NOT_FOUND");
691
712
  const etag = `"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`;
692
713
  reply.header("Content-Type", "application/pdf");
693
714
  const targetSuffix = mainFile === project.main_file ? "" : `-${texFileStem(mainFile)}`;
@@ -721,7 +742,7 @@ export function registerCompileRoutes(app, context) {
721
742
  }
722
743
  catch (error) {
723
744
  if (isMissingFileError(error))
724
- return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
745
+ return apiError(reply, 404, "PDF_NOT_FOUND");
725
746
  throw error;
726
747
  }
727
748
  return reply.code(206).send(stream);
@@ -733,7 +754,7 @@ export function registerCompileRoutes(app, context) {
733
754
  }
734
755
  catch (error) {
735
756
  if (isMissingFileError(error))
736
- return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
757
+ return apiError(reply, 404, "PDF_NOT_FOUND");
737
758
  throw error;
738
759
  }
739
760
  return reply.code(200).send(stream);