papilot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +10 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +529 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,481 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { run } from "../core/process.ts";
4
+ import { argument, buildOutline } from "../core/outline.ts";
5
+ import { listFiles } from "../core/files.ts";
6
+ import { PdfDocument, type PdfPage, type OutlineEntry } from "./pdf.ts";
7
+ import type {
8
+ BuildInfo,
9
+ Heading,
10
+ Position,
11
+ Span,
12
+ SourceBox,
13
+ } from "../types.ts";
14
+ const MAX_LOOKUPS = 80;
15
+ const BOUNDARY =
16
+ /^\s*\\(?:\[|\]|begin\{|end\{|input\{|include\{|(?:sub)*section\*?\{|chapter\*?\{|paragraph\{)/;
17
+ export type Box = SourceBox;
18
+ export type Intervals = Map<number, Span[]>;
19
+ export class Build {
20
+ document: PdfDocument;
21
+ locator: Locator;
22
+ margins: Margins;
23
+ figures: FigureLocator;
24
+ outline: Heading[];
25
+ constructor(
26
+ public info: BuildInfo,
27
+ public main: string,
28
+ public sourceDir: string,
29
+ ) {
30
+ this.document = new PdfDocument(info.pdf);
31
+ this.margins = new Margins(this.document);
32
+ this.locator = new Locator(this);
33
+ this.outline = buildOutline((n) => this.read(n), main).nodes;
34
+ this.figures = new FigureLocator(this);
35
+ }
36
+ path(name: string) {
37
+ return path.join(
38
+ this.info.root,
39
+ path.posix.relative(this.sourceDir || ".", name),
40
+ );
41
+ }
42
+ read(name: string) {
43
+ const file = this.path(name);
44
+ return fs.existsSync(file) && fs.statSync(file).isFile()
45
+ ? fs.readFileSync(file, "utf8")
46
+ : null;
47
+ }
48
+ close() {
49
+ this.document.close();
50
+ }
51
+ }
52
+ export function sourceRange(
53
+ lines: string[],
54
+ start: number,
55
+ end: number,
56
+ whole = false,
57
+ ): Span {
58
+ if (!lines.length) return [1, 0];
59
+ if (whole) return [1, lines.length];
60
+ start = Math.min(Math.max(1, start), lines.length);
61
+ end = Math.min(Math.max(1, end), lines.length);
62
+ const lower = Math.max(1, start - 60),
63
+ upper = Math.min(lines.length, end + 60);
64
+ while (
65
+ start > lower &&
66
+ lines[start - 2].trim() &&
67
+ !BOUNDARY.test(lines[start - 2])
68
+ )
69
+ start--;
70
+ while (end < upper && lines[end].trim() && !BOUNDARY.test(lines[end])) end++;
71
+ return [start, end];
72
+ }
73
+ export function textBounds(page: PdfPage): Span {
74
+ const blocks = page.blocks.filter((b) => b.text.trim());
75
+ return blocks.length
76
+ ? [
77
+ Math.max(0, Math.min(...blocks.map((b) => b.rect[0])) - 8),
78
+ Math.min(page.width, Math.max(...blocks.map((b) => b.rect[2])) + 8),
79
+ ]
80
+ : [0, page.width];
81
+ }
82
+ const textBlocks = (page: PdfPage) =>
83
+ page.blocks
84
+ .filter((b) => b.text.trim())
85
+ .sort((a, b) => a.rect[1] - b.rect[1]);
86
+ export class Margins {
87
+ header: number | null = null;
88
+ footer: number | null = null;
89
+ constructor(document: PdfDocument) {
90
+ const sample =
91
+ document.length > 40
92
+ ? [
93
+ ...new Set(
94
+ Array.from({ length: 40 }, (_, i) =>
95
+ Math.round((i * (document.length - 1)) / 39),
96
+ ),
97
+ ),
98
+ ]
99
+ : Array.from({ length: document.length }, (_, i) => i),
100
+ tops = new Map<number, number>(),
101
+ bottoms = new Map<number, number>();
102
+ const add = (counts: Map<number, number>, n: number) =>
103
+ counts.set(n, (counts.get(n) ?? 0) + 1);
104
+ for (const number of sample) {
105
+ const blocks = textBlocks(document.page(number));
106
+ if (blocks.length < 2) continue;
107
+ if (blocks[1].rect[1] - blocks[0].rect[3] >= 8)
108
+ add(tops, Math.round(blocks[0].rect[3]));
109
+ if (
110
+ blocks.at(-1)!.rect[1] -
111
+ Math.max(...blocks.slice(0, -1).map((b) => b.rect[3])) >=
112
+ 8
113
+ )
114
+ add(bottoms, Math.round(blocks.at(-1)!.rect[1]));
115
+ }
116
+ const best = (counts: Map<number, number>) =>
117
+ [...counts].sort((a, b) => b[1] - a[1])[0],
118
+ top = best(tops),
119
+ bottom = best(bottoms),
120
+ needed = Math.max(3, 0.3 * sample.length);
121
+ if (top && top[1] >= needed) this.header = top[0] + 1;
122
+ if (bottom && bottom[1] >= needed) this.footer = bottom[0] - 1;
123
+ }
124
+ body(page: PdfPage): Span {
125
+ const blocks = textBlocks(page).filter(
126
+ (b) =>
127
+ !(this.header !== null && b.rect[3] <= this.header + 1) &&
128
+ !(this.footer !== null && b.rect[1] >= this.footer - 1),
129
+ );
130
+ return blocks.length
131
+ ? [
132
+ Math.max(0, Math.min(...blocks.map((b) => b.rect[1])) - 6),
133
+ Math.min(page.height, Math.max(...blocks.map((b) => b.rect[3])) + 6),
134
+ ]
135
+ : [(this.header ?? 36) + 4, (this.footer ?? page.height - 36) - 4];
136
+ }
137
+ }
138
+ export class Locator {
139
+ cache = new Map<string, Box[]>();
140
+ constructor(public build: Build) {}
141
+ lookup(file: string, line: number): Box[] {
142
+ const key = `${file}:${line}`;
143
+ if (this.cache.has(key)) return this.cache.get(key)!;
144
+ const result = run(
145
+ "synctex",
146
+ ["view", "-i", `${line}:0:${file}`, "-o", this.build.info.pdf],
147
+ { check: false },
148
+ ).stdout.toString(),
149
+ records: Box[] = [],
150
+ shipped = new Set<number>(),
151
+ margins = this.build.margins;
152
+ for (const block of result.split(/^Page:/m).slice(1)) {
153
+ const fields = Object.fromEntries(
154
+ [...block.matchAll(/^(x|y|h|v|W|H):([\d.+-]+)/gm)].map((m) => [
155
+ m[1],
156
+ Number(m[2]),
157
+ ]),
158
+ ),
159
+ page = Number(block.split("\n")[0]) - 1,
160
+ { y, v, H: height } = fields;
161
+ if (
162
+ ![page, y, v, height].every(Number.isFinite) ||
163
+ y < v - height - 4 ||
164
+ y > v + 4
165
+ )
166
+ continue;
167
+ if (
168
+ (margins.header !== null && y < margins.header) ||
169
+ (margins.footer !== null && y > margins.footer)
170
+ )
171
+ shipped.add(page);
172
+ else
173
+ records.push([
174
+ page,
175
+ Math.min(y, v - height),
176
+ Math.max(y, v),
177
+ fields.h,
178
+ fields.h + fields.W,
179
+ ]);
180
+ }
181
+ const filtered = records.filter((r) => !shipped.has(r[0]));
182
+ this.cache.set(key, filtered);
183
+ return filtered;
184
+ }
185
+ boxes(name: string, start: number, end: number) {
186
+ const file = this.build.path(name);
187
+ if (!fs.existsSync(file) || path.extname(file) !== ".tex") return [];
188
+ const result: Box[] = [];
189
+ for (
190
+ let line = Math.max(1, start);
191
+ line <= end && line < Math.max(1, start) + MAX_LOOKUPS;
192
+ line++
193
+ )
194
+ result.push(...this.lookup(file, line));
195
+ return result;
196
+ }
197
+ locate(name: string, start: number, end: number, whole = false): Position[] {
198
+ const file = this.build.path(name);
199
+ if (!fs.existsSync(file) || path.extname(file) !== ".tex") return [];
200
+ const lines = fs.readFileSync(file, "utf8").split("\n");
201
+ [start, end] = sourceRange(lines, start, end, whole);
202
+ let wanted = Array.from(
203
+ { length: Math.max(0, end - start + 1) },
204
+ (_, i) => i + start,
205
+ ).filter(
206
+ (n) => lines[n - 1].trim() && !lines[n - 1].trimStart().startsWith("%"),
207
+ );
208
+ if (wanted.length > MAX_LOOKUPS) {
209
+ const step = wanted.length / MAX_LOOKUPS;
210
+ wanted = [
211
+ ...new Set([
212
+ ...Array.from(
213
+ { length: MAX_LOOKUPS },
214
+ (_, i) => wanted[Math.floor(i * step)],
215
+ ),
216
+ wanted[0],
217
+ wanted.at(-1)!,
218
+ ]),
219
+ ].sort((a, b) => a - b);
220
+ }
221
+ const positions = new Map<
222
+ string,
223
+ { page: number; column: number; points: Span[] }
224
+ >();
225
+ for (const n of wanted)
226
+ for (const [page, y0, y1, x0, x1] of this.lookup(file, n))
227
+ if (page >= 0 && page < this.build.document.length) {
228
+ const column =
229
+ !whole && x0 !== undefined && x1 !== undefined
230
+ ? this.build.document.page(page).column([x0, y0, x1, y1])
231
+ : -1;
232
+ const key = `${page}:${column}`;
233
+ if (!positions.has(key))
234
+ positions.set(key, { page, column, points: [] });
235
+ positions.get(key)!.points.push([y0, y1]);
236
+ }
237
+ return [...positions.values()]
238
+ .sort((a, b) => a.page - b.page || a.column - b.column)
239
+ .map(({ page: number, column, points }) => {
240
+ const page = this.build.document.page(number),
241
+ [columnLeft, columnRight] = page.columnBounds(column);
242
+ let top = Math.max(0, Math.min(...points.map((p) => p[0])) - 14),
243
+ bottom = Math.min(
244
+ page.height,
245
+ Math.max(...points.map((p) => p[1])) + 16,
246
+ );
247
+ for (const block of page.blocks)
248
+ if (
249
+ block.rect[1] < bottom &&
250
+ block.rect[3] > top &&
251
+ block.rect[0] < columnRight &&
252
+ block.rect[2] > columnLeft &&
253
+ block.rect[3] - block.rect[1] < 180
254
+ ) {
255
+ top = Math.min(top, block.rect[1] - 8);
256
+ bottom = Math.max(bottom, block.rect[3] + 8);
257
+ }
258
+ const [bodyTop, bodyBottom] = this.build.margins.body(page);
259
+ top = Math.max(
260
+ top,
261
+ Math.min(bodyTop, Math.min(...points.map((p) => p[0]))),
262
+ );
263
+ bottom = Math.min(
264
+ bottom,
265
+ Math.max(bodyBottom, Math.max(...points.map((p) => p[1]))),
266
+ );
267
+ const [left, right] = textBounds(page);
268
+ return {
269
+ page: number,
270
+ clip: [
271
+ Math.max(left, columnLeft),
272
+ Math.max(0, top),
273
+ Math.min(right, columnRight),
274
+ Math.min(page.height, bottom),
275
+ ],
276
+ label: page.label,
277
+ };
278
+ });
279
+ }
280
+ }
281
+ const FLOAT_STRUCTURE =
282
+ /^\s*(%|\\(begin|end)\{(figure|table)\*?\}|\\(caption|label|centering)\b)/;
283
+ export const contentLines = (text: string) =>
284
+ text
285
+ .split("\n")
286
+ .flatMap((line, i) =>
287
+ line.trim() && !FLOAT_STRUCTURE.test(line) ? [i + 1] : [],
288
+ );
289
+ export class FigureLocator {
290
+ labels = new Map<string, [string, string]>();
291
+ constructor(public build: Build) {
292
+ for (const file of listFiles(path.dirname(build.info.pdf)).filter((f) =>
293
+ f.endsWith(".aux"),
294
+ ))
295
+ for (const line of fs.readFileSync(file, "utf8").split("\n"))
296
+ if (line.startsWith("\\newlabel")) {
297
+ const [label, end] = argument(line, "\\newlabel".length),
298
+ [value] = argument(line, end);
299
+ if (!value || !label) continue;
300
+ const fields: string[] = [];
301
+ let i = 0;
302
+ while (i < value.length) {
303
+ const [field, next] = argument(value, i);
304
+ if (field === null) break;
305
+ fields.push(field);
306
+ i = next;
307
+ }
308
+ if (fields.length >= 4)
309
+ this.labels.set(label, [fields[0], fields[3]]);
310
+ }
311
+ }
312
+ locate(name: string): Position[] {
313
+ const text = this.build.read(name);
314
+ if (text === null) return [];
315
+ const result: Position[] = [];
316
+ for (const match of text.matchAll(/\\label\{([^}]+)\}/g)) {
317
+ const [number, anchor] = this.labels.get(match[1]) ?? ["", ""],
318
+ destination = this.build.document.destination(
319
+ "#nameddest=" + encodeURIComponent(anchor),
320
+ );
321
+ if (!destination || !anchor.startsWith("figure.")) continue;
322
+ const page = this.build.document.page(destination.page);
323
+ let top = destination.y;
324
+ const captions = [
325
+ "Figure ",
326
+ "Fig. ",
327
+ "Abbildung ",
328
+ "Figure~",
329
+ "图 ",
330
+ "图",
331
+ ].flatMap((prefix) =>
332
+ [":", ":"].flatMap((colon) => page.search(prefix + number + colon)),
333
+ );
334
+ if (!captions.length) continue;
335
+ const caption = captions.sort(
336
+ (a, b) => Math.abs(a[1] - top) - Math.abs(b[1] - top),
337
+ )[0],
338
+ bottom =
339
+ Math.max(
340
+ caption[3],
341
+ ...page.blocks
342
+ .filter((b) => b.rect[1] < caption[3] && b.rect[3] > caption[1])
343
+ .map((b) => b.rect[3]),
344
+ ) + 10;
345
+ const content = contentLines(text)
346
+ .slice(0, MAX_LOOKUPS)
347
+ .flatMap((line) =>
348
+ this.build.locator.lookup(this.build.path(name), line),
349
+ )
350
+ .filter(
351
+ ([p, y0]) =>
352
+ p === destination.page &&
353
+ caption[1] - 0.8 * page.height < y0 &&
354
+ y0 < caption[1],
355
+ )
356
+ .map(([, y0]) => y0);
357
+ top = Math.min(top, ...content);
358
+ const [left, right] = textBounds(page);
359
+ result.push({
360
+ page: destination.page,
361
+ clip: [left, Math.max(0, top - 6), right, bottom],
362
+ label: page.label,
363
+ });
364
+ }
365
+ return result;
366
+ }
367
+ }
368
+ export function entryFor(node: Heading, toc: OutlineEntry[]) {
369
+ const names = [
370
+ node.kind + "." + node.number,
371
+ ...(["chapter", "section"].includes(node.kind)
372
+ ? ["appendix." + node.number]
373
+ : []),
374
+ ];
375
+ let matches = toc.flatMap((row, i) => (names.includes(row.name) ? [i] : []));
376
+ if (!matches.length)
377
+ matches = toc.flatMap((row, i) =>
378
+ row.title.trim().toLowerCase() === node.title.trim().toLowerCase() ||
379
+ row.title.trim().toLowerCase() ===
380
+ `${node.number} ${node.title}`.trim().toLowerCase()
381
+ ? [i]
382
+ : [],
383
+ );
384
+ if (matches.length !== 1)
385
+ throw new Error(
386
+ `Heading ${node.number} ${JSON.stringify(node.title)} is not uniquely in the PDF outline.`,
387
+ );
388
+ return matches[0];
389
+ }
390
+ export function position(
391
+ document: PdfDocument,
392
+ row: OutlineEntry,
393
+ ): [number, number] {
394
+ if (row.page < 0 || row.page >= document.length)
395
+ throw new Error("The heading is outside this PDF.");
396
+ return [row.page, Math.max(0, row.y - 4)];
397
+ }
398
+ export function addInterval(
399
+ intervals: Intervals,
400
+ page: number,
401
+ top: number,
402
+ bottom: number,
403
+ ) {
404
+ if (bottom - top <= 2) return;
405
+ const merged: Span[] = [];
406
+ for (const [a, b] of [
407
+ ...(intervals.get(page) ?? []),
408
+ [top, bottom] as Span,
409
+ ].sort((a, b) => a[0] - b[0])) {
410
+ const last = merged.at(-1);
411
+ if (last && a <= last[1] + 20) last[1] = Math.max(last[1], b);
412
+ else merged.push([a, b]);
413
+ }
414
+ intervals.set(page, merged);
415
+ }
416
+ export function subtractInterval(
417
+ intervals: Intervals,
418
+ page: number,
419
+ top: number,
420
+ bottom: number,
421
+ ) {
422
+ intervals.set(
423
+ page,
424
+ (intervals.get(page) ?? []).flatMap(([a, b]) =>
425
+ b <= top || a >= bottom
426
+ ? [[a, b] as Span]
427
+ : [
428
+ ...(top - a > 2 ? [[a, top] as Span] : []),
429
+ ...(b - bottom > 2 ? [[bottom, b] as Span] : []),
430
+ ],
431
+ ),
432
+ );
433
+ }
434
+ export function sectionIntervals(
435
+ build: Build,
436
+ node: Heading,
437
+ figureFiles: (file: string) => boolean,
438
+ ): [Intervals, string[]] {
439
+ const document = build.document,
440
+ toc = document.outline,
441
+ index = entryFor(node, toc),
442
+ [startPage, top] = position(document, toc[index]),
443
+ boundary = toc
444
+ .slice(index + 1)
445
+ .find((row) => row.level <= toc[index].level && row.page >= 0),
446
+ [endPage, bottom] = boundary
447
+ ? position(document, boundary)
448
+ : [document.length - 1, document.page(document.length - 1).height - 60],
449
+ intervals: Intervals = new Map();
450
+ for (let page = startPage; page <= endPage; page++) {
451
+ const [bodyTop, bodyBottom] = build.margins.body(document.page(page));
452
+ addInterval(
453
+ intervals,
454
+ page,
455
+ page === startPage ? top : bodyTop,
456
+ page === endPage ? bottom : bodyBottom,
457
+ );
458
+ }
459
+ const own = new Set(node.files.filter(figureFiles));
460
+ const following = boundary
461
+ ? build.outline.find((candidate) => {
462
+ try {
463
+ return toc[entryFor(candidate, toc)] === boundary;
464
+ } catch {
465
+ return false;
466
+ }
467
+ })
468
+ : null;
469
+ if (following)
470
+ for (const file of following.files)
471
+ if (!own.has(file) && figureFiles(file))
472
+ for (const r of build.figures.locate(file))
473
+ subtractInterval(intervals, r.page, r.clip[1], r.clip[3]);
474
+ const missing: string[] = [];
475
+ for (const file of own) {
476
+ const found = build.figures.locate(file);
477
+ if (!found.length) missing.push(file);
478
+ for (const r of found) addInterval(intervals, r.page, r.clip[1], r.clip[3]);
479
+ }
480
+ return [intervals, missing];
481
+ }