papilot 0.8.6 → 0.8.7

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 (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -28,11 +28,12 @@ const conflict = (message: string) => Object.assign(new Error(message), { status
28
28
 
29
29
  async function engine() {
30
30
  const load = (file: string) => import(pathToFileURL(path.join(ENGINE, file)).href);
31
- const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, revertUnits, withUnits, splitLines, textOf }, { opcodes }, { render, more }] = await Promise.all(
31
+ const [{ Builder, BuildError }, { Repo }, { loadProject, isFigure }, { diffUnits, diffUnitsAsync, revertUnits, withUnits, splitLines, textOf }, { opcodes }, { render, more }] = await Promise.all(
32
32
  ['src/core/build.ts', 'src/core/git.ts', 'src/core/config.ts', 'src/core/changes.ts', 'src/core/diff.ts', 'src/core/publish.ts'].map(load));
33
- return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, revertUnits, withUnits, splitLines, textOf, opcodes, render, more } as {
33
+ return { Builder, BuildError, Repo, loadProject, isFigure, diffUnits, diffUnitsAsync, revertUnits, withUnits, splitLines, textOf, opcodes, render, more } as {
34
34
  Builder: any; BuildError: any; Repo: any; loadProject: (root: string) => any; isFigure: (project: any, file: string) => boolean;
35
35
  diffUnits: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Unit[];
36
+ diffUnitsAsync: (repo: any, before: string, after: string, isFigure: (file: string) => boolean) => Promise<Unit[]>;
36
37
  revertUnits: (old: Buffer | null, next: Buffer | null, units: Unit[]) => Buffer | null;
37
38
  withUnits: (repo: any, before: string, after: string, units: Unit[]) => string;
38
39
  splitLines: (text: string) => string[]; textOf: (data: Buffer | null) => string | null;
@@ -96,7 +97,8 @@ export class Changes {
96
97
  if (!fresh && comparison.snapshot && Date.now() - comparison.snapshot.at < 1000) return comparison.snapshot.value;
97
98
  const value = (async () => {
98
99
  const { Repo } = await engine(), repo = new Repo(root);
99
- return { before: repo.hasHead() ? repo.treeId('HEAD') : null, after: await workingTree(root) };
100
+ const [head, after] = await Promise.all([repo.textAsync(['rev-parse', '--verify', '--quiet', 'HEAD^{tree}'], { check: false }), workingTree(root)]);
101
+ return { before: head || null, after };
100
102
  })();
101
103
  comparison.snapshot = { at: Date.now(), value };
102
104
  value.catch(() => { if (comparison.snapshot?.value === value) comparison.snapshot = undefined; });
@@ -108,8 +110,8 @@ export class Changes {
108
110
  private async units(projectId: string, root: string, snapshot: Snapshot) {
109
111
  const comparison = this.comparison(projectId), key = `${snapshot.before}:${snapshot.after}`;
110
112
  if (comparison.current?.key === key) return comparison.current.units;
111
- const { Repo, loadProject, isFigure, diffUnits } = await engine(), project = loadProject(root);
112
- const units = diffUnits(new Repo(root), snapshot.before!, snapshot.after, file => isFigure(project, file)).filter(unit => !(unit.status === 'added' && ignorePattern(unit.file)));
113
+ const { Repo, loadProject, isFigure, diffUnitsAsync } = await engine(), project = loadProject(root);
114
+ const units = (await diffUnitsAsync(new Repo(root), snapshot.before!, snapshot.after, file => isFigure(project, file))).filter(unit => !(unit.status === 'added' && ignorePattern(unit.file)));
113
115
  comparison.current = { key, units };
114
116
  return units;
115
117
  }
@@ -118,7 +120,7 @@ export class Changes {
118
120
  private async staged(root: string, snapshot: Snapshot, units: Unit[]) {
119
121
  const { Repo, splitLines, textOf, opcodes } = await engine(), repo = new Repo(root);
120
122
  const files = [...new Set(units.map(unit => unit.file))];
121
- const work = repo.blobs(snapshot.after, files), index = repo.blobs('', files);
123
+ const [work, index] = await Promise.all([repo.blobsAsync(snapshot.after, files), repo.blobsAsync('', files)]);
122
124
  const staged = new Set<string>();
123
125
  for (const unit of units) {
124
126
  const ready = unit.whole_file ? same(index[unit.file], work[unit.file])
@@ -147,7 +149,10 @@ export class Changes {
147
149
  try { return await builder.build(tree, ['full']); }
148
150
  catch (error) { throw error instanceof BuildError ? Object.assign(new Error(what), { details: (error as any).details, log: (error as any).log }) : error; }
149
151
  };
150
- const old = await build(snapshot.before!, 'The last commit could not be compiled.'), next = await build(snapshot.after, 'The working tree could not be compiled.');
152
+ // The two versions compile at once; a failure is reported once both settle.
153
+ const builds = [build(snapshot.before!, 'The last commit could not be compiled.'), build(snapshot.after, 'The working tree could not be compiled.')];
154
+ await Promise.allSettled(builds);
155
+ const old = await builds[0], next = await builds[1];
151
156
  job.phase = 'render';
152
157
  // The engine compiles the commit with some of the changes when it must
153
158
  // tell which of them changes the PDF elsewhere, such as a macro's uses.
@@ -68,6 +68,7 @@ export class CursorAgent extends Adapter {
68
68
  try { this.ids = JSON.parse(fs.readFileSync(this.file, 'utf8')); } catch { /* None yet. */ }
69
69
  }
70
70
  detect() { return this.configured(); }
71
+ async preload() { if (packageFile('@cursor/sdk')) await this.load(); }
71
72
  async start() {
72
73
  if (this.ready) return;
73
74
  const sdk = await this.load();
@@ -7,11 +7,12 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { promisify } from 'node:util';
9
9
  import { pathToFileURL } from 'node:url';
10
- import { ENGINE } from './paths.ts';
10
+ import { ENGINE, ROOT } from './paths.ts';
11
11
  import type { Annotation, BuildProblem, BuildProgress, GitFile, GitStatus, ManuscriptBuild, ManuscriptProblems } from '../shared.ts';
12
12
  import { notesInstructions } from '../notes.ts';
13
13
  import { ignorePattern } from '../commit.ts';
14
14
  import { AnnotationPages, placeAnnotation } from './annotation-placement.ts';
15
+ import { annotatePdf, type ExportNote } from './annotated-pdf.ts';
15
16
  import { collectProblems, countProblems, locateProblems, scrubLog, sortProblems, type StoredProblem } from './build-problems.ts';
16
17
 
17
18
  type Rect = [number, number, number, number];
@@ -248,10 +249,9 @@ async function gitAction(root: string, args: string[], timeout = 60_000) {
248
249
  // whose size or modification time changed.
249
250
  export async function workingTree(root: string) {
250
251
  const dir = gitDir(root), saved = dir && path.join(dir, 'latex-review', 'working-index');
251
- const folder = fs.mkdtempSync(path.join(os.tmpdir(), 'papilot-index-')), index = path.join(folder, 'index'), env = { GIT_INDEX_FILE: index };
252
+ const folder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'papilot-index-')), index = path.join(folder, 'index'), env = { GIT_INDEX_FILE: index };
252
253
  const snapshot = async (seed: string | null | undefined) => {
253
- if (seed && fs.existsSync(seed)) fs.copyFileSync(seed, index);
254
- else { fs.rmSync(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
254
+ if (!seed || !await copyIndex(seed, index)) { await fs.promises.rm(index, { force: true }); await git(root, ['read-tree', 'HEAD'], env).catch(() => git(root, ['read-tree', '--empty'], env)); }
255
255
  await git(root, ['add', '-A'], env);
256
256
  return (await git(root, ['write-tree'], env)).trim();
257
257
  };
@@ -259,23 +259,52 @@ export async function workingTree(root: string) {
259
259
  let tree: string;
260
260
  try { tree = await snapshot(saved && fs.existsSync(saved) ? saved : dir && path.join(dir, 'index')); }
261
261
  catch { tree = await snapshot(null); }
262
- if (saved) try {
263
- fs.mkdirSync(path.dirname(saved), { recursive: true });
262
+ if (saved) {
264
263
  const temporary = `${saved}.${randomUUID()}.tmp`;
265
- fs.copyFileSync(index, temporary); fs.renameSync(temporary, saved);
266
- } catch { /* The next snapshot rehashes more files. */ }
264
+ try {
265
+ await fs.promises.mkdir(path.dirname(saved), { recursive: true });
266
+ if (await copyIndex(index, temporary)) await fs.promises.rename(temporary, saved);
267
+ } catch { /* The next snapshot rehashes more files. */ }
268
+ finally { await fs.promises.rm(temporary, { force: true }); }
269
+ }
267
270
  return tree;
268
- } finally { fs.rmSync(folder, { recursive: true, force: true }); }
271
+ } finally { await fs.promises.rm(folder, { recursive: true, force: true }); }
272
+ }
273
+ // Copies an index and keeps its modification time, which Git compares with
274
+ // each file's to catch edits made in the instant the index was written.
275
+ async function copyIndex(from: string, to: string) {
276
+ try {
277
+ const stat = await fs.promises.stat(from);
278
+ await fs.promises.copyFile(from, to);
279
+ await fs.promises.utimes(to, stat.atime, stat.mtime);
280
+ return true;
281
+ } catch { return false; }
282
+ }
283
+
284
+
285
+ // Each page's size in points. A long paper takes tens of milliseconds, so
286
+ // the event loop answers other requests every few pages.
287
+ async function pageSizes(mupdf: any, pdf: string) {
288
+ const document = mupdf.Document.openDocument(await fs.promises.readFile(pdf), 'application/pdf'), pages: [number, number][] = [];
289
+ try {
290
+ for (let i = 0; i < document.countPages(); i++) {
291
+ if (i && i % 32 === 0) await new Promise(resolve => setImmediate(resolve));
292
+ const page = document.loadPage(i);
293
+ try { const [x0, y0, x1, y1] = page.getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
294
+ finally { page.destroy(); }
295
+ }
296
+ } finally { document.destroy(); }
297
+ return pages;
269
298
  }
270
299
 
271
300
  async function engine() {
272
- const [{ Builder }, { Repo }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
301
+ const [{ Builder }, { Repo, gitBinary }, { loadProject }, { mupdf, PdfDocument, containsCenter, walkText }] = await Promise.all([
273
302
  import(pathToFileURL(path.join(ENGINE, 'src/core/build.ts')).href),
274
303
  import(pathToFileURL(path.join(ENGINE, 'src/core/git.ts')).href),
275
304
  import(pathToFileURL(path.join(ENGINE, 'src/core/config.ts')).href),
276
305
  import(pathToFileURL(path.join(ENGINE, 'src/render/pdf.ts')).href),
277
306
  ]);
278
- return { Builder, Repo, loadProject, mupdf, PdfDocument, containsCenter, walkText };
307
+ return { Builder, Repo, gitBinary, loadProject, mupdf, PdfDocument, containsCenter, walkText };
279
308
  }
280
309
 
281
310
  // `synctex edit` maps a PDF position (big points from the top-left corner) to
@@ -391,60 +420,157 @@ export class Manuscripts extends EventEmitter {
391
420
  private placements = new Map<string, (Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'>) | null>();
392
421
  // Placed problems per problem list; a rebuild in progress shares its predecessor's list.
393
422
  private located = new WeakMap<StoredProblem[], Promise<BuildProblem[]>>();
423
+ // Papers whose working tree changed while their manuscript compiled: they
424
+ // compile again as soon as that build ends.
425
+ private stale = new Set<string>();
426
+ // One working-tree snapshot per paper at a time; a request made meanwhile
427
+ // waits for the next one, which sees every edit made before it was asked.
428
+ private snapshots = new Map<string, { current: Promise<string>; next?: Promise<string> }>();
429
+ // Per paper, the speculative build that is running and the one wanted next.
430
+ private speculation = new Map<string, { running: boolean; wanted?: { root: string; files: Record<string, Buffer | null> } }>();
431
+ // Every open event stream listens for builds.
432
+ constructor() { super(); this.setMaxListeners(0); }
433
+ /** Stops every compiler this process started, also paused ones, so that
434
+ * nothing keeps running after Papilot quits. */
435
+ async stop() {
436
+ const { stopAll } = await import(pathToFileURL(path.join(ENGINE, 'src/core/process.ts')).href);
437
+ stopAll();
438
+ }
439
+ /** Loads the PDF engine and reads Papilot's example paper with it once,
440
+ * before the server answers requests: compiling its WebAssembly and
441
+ * setting up color conversion take a few hundred milliseconds the first
442
+ * time, which would otherwise hold up the first PDF someone opens. */
443
+ async warm() {
444
+ try {
445
+ const { PdfDocument, gitBinary } = await engine(), sample = path.join(ROOT, 'examples/papilot-paper/preview/main.pdf');
446
+ gitBinary();
447
+ if (!fs.existsSync(sample)) return;
448
+ const document = new PdfDocument(sample);
449
+ try { document.page(0); } finally { document.close(); }
450
+ } catch { /* The first build pays for it instead. */ }
451
+ }
394
452
  get busyForUpdate() { return [...this.builds.values()].some(build => build.status === 'building'); }
395
453
  busyForProject(id: string) { return this.builds.get(id)?.status === 'building'; }
396
- forgetProject(id: string) { this.builds.delete(id); }
454
+ forgetProject(id: string) { this.builds.delete(id); this.stale.delete(id); this.speculation.delete(id); }
397
455
 
398
456
  // The caller verifies the example's source tree. An edit or explicit rebuild
399
457
  // uses Builder; the original PDF can be read before TeX is installed.
400
458
  async seedPreview(projectId: string, root: string, tree: string, pdf: string) {
401
- const { mupdf } = await engine(), bytes = fs.readFileSync(pdf);
402
- const document = mupdf.Document.openDocument(bytes, 'application/pdf');
403
- const pages: [number, number][] = [];
404
- try {
405
- for (let i = 0; i < document.countPages(); i++) {
406
- const page = document.loadPage(i);
407
- try { const [x0, y0, x1, y1] = page.getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
408
- finally { page.destroy(); }
409
- }
410
- } finally { document.destroy(); }
459
+ const { mupdf } = await engine(), bytes = await fs.promises.readFile(pdf), pages = await pageSizes(mupdf, pdf);
411
460
  const key = createHash('sha256').update(tree).update(bytes).digest('hex').slice(0, 16);
412
461
  this.builds.set(projectId, { tree, status: 'ready', key, pdf, root, sourceDir: '', pages,
413
- builtAt: fs.statSync(pdf).mtimeMs, problems: [], problemsKey: key });
462
+ builtAt: (await fs.promises.stat(pdf)).mtimeMs, problems: [], problemsKey: key });
463
+ }
464
+
465
+ // The working tree as a Git tree. Requests made while a snapshot runs share
466
+ // the next one, which starts when it ends and so sees every edit made
467
+ // before they asked.
468
+ private snapshot(root: string): Promise<string> {
469
+ const entry = this.snapshots.get(root);
470
+ if (entry) return entry.next ??= entry.current.then(() => undefined, () => undefined).then(() => this.takeSnapshot(root));
471
+ return this.takeSnapshot(root);
472
+ }
473
+ private takeSnapshot(root: string) {
474
+ const current = workingTree(root);
475
+ this.snapshots.set(root, { current });
476
+ void current.then(() => undefined, () => undefined).then(() => {
477
+ const entry = this.snapshots.get(root);
478
+ if (entry?.current === current && !entry.next) this.snapshots.delete(root);
479
+ });
480
+ return current;
481
+ }
482
+
483
+ // The view polls every second and a half while a build runs; Git's status
484
+ // costs a walk of the working tree, which competes with TeX, so it is
485
+ // read again at most every few seconds then, and after Papilot's own writes.
486
+ private statuses = new Map<string, { at: number; value: Promise<GitStatus> }>();
487
+ private gitStatus(root: string, building: boolean) {
488
+ const cached = this.statuses.get(root);
489
+ if (building && cached && Date.now() - cached.at < 5000) return cached.value;
490
+ const value = gitStatus(root);
491
+ this.statuses.set(root, { at: Date.now(), value });
492
+ value.catch(() => { if (this.statuses.get(root)?.value === value) this.statuses.delete(root); });
493
+ return value;
414
494
  }
415
495
 
416
496
  async state(projectId: string, root: string, rebuild = false): Promise<{ git: GitStatus; build: ManuscriptBuild }> {
417
- const current = this.builds.get(projectId);
497
+ const current = this.builds.get(projectId), building = current?.status === 'building';
418
498
  // Polling an in-flight build needs its status, not another source snapshot.
419
499
  // Once it finishes, the next read checks for edits made during compilation.
420
- const [status, tree] = await Promise.all([gitStatus(root), current?.status === 'building' ? current.tree : workingTree(root)]);
500
+ const [status, tree] = await Promise.all([this.gitStatus(root, building), building ? current.tree : this.snapshot(root)]);
421
501
  let build = this.builds.get(projectId);
422
502
  if (!build || (build.status !== 'building' && (build.tree !== tree || rebuild))) build = this.start(projectId, root, tree, build, rebuild);
423
503
  const { status: state, key, pages, builtAt, error } = build;
424
504
  return { git: status, build: { status: state, key, pages, builtAt, error, current: build.tree === tree && state === 'ready', problems: build.problems && countProblems(build.problems), ...(state === 'building' && build.progress ? { progress: build.progress } : {}) } };
425
505
  }
426
506
 
507
+ /** Compiles the working tree if it changed, without waiting for anyone to
508
+ * look: after a commit, an applied task or a Git action, the PDF is on its
509
+ * way before the manuscript is opened. */
510
+ async refresh(projectId: string, root: string) {
511
+ this.statuses.delete(root);
512
+ if (this.builds.get(projectId)?.status === 'building') { this.stale.add(projectId); return; }
513
+ const tree = await this.snapshot(root), build = this.builds.get(projectId);
514
+ if (build?.status === 'building') { if (build.tree !== tree) this.stale.add(projectId); return; }
515
+ if (!build || build.tree !== tree) this.start(projectId, root, tree, build);
516
+ }
517
+
518
+ /** Compiles in the background what the manuscript will be once the given
519
+ * files are written into the working tree, such as a reviewed task that is
520
+ * about to be applied. When that happens, the build of the working tree is
521
+ * the same build, so its PDF is ready or already on its way. A build
522
+ * someone waits for always runs first. */
523
+ async predict(projectId: string, root: string, files: Record<string, Buffer | null>) {
524
+ let entry = this.speculation.get(projectId);
525
+ if (!entry) this.speculation.set(projectId, entry = { running: false });
526
+ entry.wanted = { root, files };
527
+ if (entry.running) return;
528
+ entry.running = true;
529
+ try {
530
+ while (entry.wanted && this.speculation.get(projectId) === entry) {
531
+ const wanted: { root: string; files: Record<string, Buffer | null> } = entry.wanted;
532
+ entry.wanted = undefined;
533
+ try {
534
+ const { Builder, Repo, loadProject } = await engine(), repo = new Repo(wanted.root);
535
+ const tree = await this.snapshot(wanted.root), predicted = await repo.treeWithAsync(tree, wanted.files);
536
+ if (predicted === tree) continue;
537
+ await new Builder(loadProject(wanted.root), repo).build(predicted, ['full'], null, { priority: 'background' });
538
+ } catch { /* A build that fails here fails again, and is reported, when it is needed. */ }
539
+ }
540
+ } finally { entry.running = false; }
541
+ }
542
+
427
543
  // Keep the previous pages visible while a newer working tree compiles.
428
544
  private start(projectId: string, root: string, tree: string, previous?: Build, force = false) {
429
545
  if (previous?.key && previous.pdf) this.oldPdfs.set(`${root}:${previous.key}`, previous.pdf);
430
546
  const build: Build = { tree, status: 'building', key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt, problems: previous?.problems, problemsKey: previous?.problemsKey, log: previous?.log, logFolder: previous?.logFolder };
431
547
  this.builds.set(projectId, build);
548
+ this.stale.delete(projectId);
549
+ this.emit('change', projectId);
432
550
  void (async () => {
433
551
  let project: { source_dir?: string; doc_dir?: string; state: string } | undefined;
434
552
  try {
435
553
  const { Builder, Repo, loadProject, mupdf } = await engine();
436
554
  project = loadProject(root);
437
555
  const startedAt = Date.now();
438
- const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
439
- const document = mupdf.Document.openDocument(fs.readFileSync(result.pdf), 'application/pdf');
440
- const pages: [number, number][] = [];
441
- for (let i = 0; i < document.countPages(); i++) { const [x0, y0, x1, y1] = document.loadPage(i).getBounds(); pages.push([Math.round(x1 - x0), Math.round(y1 - y0)]); }
442
- document.destroy?.();
556
+ const sizes = (pdf: string) => pageSizes(mupdf, pdf);
557
+ // A pass that latexmk follows with more work already shows the edit;
558
+ // its pages replace the previous PDF while references settle.
559
+ const pass = async (preview: { pdf: string; root: string }) => {
560
+ if (this.builds.get(projectId) !== build || build.status !== 'building') return;
561
+ const pages = await sizes(preview.pdf), key = createHash('sha256').update(preview.pdf).digest('hex').slice(0, 16);
562
+ if (this.builds.get(projectId) !== build || build.status !== 'building') return;
563
+ this.oldPdfs.set(`${root}:${key}`, preview.pdf);
564
+ Object.assign(build, { key, pdf: preview.pdf, root: preview.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now() });
565
+ this.emit('change', projectId);
566
+ };
567
+ const result = await new Builder(project, new Repo(root)).build(tree, ['full'], null, { force, priority: 'foreground', pass: (preview: { pdf: string; root: string }) => void pass(preview).catch(() => undefined), progress: (progress: Omit<BuildProgress, 'startedAt'>) => { if (this.builds.get(projectId) === build) build.progress = { ...progress, startedAt }; } });
568
+ const pages = await sizes(result.pdf);
443
569
  const key = createHash('sha256').update(result.key + result.pdf).digest('hex').slice(0, 16);
444
570
  if (this.builds.get(projectId) !== build) return;
445
571
  const log = String(result.pdf).replace(/\.pdf$/, '.log');
446
572
  let problems: StoredProblem[] = [];
447
- try { problems = collectProblems(fs.readFileSync(log), { cwd: result.cwd, root: result.root, sourceDir: project!.source_dir || '', outDir: path.dirname(result.pdf) }); } catch { /* A build without a readable log reports no problems. */ }
573
+ try { problems = collectProblems(await fs.promises.readFile(log), { cwd: result.cwd, root: result.root, sourceDir: project!.source_dir || '', outDir: path.dirname(result.pdf) }); } catch { /* A build without a readable log reports no problems. */ }
448
574
  Object.assign(build, { status: 'ready', key, pdf: result.pdf, root: result.root, sourceDir: project!.source_dir || '', pages, builtAt: Date.now(), error: undefined, problems, problemsKey: key, log: fs.existsSync(log) ? log : undefined, logFolder: path.dirname(result.root) });
449
575
  // Place the problems now, so the list is ready when someone opens it.
450
576
  if (problems.length) void this.problems(projectId).catch(() => undefined);
@@ -456,7 +582,7 @@ export class Manuscripts extends EventEmitter {
456
582
  // build; its sources are gone, so files resolve in the working tree.
457
583
  let problems: StoredProblem[] = [];
458
584
  if (detail.log && project) try {
459
- const kept = fs.readFileSync(detail.log), marker = Buffer.from('\n\n--- TeX log ---\n'), at = kept.indexOf(marker);
585
+ const kept = await fs.promises.readFile(detail.log), marker = Buffer.from('\n\n--- TeX log ---\n'), at = kept.indexOf(marker);
460
586
  // TeX stopped in its first pass, before references resolve, so only
461
587
  // the errors of a failed run mean anything.
462
588
  if (at >= 0) problems = collectProblems(kept.subarray(at + marker.length), { cwd: path.join(root, project.doc_dir || '.'), root: path.join(root, project.source_dir || '.'), sourceDir: project.source_dir || '' })
@@ -468,11 +594,15 @@ export class Manuscripts extends EventEmitter {
468
594
  const unexplained = (detail.details || []).filter(line => line.trim() && !/==> Fatal error occurred|^Emergency stop/.test(line) && !problems.some(problem => plain(line).includes(plain(problem.message).replace(/^[^:]+:\d+: /, ''))));
469
595
  const logFolder = detail.log && project ? path.join(project.state, 'cache/builds', path.basename(detail.log, '.log')) : undefined;
470
596
  if (!problems.length || unexplained.length) problems.push({ kind: 'error', severity: 'error', message: scrubLog([...(problems.length ? [] : [detail.message || 'The manuscript could not be compiled.']), ...unexplained].join('\n'), logFolder) });
471
- // The previous PDF stays on screen, and so do its warnings and places.
597
+ // The previous PDF stays on screen, and so do its warnings and places;
598
+ // a pass of this build that was shown went with its folder.
599
+ Object.assign(build, { key: previous?.key, pdf: previous?.pdf, root: previous?.root, sourceDir: previous?.sourceDir, pages: previous?.pages, builtAt: previous?.builtAt });
472
600
  const shown = build.problemsKey && build.problemsKey === build.key ? (build.problems || []).filter(problem => problem.severity !== 'error') : [];
473
601
  Object.assign(build, { status: 'failed', error: message, problems: [...problems, ...shown], problemsKey: shown.length ? build.problemsKey : undefined, log: detail.log && fs.existsSync(detail.log) ? detail.log : undefined, logFolder });
474
602
  }
475
603
  this.emit('change', projectId);
604
+ // Edits made while this build ran are compiled next, right away.
605
+ if (this.stale.delete(projectId)) void this.refresh(projectId, root).catch(() => undefined);
476
606
  })();
477
607
  return build;
478
608
  }
@@ -482,35 +612,52 @@ export class Manuscripts extends EventEmitter {
482
612
  async reanchor(projectId: string, root: string, notes: Annotation[]): Promise<Annotation[]> {
483
613
  const build = this.builds.get(projectId);
484
614
  if (!build?.pdf || !build.key || build.status !== 'ready' || !notes.some(note => note.key !== build.key)) return notes;
615
+ // Every poll asks again; placements already checked need no PDF opened.
616
+ const cacheKey = (note: Annotation) => JSON.stringify([projectId, build.key, note.key, note.page, note.rect, note.rects, note.quote]);
617
+ if (notes.every(note => note.key === build.key || this.placements.has(cacheKey(note))))
618
+ return notes.map(note => { const placement = note.key === build.key ? null : this.placements.get(cacheKey(note)); return placement ? { ...note, ...placement } : note; });
619
+ // Placing reads every page of a long PDF between other requests; a poll
620
+ // that arrives meanwhile shares the work instead of repeating it.
621
+ const batch = JSON.stringify([projectId, build.key, notes.map(note => cacheKey(note))]);
622
+ let placing = this.reanchoring.get(batch);
623
+ if (!placing) {
624
+ placing = this.place(projectId, root, notes, build.pdf, build.key, cacheKey);
625
+ this.reanchoring.set(batch, placing);
626
+ void placing.then(() => undefined, () => undefined).then(() => this.reanchoring.delete(batch));
627
+ }
628
+ return placing;
629
+ }
630
+ private reanchoring = new Map<string, Promise<Annotation[]>>();
631
+ private async place(projectId: string, root: string, notes: Annotation[], pdfFile: string, buildKey: string, cacheKey: (note: Annotation) => string): Promise<Annotation[]> {
485
632
  const { PdfDocument } = await engine();
486
- const current = new PdfDocument(build.pdf), target = new AnnotationPages(current);
633
+ const current = new PdfDocument(pdfFile), target = new AnnotationPages(current);
487
634
  const old = new Map<string, { document: InstanceType<typeof PdfDocument>; pages: AnnotationPages }>();
488
635
  try {
489
636
  const output: Annotation[] = [];
490
637
  for (const note of notes) {
491
- if (note.key === build.key) { output.push(note); continue; }
492
- const cacheKey = JSON.stringify([projectId, build.key, note.key, note.page, note.rect, note.rects, note.quote]);
493
- if (!this.placements.has(cacheKey)) {
638
+ if (note.key === buildKey) { output.push(note); continue; }
639
+ const key = cacheKey(note);
640
+ if (!this.placements.has(key)) {
494
641
  let placement: Pick<Annotation, 'key' | 'page' | 'rect' | 'rects' | 'source'> | null = null;
495
642
  try {
496
643
  const pdf = await this.cachedPdf(root, note.key);
497
644
  if (pdf) {
498
645
  if (!old.has(pdf)) { const document = new PdfDocument(pdf); old.set(pdf, { document, pages: new AnnotationPages(document) }); }
499
- const match = placeAnnotation(note, old.get(pdf)!.pages, target);
646
+ const match = await placeAnnotation(note, old.get(pdf)!.pages, target);
500
647
  if (match) {
501
- const { source } = await this.locate(projectId, build.key, match.page, match.rect, match.rects, note.quote);
502
- placement = { ...match, key: build.key, source };
648
+ const { source } = await this.locate(projectId, buildKey, match.page, match.rect, match.rects, note.quote);
649
+ placement = { ...match, key: buildKey, source };
503
650
  }
504
651
  }
505
652
  } catch { /* Keep the annotation intact when its position cannot be verified. */ }
506
- this.placements.set(cacheKey, placement);
653
+ this.placements.set(key, placement);
507
654
  }
508
- const placement = this.placements.get(cacheKey);
655
+ const placement = this.placements.get(key);
509
656
  output.push(placement ? { ...note, ...placement } : note);
510
657
  }
511
658
  if (this.placements.size > 2000) this.placements.clear();
512
659
  if (this.oldPdfs.size > 500) this.oldPdfs.clear();
513
- return this.builds.get(projectId)?.key === build.key ? output : notes;
660
+ return this.builds.get(projectId)?.key === buildKey ? output : notes;
514
661
  } finally { current.close(); for (const entry of old.values()) entry.document.close(); }
515
662
  }
516
663
 
@@ -525,14 +672,21 @@ export class Manuscripts extends EventEmitter {
525
672
  if (!placed) {
526
673
  placed = (async () => {
527
674
  const { PdfDocument } = await engine();
528
- const document = new PdfDocument(pdf), pages = new Map<number, ReturnType<typeof document.page>>();
675
+ // Most problems need only a page's size; its text, which takes up to
676
+ // a hundred milliseconds to read on a busy page, is read when a
677
+ // missing reference or citation is searched for on it.
678
+ const document = new PdfDocument(pdf), pages = new Map<number, { destroy(): void; getBounds(): number[]; search(needle: string, options: object): number[][][] }>();
529
679
  try {
530
680
  return sortProblems(await locateProblems(problems, pdf, page => {
531
681
  if (!Number.isInteger(page) || page < 0 || page >= document.length) return null;
532
- if (!pages.has(page)) pages.set(page, document.page(page));
533
- return pages.get(page)!;
682
+ if (!pages.has(page)) pages.set(page, document.raw.loadPage(page));
683
+ const raw = pages.get(page)!, bounds = raw.getBounds();
684
+ return {
685
+ bounds, width: bounds[2] - bounds[0], height: bounds[3] - bounds[1],
686
+ search: (needle: string) => raw.search(needle, {}).flat().map(q => [Math.min(q[0], q[2], q[4], q[6]), Math.min(q[1], q[3], q[5], q[7]), Math.max(q[0], q[2], q[4], q[6]), Math.max(q[1], q[3], q[5], q[7])]),
687
+ };
534
688
  }));
535
- } finally { for (const page of pages.values()) page.close(); document.close(); }
689
+ } finally { for (const page of pages.values()) page.destroy(); document.close(); }
536
690
  })();
537
691
  placed.catch(() => this.located.delete(problems));
538
692
  this.located.set(problems, placed);
@@ -580,14 +734,16 @@ export class Manuscripts extends EventEmitter {
580
734
  const { loadProject } = await engine();
581
735
  const cache = path.join(loadProject(root).state, 'cache/builds');
582
736
  let found: string | null = null;
583
- if (fs.existsSync(cache)) for (const entry of fs.readdirSync(cache, { withFileTypes: true })) {
584
- if (!entry.isDirectory() || entry.name.endsWith('.lock')) continue;
585
- try {
586
- const folder = path.join(cache, entry.name), saved = JSON.parse(fs.readFileSync(path.join(folder, 'result.json'), 'utf8'));
587
- if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) continue;
588
- if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === key && fs.existsSync(saved.pdf)) { found = saved.pdf; break; }
589
- } catch { /* Incomplete builds have no usable PDF. */ }
590
- }
737
+ const entries = await fs.promises.readdir(cache, { withFileTypes: true }).catch(() => []);
738
+ for (let at = 0; at < entries.length && !found; at += 32)
739
+ await Promise.all(entries.slice(at, at + 32).map(async entry => {
740
+ if (!entry.isDirectory() || entry.name.endsWith('.lock')) return;
741
+ try {
742
+ const folder = path.join(cache, entry.name), saved = JSON.parse(await fs.promises.readFile(path.join(folder, 'result.json'), 'utf8'));
743
+ if (typeof saved.pdf !== 'string' || !path.resolve(saved.pdf).startsWith(path.resolve(folder) + path.sep)) return;
744
+ if (createHash('sha256').update(saved.key + saved.pdf).digest('hex').slice(0, 16) === key && fs.existsSync(saved.pdf)) found = saved.pdf;
745
+ } catch { /* Incomplete builds have no usable PDF. */ }
746
+ }));
591
747
  this.oldPdfs.set(lookup, found); return found;
592
748
  }
593
749
 
@@ -597,7 +753,7 @@ export class Manuscripts extends EventEmitter {
597
753
  const key = state.build.key, { PdfDocument } = await engine();
598
754
  const original = new PdfDocument(pdf), current = new PdfDocument(this.pdf(projectId, key));
599
755
  try {
600
- const match = placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
756
+ const match = await placeAnnotation(note, new AnnotationPages(original), new AnnotationPages(current));
601
757
  if (!match) throw new Error('This passage could not be located reliably in the current manuscript. The suggestion remains in the review task.');
602
758
  const { source } = await this.locate(projectId, key, match.page, match.rect, match.rects, note.quote);
603
759
  const latest = await this.state(projectId, root);
@@ -639,6 +795,12 @@ export class Manuscripts extends EventEmitter {
639
795
  return build.pdf;
640
796
  }
641
797
 
798
+ // The PDF with the paper's notes drawn on it, to share with people outside Papilot.
799
+ async annotatedPdf(projectId: string, key: string, notes: ExportNote[]) {
800
+ const pdf = this.pdf(projectId, key), { mupdf } = await engine();
801
+ return annotatePdf(mupdf, fs.readFileSync(pdf), notes);
802
+ }
803
+
642
804
  // A note keeps the text under the marked region and its LaTeX source, so it
643
805
  // stays meaningful after the manuscript changes and the preview recompiles.
644
806
  // A text selection arrives as one rectangle per line plus the selected text;