papilot 0.0.0-stage → 0.1.1

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 +11 -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 +530 -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,805 @@
1
+ import fs from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+ import { opcodes as compare, similarity } from "../core/diff.ts";
5
+ import type {
6
+ Block,
7
+ Card,
8
+ Heading,
9
+ Mark,
10
+ Op,
11
+ Pair,
12
+ Position,
13
+ Rect,
14
+ Span,
15
+ Strip,
16
+ } from "../types.ts";
17
+ import { Build, sectionIntervals, type Box, type Intervals } from "./locate.ts";
18
+ import { containsCenter, intersect, intersects, PdfDocument } from "./pdf.ts";
19
+ import { textChanges } from "./pdfdiff.ts";
20
+ const RUN_GAP = 3,
21
+ JOIN_GAP = 90,
22
+ MARGIN = 900;
23
+ export interface Row {
24
+ page: number;
25
+ rect: Rect;
26
+ text: string;
27
+ column?: number;
28
+ }
29
+ type Region = [number, number, number, number];
30
+ type PairRef = [number, number];
31
+ interface Group {
32
+ top: number;
33
+ bottom: number;
34
+ pairs: PairRef[];
35
+ }
36
+ interface Piece {
37
+ kind: "context" | "change";
38
+ page: number;
39
+ top: number;
40
+ bottom: number;
41
+ pairs?: PairRef[];
42
+ parts?: Box[];
43
+ }
44
+ type Planned = Piece | { kind: "gap" };
45
+ export function textRows(document: PdfDocument, intervals: Intervals): Row[] {
46
+ const rows: Row[] = [];
47
+ for (const [page, spans] of [...intervals].sort((a, b) => a[0] - b[0]))
48
+ for (const line of document.page(page).lines) {
49
+ const text = line.text.trim().replace(/\s+/g, " ");
50
+ if (
51
+ text &&
52
+ spans.some(
53
+ ([a, b]) =>
54
+ a <= (line.rect[1] + line.rect[3]) / 2 &&
55
+ (line.rect[1] + line.rect[3]) / 2 <= b,
56
+ )
57
+ )
58
+ rows.push({
59
+ page,
60
+ rect: line.rect,
61
+ text,
62
+ column: document.page(page).column(line.rect),
63
+ });
64
+ }
65
+ return rows.sort(
66
+ (a, b) =>
67
+ a.page - b.page ||
68
+ (document.page(a.page).twoColumns
69
+ ? 0
70
+ : a.rect[1] - b.rect[1] || a.rect[0] - b.rect[0]),
71
+ );
72
+ }
73
+ export function pairRelocated(
74
+ regions: Region[],
75
+ oldRows: Row[],
76
+ newRows: Row[],
77
+ ) {
78
+ const removed = regions.filter((r) => r[0] < r[1] && r[2] === r[3]),
79
+ added = regions.filter((r) => r[0] === r[1] && r[2] < r[3]),
80
+ result = regions.filter((r) => !removed.includes(r) && !added.includes(r));
81
+ while (removed.length && added.length) {
82
+ const candidates = removed
83
+ .flatMap((a) =>
84
+ added.map((b) => ({
85
+ a,
86
+ b,
87
+ score: similarity(
88
+ oldRows
89
+ .slice(a[0], a[1])
90
+ .map((r) => r.text)
91
+ .join(" ")
92
+ .split(/\s+/),
93
+ newRows
94
+ .slice(b[2], b[3])
95
+ .map((r) => r.text)
96
+ .join(" ")
97
+ .split(/\s+/),
98
+ ),
99
+ })),
100
+ )
101
+ .sort((a, b) => b.score - a.score),
102
+ { a, b } = candidates[0];
103
+ removed.splice(removed.indexOf(a), 1);
104
+ added.splice(added.indexOf(b), 1);
105
+ let [i, j] = a,
106
+ [, , x, y] = b;
107
+ while (i < j && x < y && oldRows[i].text === newRows[x].text) {
108
+ i++;
109
+ x++;
110
+ }
111
+ while (i < j && x < y && oldRows[j - 1].text === newRows[y - 1].text) {
112
+ j--;
113
+ y--;
114
+ }
115
+ if (i < j || x < y) result.push([i, j, x, y]);
116
+ }
117
+ return [...result, ...removed, ...added].sort(
118
+ (a, b) => a[2] - b[2] || a[0] - b[0],
119
+ );
120
+ }
121
+ export function aligned(old: Row[], next: Row[]): [Row[], Row[]][] {
122
+ const offsets = (rows: Row[]): [Span[], number] => {
123
+ let total = 0;
124
+ const result = rows.map((r) => {
125
+ const span: Span = [total, total + r.text.length];
126
+ total += r.text.length + 1;
127
+ return span;
128
+ });
129
+ return [result, Math.max(total, 1)];
130
+ };
131
+ const [newOffsets, newTotal] = offsets(next),
132
+ pieces: {
133
+ page: number;
134
+ rows: Row[];
135
+ start: number;
136
+ end: number;
137
+ old: Row[];
138
+ }[] = [];
139
+ next.forEach((row, i) => {
140
+ const [start, end] = newOffsets[i],
141
+ previous = pieces.at(-1);
142
+ if (previous?.page === row.page && previous.rows[0].column === row.column) {
143
+ previous.rows.push(row);
144
+ previous.end = end;
145
+ } else pieces.push({ page: row.page, rows: [row], start, end, old: [] });
146
+ });
147
+ const [spans, oldTotal] = offsets(old);
148
+ old.forEach((row, i) => {
149
+ const middle = ((spans[i][0] + spans[i][1]) / 2 / oldTotal) * newTotal;
150
+ (pieces.find((p) => middle < p.end + 1) ?? pieces.at(-1))?.old.push(row);
151
+ });
152
+ return pieces.map((p) => [p.old, p.rows]);
153
+ }
154
+ export function lineBefore(
155
+ rows: Row[],
156
+ index: number,
157
+ previousPage = false,
158
+ ): number | null {
159
+ if (!index) return null;
160
+ if (!previousPage) return index - 1;
161
+ if (index >= rows.length) return null;
162
+ let k = index - 1;
163
+ while (k >= 0 && rows[k].page === rows[index].page) k--;
164
+ return k >= 0 ? k : null;
165
+ }
166
+ export function balancedTextPairs(
167
+ card: Card,
168
+ oldRows: Row[],
169
+ newRows: Row[],
170
+ opcodes: Op[],
171
+ oldDocument: PdfDocument,
172
+ document: PdfDocument,
173
+ ): Pair[] | null {
174
+ const own = card.own;
175
+ const touches = (rows: Row[], side: "old" | "new") => {
176
+ if (own && (own.old.length || own.new.length))
177
+ return rows.some((row) =>
178
+ own[side].some(
179
+ ([page, top, bottom, left, right]) =>
180
+ row.page === page &&
181
+ row.rect[1] < bottom &&
182
+ row.rect[3] > top &&
183
+ (left === undefined ||
184
+ right === undefined ||
185
+ !Number.isFinite(left + right) ||
186
+ (row.rect[0] < right && row.rect[2] > left)),
187
+ ),
188
+ );
189
+ return card.pairs.some((pair) => {
190
+ const position = pair[side];
191
+ if (!position) return false;
192
+ const marks = pair[side === "old" ? "before_marks" : "after_marks"],
193
+ rects = marks.length ? marks.map((m) => m.rect) : [position.clip];
194
+ return rows.some(
195
+ (row) =>
196
+ row.page === position.page &&
197
+ rects.some((r) => intersects(row.rect, r)),
198
+ );
199
+ });
200
+ };
201
+ const selected = pairRelocated(
202
+ opcodes
203
+ .filter(
204
+ ([tag, i, j, x, y]) =>
205
+ tag !== "equal" &&
206
+ (touches(oldRows.slice(i, j), "old") ||
207
+ touches(newRows.slice(x, y), "new")),
208
+ )
209
+ .map(([, i, j, x, y]) => [i, j, x, y]),
210
+ oldRows,
211
+ newRows,
212
+ ),
213
+ runs: Region[] = [];
214
+ for (const [i, j, x, y] of selected) {
215
+ const last = runs.at(-1);
216
+ if (
217
+ last &&
218
+ i - last[1] >= 0 &&
219
+ i - last[1] <= RUN_GAP &&
220
+ x - last[3] >= 0 &&
221
+ x - last[3] <= RUN_GAP
222
+ ) {
223
+ last[1] = j;
224
+ last[3] = y;
225
+ } else runs.push([i, j, x, y]);
226
+ }
227
+ if (!runs.length || runs.every(([, , x, y]) => x === y)) return null;
228
+ const positions = (rows: Row[], doc: PdfDocument) => {
229
+ const result: Position[] = [];
230
+ for (const row of rows) {
231
+ const previous = result.at(-1),
232
+ page = doc.page(row.page),
233
+ [left, right] = page.columnBounds(row.column ?? -1);
234
+ if (
235
+ previous?.page === row.page &&
236
+ previous.clip[0] === left &&
237
+ previous.clip[2] === right
238
+ )
239
+ previous.clip[3] = Math.max(previous.clip[3], row.rect[3] + 2);
240
+ else
241
+ result.push({
242
+ page: row.page,
243
+ clip: [left, row.rect[1] - 2, right, row.rect[3] + 2],
244
+ label: page.label,
245
+ });
246
+ }
247
+ return result;
248
+ };
249
+ const left: Position[] = [],
250
+ right: Position[] = [],
251
+ links: [number | null, number | null][] = [];
252
+ for (const [i, j, x, y] of runs) {
253
+ for (const previousPage of [false, true]) {
254
+ const a = lineBefore(oldRows, i, previousPage),
255
+ b = lineBefore(newRows, x, previousPage);
256
+ if (
257
+ (a !== null && oldRows[a].text.endsWith("-")) ||
258
+ (b !== null && newRows[b].text.endsWith("-"))
259
+ ) {
260
+ left.push(
261
+ ...positions(a === null ? [] : oldRows.slice(a, a + 1), oldDocument),
262
+ );
263
+ right.push(
264
+ ...positions(b === null ? [] : newRows.slice(b, b + 1), document),
265
+ );
266
+ break;
267
+ }
268
+ }
269
+ if (x === y) {
270
+ const old = positions(oldRows.slice(i, j), oldDocument);
271
+ links.push(...old.map((_, k): [number, null] => [left.length + k, null]));
272
+ left.push(...old);
273
+ continue;
274
+ }
275
+ for (const [old, next] of aligned(
276
+ oldRows.slice(i, j),
277
+ newRows.slice(x, y),
278
+ )) {
279
+ right.push(...positions(next, document));
280
+ const olds = positions(old, oldDocument);
281
+ links.push(
282
+ ...(olds.length
283
+ ? olds.map((_, k): [number, number] => [
284
+ left.length + k,
285
+ right.length - 1,
286
+ ])
287
+ : [[null, right.length - 1] as [null, number]]),
288
+ );
289
+ left.push(...olds);
290
+ }
291
+ }
292
+ const marks = textChanges(oldDocument, document, left, right),
293
+ shown = new Set<number | null>();
294
+ return links.map(([a, b]) => {
295
+ const pair: Pair = {
296
+ old: a === null ? null : left[a],
297
+ new: b === null ? null : right[b],
298
+ before_marks: a === null ? [] : (marks.old_marks[a] ?? []),
299
+ after_marks: b === null || shown.has(b) ? [] : (marks.new_marks[b] ?? []),
300
+ };
301
+ // Line-based balancing can stop at different occurrences of repeated text
302
+ // in narrow columns. Retain the word marks from the complete source-local
303
+ // paragraph, instead of treating unequal crop tails as new edits.
304
+ for (const side of ["old", "new"] as const) {
305
+ const position = pair[side],
306
+ doc = side === "old" ? oldDocument : document;
307
+ if (!position || !doc.page(position.page).twoColumns) continue;
308
+ const field = side === "old" ? "before_marks" : "after_marks";
309
+ pair[field] =
310
+ side === "new" && shown.has(b)
311
+ ? []
312
+ : card.pairs
313
+ .filter((p) => p[side]?.page === position.page)
314
+ .flatMap((p) => p[field])
315
+ .filter((mark) => containsCenter(position.clip, mark.rect));
316
+ }
317
+ shown.add(b);
318
+ return pair;
319
+ });
320
+ }
321
+ export function withoutMoves(
322
+ opcodes: Op[],
323
+ oldTexts: string[],
324
+ newTexts: string[],
325
+ minimum = 2,
326
+ ): Op[] {
327
+ const ops = opcodes.filter((o) => o[0] !== "equal").map((o) => [...o] as Op),
328
+ equal = (a: string[], b: string[]) =>
329
+ a.length === b.length && a.every((v, i) => v === b[i]);
330
+ for (const a of ops)
331
+ for (const b of ops) {
332
+ if (a === b) continue;
333
+ let moved = oldTexts.slice(b[1], b[2]);
334
+ if (moved.length >= minimum && a[4] - a[3] >= moved.length) {
335
+ if (equal(newTexts.slice(a[3], a[3] + moved.length), moved)) {
336
+ a[3] += moved.length;
337
+ b[2] = b[1];
338
+ } else if (equal(newTexts.slice(a[4] - moved.length, a[4]), moved)) {
339
+ a[4] -= moved.length;
340
+ b[2] = b[1];
341
+ }
342
+ }
343
+ moved = newTexts.slice(b[3], b[4]);
344
+ if (moved.length >= minimum && a[2] - a[1] >= moved.length) {
345
+ if (equal(oldTexts.slice(a[1], a[1] + moved.length), moved)) {
346
+ a[1] += moved.length;
347
+ b[4] = b[3];
348
+ } else if (equal(oldTexts.slice(a[2] - moved.length, a[2]), moved)) {
349
+ a[2] -= moved.length;
350
+ b[4] = b[3];
351
+ }
352
+ }
353
+ }
354
+ return ops
355
+ .filter(([, i, j, x, y]) => i < j || x < y)
356
+ .map(([, i, j, x, y]) => [
357
+ i < j && x < y ? "replace" : i < j ? "delete" : "insert",
358
+ i,
359
+ j,
360
+ x,
361
+ y,
362
+ ]);
363
+ }
364
+ export function changedLineBounds(
365
+ document: PdfDocument,
366
+ position: Position,
367
+ marks: Mark[],
368
+ ): Span {
369
+ const lines = document
370
+ .page(position.page)
371
+ .lines.filter((l) => marks.some((m) => intersects(l.rect, m.rect)));
372
+ return lines.length
373
+ ? [
374
+ Math.max(
375
+ position.clip[1],
376
+ Math.min(...lines.map((l) => l.rect[1])) - 2,
377
+ ),
378
+ Math.min(
379
+ position.clip[3],
380
+ Math.max(...lines.map((l) => l.rect[3])) + 2,
381
+ ),
382
+ ]
383
+ : [position.clip[1], position.clip[3]];
384
+ }
385
+ export function snap(
386
+ document: PdfDocument,
387
+ number: number,
388
+ top: number,
389
+ bottom: number,
390
+ limit = 10,
391
+ ): Span {
392
+ const page = document.page(number),
393
+ ink = (y0: number, y1: number): [Span, boolean[]] => {
394
+ y0 = Math.max(0, y0);
395
+ y1 = Math.min(page.height, y1);
396
+ if (y1 - y0 < 1) return [[y0, y1], []];
397
+ const image = page.raster([0, y0, page.width, y1], 2, true);
398
+ return [
399
+ [y0, y1],
400
+ Array.from({ length: image.height }, (_, r) =>
401
+ image.pixels
402
+ .subarray(r * image.stride, r * image.stride + image.width)
403
+ .some((p) => p < 200),
404
+ ),
405
+ ];
406
+ };
407
+ let [clip, rows] = ink(bottom, bottom + limit),
408
+ index = rows.findIndex((filled) => !filled);
409
+ if (index >= 0)
410
+ bottom = clip[0] + (index * (clip[1] - clip[0])) / rows.length;
411
+ else if (rows.length) bottom = clip[1];
412
+ [clip, rows] = ink(top - limit, top);
413
+ index = rows.findLastIndex((filled) => !filled);
414
+ if (index >= 0)
415
+ top = clip[0] + ((index + 1) * (clip[1] - clip[0])) / rows.length;
416
+ else if (rows.length) top = clip[0];
417
+ return [top, bottom];
418
+ }
419
+ export function groupedRegions(
420
+ cards: [number, Card][],
421
+ intervals: Intervals,
422
+ snapped?: (page: number, top: number, bottom: number) => Span,
423
+ ) {
424
+ const result = new Map<number, Group[]>();
425
+ for (const [index, card] of cards)
426
+ card.pairs.forEach((pair, pairIndex) => {
427
+ const position = pair.new;
428
+ if (!position) return;
429
+ for (const [start, end] of intervals.get(position.page) ?? []) {
430
+ let top = Math.max(start, position.clip[1]),
431
+ bottom = Math.min(end, position.clip[3]);
432
+ if (bottom > top && snapped)
433
+ [top, bottom] = snapped(position.page, top, bottom);
434
+ if (bottom > top) {
435
+ if (!result.has(position.page)) result.set(position.page, []);
436
+ result
437
+ .get(position.page)!
438
+ .push({ top, bottom, pairs: [[index, pairIndex]] });
439
+ }
440
+ }
441
+ });
442
+ for (const [page, regions] of result) {
443
+ const merged: Group[] = [];
444
+ for (const region of regions.sort((a, b) => a.top - b.top)) {
445
+ const last = merged.at(-1),
446
+ shared =
447
+ last && region.pairs.some(([i]) => last.pairs.some(([j]) => i === j));
448
+ if (
449
+ last &&
450
+ (region.top <= last.bottom ||
451
+ (shared && region.top - last.bottom <= JOIN_GAP))
452
+ ) {
453
+ last.bottom = Math.max(last.bottom, region.bottom);
454
+ last.pairs.push(...region.pairs);
455
+ } else merged.push(region);
456
+ }
457
+ result.set(page, merged);
458
+ }
459
+ return result;
460
+ }
461
+ export function contextPlan(
462
+ intervals: Intervals,
463
+ groups: Map<number, Group[]>,
464
+ ): Piece[] {
465
+ const plan: Piece[] = [];
466
+ for (const [page, spans] of [...intervals].sort((a, b) => a[0] - b[0]))
467
+ for (const [start, end] of spans) {
468
+ let cursor = start;
469
+ for (const group of groups.get(page) ?? []) {
470
+ const top = Math.max(start, group.top),
471
+ bottom = Math.min(end, group.bottom);
472
+ if (bottom <= top) continue;
473
+ if (top > cursor)
474
+ plan.push({ kind: "context", page, top: cursor, bottom: top });
475
+ plan.push({ kind: "change", page, top, bottom, pairs: group.pairs });
476
+ cursor = bottom;
477
+ }
478
+ if (cursor < end)
479
+ plan.push({ kind: "context", page, top: cursor, bottom: end });
480
+ }
481
+ return plan;
482
+ }
483
+ export function foldContext(plan: Piece[], margin = MARGIN): Planned[] {
484
+ let total = 0;
485
+ const offsets = plan.map((p) => {
486
+ const offset = total;
487
+ total += p.bottom - p.top;
488
+ return offset;
489
+ }),
490
+ changes = plan.flatMap((p, i) =>
491
+ p.kind === "change"
492
+ ? [[offsets[i], offsets[i] + p.bottom - p.top] as Span]
493
+ : [],
494
+ );
495
+ if (!changes.length) return plan;
496
+ const result: Planned[] = [];
497
+ plan.forEach((piece, i) => {
498
+ if (piece.kind === "change") {
499
+ result.push(piece);
500
+ return;
501
+ }
502
+ const height = piece.bottom - piece.top,
503
+ offset = offsets[i],
504
+ keep = changes
505
+ .map(([a, b]): Span => [
506
+ Math.max(0, a - margin - offset),
507
+ Math.min(height, b + margin - offset),
508
+ ])
509
+ .filter(([a, b]) => b > a)
510
+ .sort((a, b) => a[0] - b[0]);
511
+ let cursor = 0;
512
+ for (let [low, high] of keep) {
513
+ low = Math.max(low, cursor);
514
+ if (high <= low) continue;
515
+ if (low > cursor) result.push({ kind: "gap" });
516
+ result.push({ ...piece, top: piece.top + low, bottom: piece.top + high });
517
+ cursor = high;
518
+ }
519
+ if (cursor < height) result.push({ kind: "gap" });
520
+ });
521
+ return result.filter(
522
+ (p, i) => p.kind !== "gap" || i === 0 || result[i - 1].kind !== "gap",
523
+ );
524
+ }
525
+ export function acrossPages(plan: Planned[]) {
526
+ const result: Planned[] = [];
527
+ for (const original of plan) {
528
+ let piece = original;
529
+ if (piece.kind === "change") {
530
+ let k = result.length;
531
+ while (k) {
532
+ const p = result[k - 1];
533
+ if (p.kind !== "context" || p.bottom - p.top >= 12) break;
534
+ k--;
535
+ }
536
+ const previous = k ? result[k - 1] : null;
537
+ if (
538
+ previous?.kind === "change" &&
539
+ previous.parts!.at(-1)![0] !== piece.page &&
540
+ previous.pairs!.some(
541
+ ([i]) =>
542
+ piece.kind === "change" && piece.pairs!.some(([j]) => i === j),
543
+ )
544
+ ) {
545
+ result.splice(k);
546
+ previous.parts!.push([piece.page, piece.top, piece.bottom]);
547
+ previous.pairs!.push(...piece.pairs!);
548
+ continue;
549
+ }
550
+ piece = { ...piece, parts: [[piece.page, piece.top, piece.bottom]] };
551
+ }
552
+ result.push(piece);
553
+ }
554
+ return result;
555
+ }
556
+ export class Strips {
557
+ count = 0;
558
+ constructor(public out: string) {}
559
+ write(
560
+ document: PdfDocument,
561
+ number: number,
562
+ top: number,
563
+ bottom: number,
564
+ marks: Mark[] = [],
565
+ side = "",
566
+ ): Strip | null {
567
+ const page = document.page(number);
568
+ top = Math.max(0, top);
569
+ bottom = Math.min(page.height, bottom);
570
+ if (bottom - top < 0.1) return null;
571
+ const svg = page.svg(top, bottom),
572
+ digest = createHash("sha256")
573
+ .update(svg)
574
+ .update(JSON.stringify(marks))
575
+ .update(side)
576
+ .digest("hex")
577
+ .slice(0, 12),
578
+ name = `s${String(++this.count).padStart(4, "0")}-${digest}`,
579
+ clean = marks.length ? name + "-clean.svg" : name + ".svg";
580
+ fs.writeFileSync(path.join(this.out, clean), svg);
581
+ if (marks.length) {
582
+ const color = side === "old" ? "#c72938" : "#1f8c52",
583
+ overlays = marks
584
+ .map((mark) => {
585
+ const r = intersect(mark.rect, [0, top, page.width, bottom]);
586
+ if (r[2] <= r[0] || r[3] <= r[1]) return "";
587
+ const x = r[0],
588
+ y = r[1] - top,
589
+ width = r[2] - r[0],
590
+ height = r[3] - r[1],
591
+ strike = y + height * 0.55;
592
+ return (
593
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="${color}" fill-opacity=".18"/>` +
594
+ (side === "old"
595
+ ? `<path d="M ${x} ${strike} H ${r[2]}" stroke="${color}" stroke-width=".45"/>`
596
+ : "")
597
+ );
598
+ })
599
+ .join("");
600
+ fs.writeFileSync(
601
+ path.join(this.out, name + ".svg"),
602
+ svg.replace("</svg>", overlays + "</svg>"),
603
+ );
604
+ }
605
+ const text = page.lines
606
+ .map((line) => {
607
+ const chars = line.chars
608
+ .filter((char) =>
609
+ containsCenter([0, top, page.width, bottom], char.rect),
610
+ )
611
+ .map((char) => ({
612
+ text: char.text,
613
+ rect: [
614
+ char.rect[0],
615
+ char.rect[1] - top,
616
+ char.rect[2],
617
+ char.rect[3] - top,
618
+ ] as Rect,
619
+ }));
620
+ return { text: chars.map((c) => c.text).join(""), chars };
621
+ })
622
+ .filter((line) => line.text.trim());
623
+ return {
624
+ src: name + ".svg",
625
+ clean,
626
+ width: Math.round(page.width * 100) / 100,
627
+ height: Math.round((bottom - top) * 100) / 100,
628
+ page: number + 1,
629
+ label: page.label,
630
+ text,
631
+ };
632
+ }
633
+ }
634
+ export function renderSection(
635
+ old: Build,
636
+ next: Build,
637
+ node: Heading,
638
+ inputCards: [number, Card][],
639
+ strips: Strips,
640
+ figureFiles: (file: string) => boolean,
641
+ ): [Block[], number[], number] {
642
+ const [intervals, missing] = sectionIntervals(next, node, figureFiles),
643
+ cards: [number, Card][] = structuredClone(inputCards);
644
+ let oldRows: Row[] = [],
645
+ newRows: Row[] = [],
646
+ opcodes: Op[] = [];
647
+ if (cards.some(([, card]) => card.comparison === "text")) {
648
+ const oldNode = old.outline.find((n) => n.id === node.id);
649
+ if (oldNode)
650
+ try {
651
+ const [oldIntervals] = sectionIntervals(old, oldNode, figureFiles);
652
+ oldRows = textRows(old.document, oldIntervals);
653
+ newRows = textRows(next.document, intervals);
654
+ const a = oldRows.map((r) => r.text),
655
+ b = newRows.map((r) => r.text);
656
+ opcodes = withoutMoves(compare(a, b), a, b);
657
+ } catch {
658
+ /* Missing PDF heading: use the source-local crops. */
659
+ }
660
+ }
661
+ for (const [, card] of cards) {
662
+ if (card.comparison !== "text" || figureFiles(card.file)) continue;
663
+ if (opcodes.length) {
664
+ const balanced = balancedTextPairs(
665
+ card,
666
+ oldRows,
667
+ newRows,
668
+ opcodes,
669
+ old.document,
670
+ next.document,
671
+ );
672
+ if (balanced) {
673
+ card.pairs = balanced;
674
+ continue;
675
+ }
676
+ }
677
+ for (const pair of card.pairs)
678
+ if (
679
+ pair.old &&
680
+ pair.new &&
681
+ pair.before_marks.length &&
682
+ pair.after_marks.length
683
+ ) {
684
+ [pair.old.clip[1], pair.old.clip[3]] = changedLineBounds(
685
+ old.document,
686
+ pair.old,
687
+ pair.before_marks,
688
+ );
689
+ [pair.new.clip[1], pair.new.clip[3]] = changedLineBounds(
690
+ next.document,
691
+ pair.new,
692
+ pair.after_marks,
693
+ );
694
+ }
695
+ }
696
+ const plan = acrossPages(
697
+ foldContext(
698
+ contextPlan(
699
+ intervals,
700
+ groupedRegions(cards, intervals, (p, a, b) =>
701
+ snap(next.document, p, a, b),
702
+ ),
703
+ ),
704
+ ),
705
+ ),
706
+ byIndex = new Map(cards),
707
+ blocks: Block[] = [],
708
+ located = new Set<number>();
709
+ for (const piece of plan) {
710
+ if (piece.kind === "gap") {
711
+ blocks.push({ kind: "gap" });
712
+ continue;
713
+ }
714
+ if (piece.kind === "context") {
715
+ const image = strips.write(
716
+ next.document,
717
+ piece.page,
718
+ piece.top,
719
+ piece.bottom,
720
+ );
721
+ if (image) blocks.push({ kind: "context", image });
722
+ continue;
723
+ }
724
+ const indices = [...new Set(piece.pairs!.map(([i]) => i))].sort(
725
+ (a, b) => a - b,
726
+ );
727
+ indices.forEach((i) => located.add(i));
728
+ const pairs = piece.pairs!.map(([i, j]) => byIndex.get(i)!.pairs[j]),
729
+ identical = indices.every(
730
+ (i) => byIndex.get(i)!.comparison === "identical",
731
+ );
732
+ const regions = identical
733
+ ? []
734
+ : pairs.flatMap((pair) =>
735
+ pair.old
736
+ ? [
737
+ {
738
+ page: pair.old.page,
739
+ top: pair.old.clip[1],
740
+ bottom: pair.old.clip[3],
741
+ marks: pair.before_marks,
742
+ },
743
+ ]
744
+ : [],
745
+ ),
746
+ merged: typeof regions = [];
747
+ for (const region of regions.sort(
748
+ (a, b) => a.page - b.page || a.top - b.top,
749
+ )) {
750
+ const last = merged.at(-1);
751
+ if (
752
+ last &&
753
+ region.page === last.page &&
754
+ region.top <= last.bottom + JOIN_GAP
755
+ ) {
756
+ last.bottom = Math.max(last.bottom, region.bottom);
757
+ last.marks.push(...region.marks);
758
+ } else merged.push({ ...region, marks: [...region.marks] });
759
+ }
760
+ const before = merged
761
+ .map((r) =>
762
+ strips.write(
763
+ old.document,
764
+ r.page,
765
+ ...snap(old.document, r.page, r.top, r.bottom),
766
+ r.marks,
767
+ "old",
768
+ ),
769
+ )
770
+ .filter((s): s is Strip => s !== null),
771
+ after = piece
772
+ .parts!.map(([page, top, bottom]) =>
773
+ strips.write(
774
+ next.document,
775
+ page,
776
+ top,
777
+ bottom,
778
+ pairs
779
+ .filter((p) => p.new?.page === page)
780
+ .flatMap((p) => p.after_marks),
781
+ "new",
782
+ ),
783
+ )
784
+ .filter((s): s is Strip => s !== null),
785
+ edited = pairs.some((p) =>
786
+ [...p.before_marks, ...p.after_marks].some((m) => m.kind !== "moved"),
787
+ );
788
+ blocks.push({
789
+ kind: "change",
790
+ units: indices.map((i) => byIndex.get(i)!.id),
791
+ identical,
792
+ reflow:
793
+ !identical &&
794
+ !edited &&
795
+ indices.every((i) => byIndex.get(i)!.comparison === "text"),
796
+ before,
797
+ after,
798
+ });
799
+ }
800
+ return [
801
+ blocks,
802
+ cards.map(([i]) => i).filter((i) => !located.has(i)),
803
+ missing.length,
804
+ ];
805
+ }