papilot 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/doctor.mjs +1 -1
- package/dist/assets/{_virtual_papilot-licenses-CMfCtEYQ.js → _virtual_papilot-licenses-YJbh9c1G.js} +1 -1
- package/dist/assets/index-CwFvCo6r.css +1 -0
- package/dist/assets/index-dByEATYE.js +267 -0
- package/dist/assets/{math-CEU4m4-H.js → math-UelUOI-R.js} +1 -1
- package/dist/assets/{pdf-view-HWRGBLsZ.js → pdf-view-ChJvfsD8.js} +1 -1
- package/dist/index.html +2 -2
- package/engine/dist/web/assets/FullPaper-DbT25OhQ.js +54 -0
- package/engine/dist/web/assets/index-C_KwLmXQ.js +19 -0
- package/engine/dist/web/assets/index-D3_ZFdT8.css +1 -0
- package/engine/dist/web/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
- package/{vendor/latex-review → engine}/dist/web/index.html +2 -2
- package/engine/package-lock.json +3390 -0
- package/engine/package.json +50 -0
- package/{vendor/latex-review → engine}/src/core/build.ts +22 -6
- package/engine/src/core/progress.ts +67 -0
- package/{vendor/latex-review → engine}/src/core/review.ts +32 -0
- package/{vendor/latex-review → engine}/src/render/inline.ts +1 -0
- package/{vendor/latex-review → engine}/src/render/locate.ts +26 -16
- package/engine/src/render/positions.ts +34 -0
- package/engine/src/render/synctex.ts +188 -0
- package/engine/src/render/synctex.wasm +0 -0
- package/engine/src/render/synctex.wasm.LICENSE +50 -0
- package/{vendor/latex-review → engine}/src/server.ts +5 -3
- package/{vendor/latex-review → engine}/src/types.ts +13 -0
- package/engine/src/web/FullPaper.tsx +318 -0
- package/{vendor/latex-review → engine}/src/web/locales/de.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/es.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/fr.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/it.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/ja.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/ko.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/pt.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/sv.json +3 -0
- package/{vendor/latex-review → engine}/src/web/locales/zh-Hant.json +3 -0
- package/engine/src/web/paper.ts +13 -0
- package/{vendor/latex-review → engine}/src/web/review.tsx +94 -1
- package/{vendor/latex-review → engine}/src/web/style.css +70 -0
- package/engine/src/web/view-preferences.ts +15 -0
- package/{vendor/latex-review → engine}/src/web/zh-CN.ts +3 -0
- package/package.json +10 -8
- package/src/conversation.ts +1 -1
- package/src/server/app.ts +18 -1
- package/src/server/changes.ts +12 -3
- package/src/server/imports.ts +49 -22
- package/src/server/main.ts +3 -2
- package/src/server/manuscript.ts +5 -4
- package/src/server/paths.ts +1 -1
- package/src/server/start.ts +3 -10
- package/src/server/update-install.ts +8 -25
- package/src/server/updates.ts +43 -25
- package/src/shared.ts +6 -3
- package/vendor/latex-review/package-lock.json +4 -3106
- package/vendor/latex-review/package.json +3 -46
- package/README.de.md +0 -110
- package/README.es.md +0 -110
- package/README.fr.md +0 -110
- package/README.it.md +0 -110
- package/README.ja.md +0 -110
- package/README.ko.md +0 -110
- package/README.pt.md +0 -110
- package/README.sv.md +0 -110
- package/README.zh-CN.md +0 -110
- package/README.zh-Hant.md +0 -110
- package/dist/assets/index-oAIkxhfp.js +0 -265
- package/dist/assets/index-t_qCOF_B.css +0 -1
- package/vendor/latex-review/dist/web/assets/index-DVs7cTBl.js +0 -19
- package/vendor/latex-review/dist/web/assets/index-ysOpu05D.css +0 -1
- /package/{vendor/latex-review → engine}/LICENSE +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
- /package/{vendor/latex-review → engine}/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
- /package/{vendor/latex-review → engine}/src/cli.ts +0 -0
- /package/{vendor/latex-review → engine}/src/client.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/bib.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/changes.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/config.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/current-commit.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/delivery.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/diff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/files.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/git.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/outline.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/process.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/publish.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/reconcile.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/store.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/tasks.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/verify.ts +0 -0
- /package/{vendor/latex-review → engine}/src/core/view.ts +0 -0
- /package/{vendor/latex-review → engine}/src/locales.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/effects.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdf.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/pdfdiff.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/round.ts +0 -0
- /package/{vendor/latex-review → engine}/src/render/worker.ts +0 -0
- /package/{vendor/latex-review → engine}/src/runtime.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/ChangeMap.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/PdfStrip.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/effect-preferences.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/GUST-FONT-LICENSE.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/LPPL.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/MANIFEST-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README-newpx.txt +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/README.md +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
- /package/{vendor/latex-review → engine}/src/web/i18n.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/index.html +0 -0
- /package/{vendor/latex-review → engine}/src/web/locales/index.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/main.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/model.ts +0 -0
- /package/{vendor/latex-review → engine}/src/web/overview.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/references.tsx +0 -0
- /package/{vendor/latex-review → engine}/src/web/shortcuts.ts +0 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "latex-review-skill",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.22.0"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"latex-review": "scripts/review.mjs"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"review": "tsx src/cli.ts",
|
|
14
|
+
"dev": "vite --host 127.0.0.1",
|
|
15
|
+
"build": "tsc --noEmit && vite build",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"check": "npm run typecheck && npm test && npm run build",
|
|
19
|
+
"demo": "tsx examples/demo.ts",
|
|
20
|
+
"test:browser": "playwright test",
|
|
21
|
+
"test:corpus": "tsx scripts/validate-corpus.ts",
|
|
22
|
+
"fixture:prepare": "tsx scripts/prepare-fixture.ts",
|
|
23
|
+
"format": "prettier --write src tests scripts examples/demo.ts *.ts *.json"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@fastify/compress": "^9.2.2",
|
|
27
|
+
"@fastify/static": "^10.1.7",
|
|
28
|
+
"commander": "^15.0.0",
|
|
29
|
+
"diff": "^9.0.0",
|
|
30
|
+
"fastify": "^5.12.7",
|
|
31
|
+
"mupdf": "^1.28.1",
|
|
32
|
+
"proper-lockfile": "^4.1.2",
|
|
33
|
+
"react": "^19.3.1",
|
|
34
|
+
"react-dom": "^19.3.1",
|
|
35
|
+
"tsx": "^4.23.15"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@playwright/test": "^1.64.1",
|
|
39
|
+
"@types/node": "^22.20.5",
|
|
40
|
+
"@types/proper-lockfile": "^4.1.4",
|
|
41
|
+
"@types/react": "^19.3.0",
|
|
42
|
+
"@types/react-dom": "^19.3.0",
|
|
43
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
44
|
+
"pdfjs-dist": "^6.4.299",
|
|
45
|
+
"prettier": "^3.9.10",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vite": "^8.3.5",
|
|
48
|
+
"vitest": "^5.0.5"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -8,7 +8,8 @@ import { listFiles, readJson, writeJson, withLock, now } from "./files.ts";
|
|
|
8
8
|
import { stripComments } from "./outline.ts";
|
|
9
9
|
import { signature, type Project } from "./config.ts";
|
|
10
10
|
import { run, runAsync } from "./process.ts";
|
|
11
|
-
import
|
|
11
|
+
import { progressReader, writtenPages } from "./progress.ts";
|
|
12
|
+
import type { BuildInfo, BuildProgress } from "../types.ts";
|
|
12
13
|
export class BuildError extends Error {
|
|
13
14
|
constructor(
|
|
14
15
|
message: string,
|
|
@@ -219,7 +220,7 @@ export class Builder {
|
|
|
219
220
|
rev: string,
|
|
220
221
|
targets = ["full"],
|
|
221
222
|
seed: BuildInfo | null = null,
|
|
222
|
-
options: { force?: boolean } = {},
|
|
223
|
+
options: { force?: boolean; progress?: (progress: BuildProgress) => void } = {},
|
|
223
224
|
): Promise<BuildInfo> {
|
|
224
225
|
const p = this.project,
|
|
225
226
|
partial = JSON.stringify(targets) !== '["full"]';
|
|
@@ -270,10 +271,10 @@ export class Builder {
|
|
|
270
271
|
const start = partial || options.force ? null : this.latest(context) ?? "author";
|
|
271
272
|
let result: BuildInfo;
|
|
272
273
|
try {
|
|
273
|
-
result = await this.compile(rev, tree, key, targets, seed, date, start);
|
|
274
|
+
result = await this.compile(rev, tree, key, targets, seed, date, start, options.progress);
|
|
274
275
|
} catch (error) {
|
|
275
276
|
if (!start || (error instanceof BuildError && !error.details.some(line => /(?:^|\s|\()(?:\.\/)?build\//.test(line)))) throw error;
|
|
276
|
-
result = await this.compile(rev, tree, key, targets, seed, date, null);
|
|
277
|
+
result = await this.compile(rev, tree, key, targets, seed, date, null, options.progress);
|
|
277
278
|
}
|
|
278
279
|
if (!partial) writeJson(path.join(this.cache, "latest.json"), { key, context, date: date.SOURCE_DATE_EPOCH });
|
|
279
280
|
return result;
|
|
@@ -293,6 +294,7 @@ export class Builder {
|
|
|
293
294
|
seed: BuildInfo | null,
|
|
294
295
|
date: ReturnType<typeof buildDate>,
|
|
295
296
|
start: { info: BuildInfo; date: unknown } | "author" | null,
|
|
297
|
+
progress?: (progress: BuildProgress) => void,
|
|
296
298
|
): Promise<BuildInfo> {
|
|
297
299
|
const p = this.project,
|
|
298
300
|
partial = JSON.stringify(targets) !== '["full"]',
|
|
@@ -373,14 +375,28 @@ export class Builder {
|
|
|
373
375
|
args.push(main);
|
|
374
376
|
const transcript = path.join(folder, "latexmk.log"),
|
|
375
377
|
fd = fs.openSync(transcript, "w"),
|
|
376
|
-
started = now()
|
|
378
|
+
started = now(),
|
|
379
|
+
// Pages are counted against the build this one continues from.
|
|
380
|
+
read = progress
|
|
381
|
+
? progressReader(
|
|
382
|
+
progress,
|
|
383
|
+
writtenPages(
|
|
384
|
+
base
|
|
385
|
+
? path.join(path.dirname(base.pdf), job + ".log")
|
|
386
|
+
: path.join(p.root, p.doc_dir, "build", job + ".log"),
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
: null;
|
|
377
390
|
let failure: Error | null = null;
|
|
378
391
|
try {
|
|
379
392
|
await runAsync("latexmk", args, {
|
|
380
393
|
cwd,
|
|
381
394
|
env: { ...process.env, ...date },
|
|
382
395
|
timeout: 1800000,
|
|
383
|
-
output: (chunk) =>
|
|
396
|
+
output: (chunk) => {
|
|
397
|
+
fs.writeSync(fd, chunk);
|
|
398
|
+
read?.(chunk);
|
|
399
|
+
},
|
|
384
400
|
});
|
|
385
401
|
} catch (error) {
|
|
386
402
|
failure = error as Error;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import type { BuildProgress } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
// What latexmk is doing, read from its output as it streams: the rule it
|
|
5
|
+
// runs, the TeX pass, and the pages TeX has shipped out in that pass. TeX
|
|
6
|
+
// prints "[n" for every page and flushes the terminal right after, so the
|
|
7
|
+
// count moves while a long document typesets.
|
|
8
|
+
const RUN = /^Run number (\d+) of rule '([^']+)'/;
|
|
9
|
+
const WRITTEN = /^Output written on .*?\((\d+) pages?\b/;
|
|
10
|
+
// A page marker is "[" with \count0 (and any nonzero \count1–9), closed by
|
|
11
|
+
// "]", a space, or the font map and image notes TeX prints inside it.
|
|
12
|
+
const PAGE = /\[-?\d+(?:\.-?\d+)*(?=[\]\s{<(])/g;
|
|
13
|
+
|
|
14
|
+
export function progressReader(
|
|
15
|
+
report: (progress: BuildProgress) => void,
|
|
16
|
+
total: number | null,
|
|
17
|
+
) {
|
|
18
|
+
const state: BuildProgress = { step: "tex", pass: 0, pages: 0, total };
|
|
19
|
+
let line = "",
|
|
20
|
+
scanned = 0;
|
|
21
|
+
const pages = (text: string, from: number) => {
|
|
22
|
+
if (state.step !== "tex" || state.pass === 0) return text.length;
|
|
23
|
+
PAGE.lastIndex = from;
|
|
24
|
+
let end = from;
|
|
25
|
+
while (PAGE.exec(text)) {
|
|
26
|
+
state.pages++;
|
|
27
|
+
end = PAGE.lastIndex;
|
|
28
|
+
}
|
|
29
|
+
// An unfinished marker at the end counts once it is complete.
|
|
30
|
+
const open = text.lastIndexOf("[");
|
|
31
|
+
return open >= end && /^\[-?[\d.]*$/.test(text.slice(open)) ? open : text.length;
|
|
32
|
+
};
|
|
33
|
+
report({ ...state });
|
|
34
|
+
return (chunk: Buffer) => {
|
|
35
|
+
const before = JSON.stringify(state),
|
|
36
|
+
parts = (line + chunk.toString("latin1")).split("\n");
|
|
37
|
+
line = parts.pop()!;
|
|
38
|
+
for (const complete of parts) {
|
|
39
|
+
const run = RUN.exec(complete),
|
|
40
|
+
written = WRITTEN.exec(complete);
|
|
41
|
+
if (run) {
|
|
42
|
+
const rule = run[2].split(" ")[0];
|
|
43
|
+
if (/^(?:pdf|xe|lua)?latex$/.test(rule))
|
|
44
|
+
Object.assign(state, { step: "tex", pass: Number(run[1]), pages: 0 });
|
|
45
|
+
else
|
|
46
|
+
state.step = /^(?:xdvipdfmx|dvipdfmx?|dvips|ps2pdf)$/.test(rule)
|
|
47
|
+
? "pdf"
|
|
48
|
+
: "bibliography";
|
|
49
|
+
} else if (written) state.total = state.pages = Number(written[1]);
|
|
50
|
+
else pages(complete, scanned);
|
|
51
|
+
scanned = 0;
|
|
52
|
+
}
|
|
53
|
+
scanned = pages(line, scanned);
|
|
54
|
+
if (JSON.stringify(state) !== before) report({ ...state });
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The pages of the build a compilation continues from, as TeX reported them.
|
|
59
|
+
export function writtenPages(log: string): number | null {
|
|
60
|
+
try {
|
|
61
|
+
const text = fs.readFileSync(log, "latin1"),
|
|
62
|
+
found = [...text.matchAll(/Output written on .*?\((\d+) pages?\b/g)].at(-1);
|
|
63
|
+
return found ? Number(found[1]) : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { App } from "../server.ts";
|
|
|
8
8
|
import type {
|
|
9
9
|
Action,
|
|
10
10
|
ActionDetail,
|
|
11
|
+
BuildInfo,
|
|
11
12
|
ActionKind,
|
|
12
13
|
Decision,
|
|
13
14
|
Feedback,
|
|
@@ -54,6 +55,8 @@ export function safePath(root: string, file: string) {
|
|
|
54
55
|
return target;
|
|
55
56
|
}
|
|
56
57
|
export class Review {
|
|
58
|
+
// Strip positions recovered for earlier rounds, by round and strip image.
|
|
59
|
+
private tops = new Map<string, Map<string, number>>();
|
|
57
60
|
constructor(public app: App) {}
|
|
58
61
|
latestRound(name: string) {
|
|
59
62
|
return this.app.store.one<Round>(
|
|
@@ -223,6 +226,35 @@ export class Review {
|
|
|
223
226
|
: null,
|
|
224
227
|
};
|
|
225
228
|
}
|
|
229
|
+
// The full paper places each change by where its strips start on the page.
|
|
230
|
+
// Rounds rendered before strips recorded that recover it from the round's
|
|
231
|
+
// new PDF the first time they are shown.
|
|
232
|
+
async positioned(detail: RoundDetail): Promise<RoundDetail> {
|
|
233
|
+
const strips = [...detail.sections.flatMap((s) => s.blocks), ...detail.loose]
|
|
234
|
+
.flatMap((block) => (block.kind === "change" ? block.after : []))
|
|
235
|
+
.filter((strip) => strip.top === undefined);
|
|
236
|
+
if (!strips.length) return detail;
|
|
237
|
+
const key = `${detail.task}:${detail.round}`;
|
|
238
|
+
let tops = this.tops.get(key);
|
|
239
|
+
if (!tops) {
|
|
240
|
+
tops = new Map();
|
|
241
|
+
const build = this.round(detail.task, detail.round).summary.builds?.at(-1),
|
|
242
|
+
info = build
|
|
243
|
+
? readJson<BuildInfo | null>(path.join(this.app.state, "cache/builds", build, "result.json"), null)
|
|
244
|
+
: null;
|
|
245
|
+
if (info && fs.existsSync(info.pdf)) {
|
|
246
|
+
const { stripTops } = await import("../render/positions.ts");
|
|
247
|
+
stripTops(info.pdf, strips);
|
|
248
|
+
for (const strip of strips) if (strip.top !== undefined) tops.set(strip.src, strip.top);
|
|
249
|
+
}
|
|
250
|
+
this.tops.set(key, tops);
|
|
251
|
+
}
|
|
252
|
+
for (const strip of strips) {
|
|
253
|
+
const top = tops.get(strip.src);
|
|
254
|
+
if (top !== undefined) strip.top = top;
|
|
255
|
+
}
|
|
256
|
+
return detail;
|
|
257
|
+
}
|
|
226
258
|
roundDetail(name: string, number: number): RoundDetail {
|
|
227
259
|
const round = this.round(name, number),
|
|
228
260
|
render = readJson<RenderedRound>(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { run } from "../core/process.ts";
|
|
4
3
|
import { argument, buildOutline } from "../core/outline.ts";
|
|
4
|
+
import { SynctexIndex } from "./synctex.ts";
|
|
5
5
|
import { listFiles } from "../core/files.ts";
|
|
6
6
|
import { PdfDocument, type PdfPage, type OutlineEntry } from "./pdf.ts";
|
|
7
7
|
import type {
|
|
@@ -47,6 +47,7 @@ export class Build {
|
|
|
47
47
|
}
|
|
48
48
|
close() {
|
|
49
49
|
this.document.close();
|
|
50
|
+
this.locator.close();
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
export function sourceRange(
|
|
@@ -135,29 +136,34 @@ export class Margins {
|
|
|
135
136
|
: [(this.header ?? 36) + 4, (this.footer ?? page.height - 36) - 4];
|
|
136
137
|
}
|
|
137
138
|
}
|
|
139
|
+
// The CLI prints six decimals with printf, which rounds an exact half to
|
|
140
|
+
// even where toFixed rounds it up; matching it keeps every crop the same as
|
|
141
|
+
// with `synctex view`.
|
|
142
|
+
export function printed(value: number) {
|
|
143
|
+
const exact = Math.abs(value).toFixed(30),
|
|
144
|
+
cut = exact.indexOf(".") + 7,
|
|
145
|
+
kept = exact.slice(0, cut);
|
|
146
|
+
const magnitude =
|
|
147
|
+
/^50*$/.test(exact.slice(cut)) && Number(kept.at(-1)) % 2 === 0
|
|
148
|
+
? Number(kept)
|
|
149
|
+
: Number(Math.abs(value).toFixed(6));
|
|
150
|
+
return value < 0 ? -magnitude : magnitude;
|
|
151
|
+
}
|
|
138
152
|
export class Locator {
|
|
139
153
|
cache = new Map<string, Box[]>();
|
|
154
|
+
private index: SynctexIndex | null = null;
|
|
140
155
|
constructor(public build: Build) {}
|
|
141
156
|
lookup(file: string, line: number): Box[] {
|
|
142
157
|
const key = `${file}:${line}`;
|
|
143
158
|
if (this.cache.has(key)) return this.cache.get(key)!;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
["view", "-i", `${line}:0:${file}`, "-o", this.build.info.pdf],
|
|
147
|
-
{ check: false },
|
|
148
|
-
).stdout.toString(),
|
|
149
|
-
records: Box[] = [],
|
|
159
|
+
this.index ??= new SynctexIndex(this.build.info.pdf);
|
|
160
|
+
const records: Box[] = [],
|
|
150
161
|
shipped = new Set<number>(),
|
|
151
162
|
margins = this.build.margins;
|
|
152
|
-
for (const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
Number(m[2]),
|
|
157
|
-
]),
|
|
158
|
-
),
|
|
159
|
-
page = Number(block.split("\n")[0]) - 1,
|
|
160
|
-
{ y, v, H: height } = fields;
|
|
163
|
+
for (const result of this.index.view(file, line)) {
|
|
164
|
+
const [number, , y, h, v, W, height] = result.map(printed),
|
|
165
|
+
page = number - 1,
|
|
166
|
+
fields = { h, W };
|
|
161
167
|
if (
|
|
162
168
|
![page, y, v, height].every(Number.isFinite) ||
|
|
163
169
|
y < v - height - 4 ||
|
|
@@ -182,6 +188,10 @@ export class Locator {
|
|
|
182
188
|
this.cache.set(key, filtered);
|
|
183
189
|
return filtered;
|
|
184
190
|
}
|
|
191
|
+
close() {
|
|
192
|
+
this.index?.close();
|
|
193
|
+
this.index = null;
|
|
194
|
+
}
|
|
185
195
|
boxes(name: string, start: number, end: number) {
|
|
186
196
|
const file = this.build.path(name);
|
|
187
197
|
if (!fs.existsSync(file) || path.extname(file) !== ".tex") return [];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PdfDocument } from "./pdf.ts";
|
|
2
|
+
import type { Strip } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
// Where strips start on their page, for rounds rendered before strips
|
|
5
|
+
// recorded it. A strip keeps its characters relative to its top, read from
|
|
6
|
+
// the same PDF page by the same code: the difference between a character's
|
|
7
|
+
// place on the page and in the strip is where the strip starts. A strip
|
|
8
|
+
// without text (a figure alone) stays unplaced.
|
|
9
|
+
export function stripTops(pdf: string, strips: Strip[]) {
|
|
10
|
+
const document = new PdfDocument(pdf);
|
|
11
|
+
try {
|
|
12
|
+
for (const strip of strips) {
|
|
13
|
+
if (strip.top !== undefined || strip.page < 1 || strip.page > document.length) continue;
|
|
14
|
+
const page = document.page(strip.page - 1);
|
|
15
|
+
found: for (const line of strip.text ?? []) {
|
|
16
|
+
if (line.chars.length < 3) continue;
|
|
17
|
+
for (const candidate of page.lines)
|
|
18
|
+
if (
|
|
19
|
+
candidate.chars.length === line.chars.length &&
|
|
20
|
+
candidate.chars.every(
|
|
21
|
+
(char, i) =>
|
|
22
|
+
char.text === line.chars[i].text &&
|
|
23
|
+
Math.abs(char.rect[0] - line.chars[i].rect[0]) < 0.01,
|
|
24
|
+
)
|
|
25
|
+
) {
|
|
26
|
+
strip.top = Math.round((candidate.chars[0].rect[1] - line.chars[0].rect[1]) * 100) / 100;
|
|
27
|
+
break found;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} finally {
|
|
32
|
+
document.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
// SyncTeX's own parser (synctex_parser.c with zlib), compiled to WebAssembly
|
|
4
|
+
// by native/synctex/build.sh. `synctex view` parses the whole .synctex.gz
|
|
5
|
+
// again for every query; an index parses it once and answers each forward
|
|
6
|
+
// query from the parser's table of nodes by input file and line, with the
|
|
7
|
+
// values the CLI prints.
|
|
8
|
+
let compiled: WebAssembly.Module | undefined;
|
|
9
|
+
const wasm = () =>
|
|
10
|
+
(compiled ??= new WebAssembly.Module(
|
|
11
|
+
fs.readFileSync(new URL("./synctex.wasm", import.meta.url)),
|
|
12
|
+
));
|
|
13
|
+
|
|
14
|
+
interface Exports {
|
|
15
|
+
memory: WebAssembly.Memory;
|
|
16
|
+
_initialize?: () => void;
|
|
17
|
+
st_alloc(size: number): number;
|
|
18
|
+
st_free(pointer: number): void;
|
|
19
|
+
st_open(output: number): number;
|
|
20
|
+
st_close(): void;
|
|
21
|
+
st_view(input: number, line: number): number;
|
|
22
|
+
st_results(): number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Page (1-based), x, y, h, v, W, H: the fields of a `synctex view` result.
|
|
26
|
+
export type ViewResult = [number, number, number, number, number, number, number];
|
|
27
|
+
|
|
28
|
+
// The few WASI calls the parser makes, served from JavaScript: it reads the
|
|
29
|
+
// .synctex(.gz) file next to the PDF and prints warnings, nothing else.
|
|
30
|
+
// (Node's own WASI can collect garbage inside fd_read while a WebAssembly
|
|
31
|
+
// frame is on the stack, which crashes V8.)
|
|
32
|
+
const ERRNO = { SUCCESS: 0, ACCES: 2, BADF: 8, INVAL: 28, NOENT: 44, NOTSUP: 58 };
|
|
33
|
+
const ROOT = 3;
|
|
34
|
+
function system(memory: () => WebAssembly.Memory) {
|
|
35
|
+
const files = new Map<number, { data: Uint8Array; position: number }>();
|
|
36
|
+
let next = ROOT + 1;
|
|
37
|
+
const view = () => new DataView(memory().buffer),
|
|
38
|
+
bytes = () => new Uint8Array(memory().buffer);
|
|
39
|
+
const vectors = (iovs: number, count: number) =>
|
|
40
|
+
Array.from({ length: count }, (_, i) => [
|
|
41
|
+
view().getUint32(iovs + 8 * i, true),
|
|
42
|
+
view().getUint32(iovs + 8 * i + 4, true),
|
|
43
|
+
]);
|
|
44
|
+
return {
|
|
45
|
+
environ_sizes_get(count: number, size: number) {
|
|
46
|
+
view().setUint32(count, 0, true);
|
|
47
|
+
view().setUint32(size, 0, true);
|
|
48
|
+
return ERRNO.SUCCESS;
|
|
49
|
+
},
|
|
50
|
+
environ_get: () => ERRNO.SUCCESS,
|
|
51
|
+
// The only preopened directory is the file system root.
|
|
52
|
+
fd_prestat_get(fd: number, out: number) {
|
|
53
|
+
if (fd !== ROOT) return ERRNO.BADF;
|
|
54
|
+
view().setUint8(out, 0);
|
|
55
|
+
view().setUint32(out + 4, 1, true);
|
|
56
|
+
return ERRNO.SUCCESS;
|
|
57
|
+
},
|
|
58
|
+
fd_prestat_dir_name(fd: number, path: number, length: number) {
|
|
59
|
+
if (fd !== ROOT || length < 1) return ERRNO.BADF;
|
|
60
|
+
bytes()[path] = 0x2f;
|
|
61
|
+
return ERRNO.SUCCESS;
|
|
62
|
+
},
|
|
63
|
+
path_open(
|
|
64
|
+
dir: number,
|
|
65
|
+
_lookup: number,
|
|
66
|
+
path: number,
|
|
67
|
+
length: number,
|
|
68
|
+
openFlags: number,
|
|
69
|
+
_rights: bigint,
|
|
70
|
+
_inherited: bigint,
|
|
71
|
+
_fdFlags: number,
|
|
72
|
+
out: number,
|
|
73
|
+
) {
|
|
74
|
+
if (dir !== ROOT) return ERRNO.BADF;
|
|
75
|
+
if (openFlags !== 0) return ERRNO.ACCES; // read only: no create or truncate
|
|
76
|
+
const name = "/" + new TextDecoder().decode(bytes().subarray(path, path + length));
|
|
77
|
+
let data: Uint8Array;
|
|
78
|
+
try {
|
|
79
|
+
if (!fs.statSync(name).isFile()) return ERRNO.NOENT;
|
|
80
|
+
data = fs.readFileSync(name);
|
|
81
|
+
} catch {
|
|
82
|
+
return ERRNO.NOENT;
|
|
83
|
+
}
|
|
84
|
+
files.set(next, { data, position: 0 });
|
|
85
|
+
view().setUint32(out, next++, true);
|
|
86
|
+
return ERRNO.SUCCESS;
|
|
87
|
+
},
|
|
88
|
+
fd_fdstat_get(fd: number, out: number) {
|
|
89
|
+
const type = fd <= 2 ? 2 : fd === ROOT ? 3 : files.has(fd) ? 4 : -1;
|
|
90
|
+
if (type < 0) return ERRNO.BADF;
|
|
91
|
+
view().setUint8(out, type);
|
|
92
|
+
view().setUint16(out + 2, 0, true);
|
|
93
|
+
view().setBigUint64(out + 8, 0xffffffffffffffffn, true);
|
|
94
|
+
view().setBigUint64(out + 16, 0xffffffffffffffffn, true);
|
|
95
|
+
return ERRNO.SUCCESS;
|
|
96
|
+
},
|
|
97
|
+
fd_read(fd: number, iovs: number, count: number, out: number) {
|
|
98
|
+
const file = files.get(fd);
|
|
99
|
+
if (!file) return fd === 0 ? (view().setUint32(out, 0, true), ERRNO.SUCCESS) : ERRNO.BADF;
|
|
100
|
+
let read = 0;
|
|
101
|
+
for (const [at, size] of vectors(iovs, count)) {
|
|
102
|
+
const chunk = file.data.subarray(file.position, file.position + size);
|
|
103
|
+
bytes().set(chunk, at);
|
|
104
|
+
file.position += chunk.length;
|
|
105
|
+
read += chunk.length;
|
|
106
|
+
if (chunk.length < size) break;
|
|
107
|
+
}
|
|
108
|
+
view().setUint32(out, read, true);
|
|
109
|
+
return ERRNO.SUCCESS;
|
|
110
|
+
},
|
|
111
|
+
fd_seek(fd: number, offset: bigint, whence: number, out: number) {
|
|
112
|
+
const file = files.get(fd);
|
|
113
|
+
if (!file) return ERRNO.BADF;
|
|
114
|
+
const base = whence === 0 ? 0 : whence === 1 ? file.position : whence === 2 ? file.data.length : NaN,
|
|
115
|
+
position = base + Number(offset);
|
|
116
|
+
if (!(position >= 0)) return ERRNO.INVAL;
|
|
117
|
+
file.position = position;
|
|
118
|
+
view().setBigUint64(out, BigInt(position), true);
|
|
119
|
+
return ERRNO.SUCCESS;
|
|
120
|
+
},
|
|
121
|
+
fd_tell(fd: number, out: number) {
|
|
122
|
+
const file = files.get(fd);
|
|
123
|
+
if (!file) return ERRNO.BADF;
|
|
124
|
+
view().setBigUint64(out, BigInt(file.position), true);
|
|
125
|
+
return ERRNO.SUCCESS;
|
|
126
|
+
},
|
|
127
|
+
// Warnings go nowhere; they carry nothing a result does not.
|
|
128
|
+
fd_write(fd: number, iovs: number, count: number, out: number) {
|
|
129
|
+
if (fd > 2) return ERRNO.BADF;
|
|
130
|
+
view().setUint32(out, vectors(iovs, count).reduce((sum, [, size]) => sum + size, 0), true);
|
|
131
|
+
return ERRNO.SUCCESS;
|
|
132
|
+
},
|
|
133
|
+
fd_close(fd: number) {
|
|
134
|
+
return files.delete(fd) ? ERRNO.SUCCESS : ERRNO.BADF;
|
|
135
|
+
},
|
|
136
|
+
path_rename: () => ERRNO.NOTSUP,
|
|
137
|
+
proc_exit(code: number) {
|
|
138
|
+
throw new Error(`SyncTeX index exited with status ${code}`);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export class SynctexIndex {
|
|
144
|
+
private exports: Exports | null;
|
|
145
|
+
private open: boolean;
|
|
146
|
+
constructor(pdf: string) {
|
|
147
|
+
let memory: WebAssembly.Memory | undefined;
|
|
148
|
+
const instance = new WebAssembly.Instance(wasm(), {
|
|
149
|
+
wasi_snapshot_preview1: system(() => memory!),
|
|
150
|
+
});
|
|
151
|
+
this.exports = instance.exports as unknown as Exports;
|
|
152
|
+
memory = this.exports.memory;
|
|
153
|
+
this.exports._initialize?.();
|
|
154
|
+
const name = this.string(pdf);
|
|
155
|
+
// Without SyncTeX data the CLI answers every query with no results.
|
|
156
|
+
this.open = this.exports.st_open(name) !== 0;
|
|
157
|
+
this.exports.st_free(name);
|
|
158
|
+
}
|
|
159
|
+
private string(text: string) {
|
|
160
|
+
const encoded = new TextEncoder().encode(text + "\0"),
|
|
161
|
+
at = this.exports!.st_alloc(encoded.length);
|
|
162
|
+
new Uint8Array(this.exports!.memory.buffer, at, encoded.length).set(encoded);
|
|
163
|
+
return at;
|
|
164
|
+
}
|
|
165
|
+
// What `synctex view -i line:0:input -o pdf` reports, in its order.
|
|
166
|
+
view(input: string, line: number): ViewResult[] {
|
|
167
|
+
if (!this.exports || !this.open) return [];
|
|
168
|
+
const name = this.string(input),
|
|
169
|
+
count = this.exports.st_view(name, line);
|
|
170
|
+
this.exports.st_free(name);
|
|
171
|
+
const rows: ViewResult[] = [];
|
|
172
|
+
if (count > 0) {
|
|
173
|
+
const data = new Float64Array(
|
|
174
|
+
this.exports.memory.buffer,
|
|
175
|
+
this.exports.st_results(),
|
|
176
|
+
count * 7,
|
|
177
|
+
);
|
|
178
|
+
for (let i = 0; i < count; i++)
|
|
179
|
+
rows.push(Array.from(data.subarray(i * 7, i * 7 + 7)) as ViewResult);
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
close() {
|
|
184
|
+
if (!this.exports) return;
|
|
185
|
+
this.exports.st_close();
|
|
186
|
+
this.exports = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
synctex.wasm contains SyncTeX's parser (https://github.com/jlaurens/synctex,
|
|
2
|
+
commit a256d4835b7df2a0472cfa3d557af73a1b83f076) and zlib 1.3.1, built by
|
|
3
|
+
native/synctex/build.sh. Their licenses follow.
|
|
4
|
+
|
|
5
|
+
--- SyncTeX
|
|
6
|
+
|
|
7
|
+
MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2008-2017 jerome DOT laurens AT u-bourgogne DOT fr
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
12
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
13
|
+
in the Software without restriction, including without limitation the rights
|
|
14
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
15
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
16
|
+
furnished to do so, subject to the following conditions:
|
|
17
|
+
|
|
18
|
+
The above copyright notice and this permission notice shall be included in all
|
|
19
|
+
copies or substantial portions of the Software.
|
|
20
|
+
|
|
21
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
25
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
26
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
27
|
+
SOFTWARE.
|
|
28
|
+
|
|
29
|
+
--- zlib
|
|
30
|
+
|
|
31
|
+
(C) 1995-2024 Jean-loup Gailly and Mark Adler
|
|
32
|
+
|
|
33
|
+
This software is provided 'as-is', without any express or implied
|
|
34
|
+
warranty. In no event will the authors be held liable for any damages
|
|
35
|
+
arising from the use of this software.
|
|
36
|
+
|
|
37
|
+
Permission is granted to anyone to use this software for any purpose,
|
|
38
|
+
including commercial applications, and to alter it and redistribute it
|
|
39
|
+
freely, subject to the following restrictions:
|
|
40
|
+
|
|
41
|
+
1. The origin of this software must not be misrepresented; you must not
|
|
42
|
+
claim that you wrote the original software. If you use this software
|
|
43
|
+
in a product, an acknowledgment in the product documentation would be
|
|
44
|
+
appreciated but is not required.
|
|
45
|
+
2. Altered source versions must be plainly marked as such, and must not be
|
|
46
|
+
misrepresented as being the original software.
|
|
47
|
+
3. This notice may not be removed or altered from any source distribution.
|
|
48
|
+
|
|
49
|
+
Jean-loup Gailly Mark Adler
|
|
50
|
+
jloup@gzip.org madler@alumni.caltech.edu
|
|
@@ -188,9 +188,11 @@ export function createServer(app: App) {
|
|
|
188
188
|
server.get<{ Params: { name: string; number: string } }>(
|
|
189
189
|
"/api/tasks/:name/rounds/:number",
|
|
190
190
|
(request) =>
|
|
191
|
-
app.review.
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
app.review.positioned(
|
|
192
|
+
app.review.roundDetail(
|
|
193
|
+
request.params.name,
|
|
194
|
+
Number(request.params.number),
|
|
195
|
+
),
|
|
194
196
|
),
|
|
195
197
|
);
|
|
196
198
|
// The places an edit changes away from its lines, past those its round
|
|
@@ -163,6 +163,14 @@ export interface BuildInfo {
|
|
|
163
163
|
seconds: number;
|
|
164
164
|
warnings: string[];
|
|
165
165
|
}
|
|
166
|
+
// A running compilation: latexmk's current step, the TeX pass, and the
|
|
167
|
+
// pages typeset in it against the last known page count.
|
|
168
|
+
export interface BuildProgress {
|
|
169
|
+
step: "tex" | "bibliography" | "pdf";
|
|
170
|
+
pass: number;
|
|
171
|
+
pages: number;
|
|
172
|
+
total: number | null;
|
|
173
|
+
}
|
|
166
174
|
export interface Position {
|
|
167
175
|
page: number;
|
|
168
176
|
clip: Rect;
|
|
@@ -200,6 +208,8 @@ export interface Strip {
|
|
|
200
208
|
clean: string;
|
|
201
209
|
width: number;
|
|
202
210
|
height: number;
|
|
211
|
+
/** Where the strip starts on its page, in points; absent in older rounds. */
|
|
212
|
+
top?: number;
|
|
203
213
|
page: number;
|
|
204
214
|
label: string;
|
|
205
215
|
text?: TextLine[];
|
|
@@ -400,6 +410,9 @@ export interface RoundDetail extends Omit<RenderedRound, "units"> {
|
|
|
400
410
|
warnings: string[];
|
|
401
411
|
files: string;
|
|
402
412
|
references?: References;
|
|
413
|
+
/** The new version's complete PDF, when the host serves one, so the review
|
|
414
|
+
* can show the changes in the whole paper. */
|
|
415
|
+
pdf?: string;
|
|
403
416
|
}
|
|
404
417
|
export type TaskState =
|
|
405
418
|
"editing" | "building" | "build_failed" | "reviewing" | "revise" | "done";
|