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,281 @@
1
+ import fs from "node:fs";
2
+ import * as mupdf from "mupdf";
3
+ import type { Rect } from "../types.ts";
4
+ export { mupdf };
5
+ export const union = (a: Rect, b: Rect): Rect => [
6
+ Math.min(a[0], b[0]),
7
+ Math.min(a[1], b[1]),
8
+ Math.max(a[2], b[2]),
9
+ Math.max(a[3], b[3]),
10
+ ];
11
+ export const intersect = (a: Rect, b: Rect): Rect => [
12
+ Math.max(a[0], b[0]),
13
+ Math.max(a[1], b[1]),
14
+ Math.min(a[2], b[2]),
15
+ Math.min(a[3], b[3]),
16
+ ];
17
+ export const intersects = (a: Rect, b: Rect) =>
18
+ a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1];
19
+ export const containsCenter = (a: Rect, b: Rect) =>
20
+ a[0] <= (b[0] + b[2]) / 2 &&
21
+ (b[0] + b[2]) / 2 <= a[2] &&
22
+ a[1] <= (b[1] + b[3]) / 2 &&
23
+ (b[1] + b[3]) / 2 <= a[3];
24
+ export interface Character {
25
+ text: string;
26
+ rect: Rect;
27
+ }
28
+ export interface Word {
29
+ text: string;
30
+ rect: Rect;
31
+ }
32
+ export interface Line {
33
+ text: string;
34
+ rect: Rect;
35
+ chars: Character[];
36
+ }
37
+ export interface TextBlock {
38
+ text: string;
39
+ rect: Rect;
40
+ lines: Line[];
41
+ }
42
+ export interface OutlineEntry {
43
+ level: number;
44
+ title: string;
45
+ uri: string;
46
+ name: string;
47
+ page: number;
48
+ y: number;
49
+ }
50
+ export interface Raster {
51
+ width: number;
52
+ height: number;
53
+ channels: number;
54
+ stride: number;
55
+ pixels: Uint8Array;
56
+ }
57
+ export class PdfPage {
58
+ private columns?: boolean;
59
+ raw: mupdf.Page;
60
+ bounds: Rect;
61
+ blocks: TextBlock[] = [];
62
+ lines: Line[] = [];
63
+ words: Word[] = [];
64
+ label: string;
65
+ constructor(
66
+ document: mupdf.Document,
67
+ public number: number,
68
+ ) {
69
+ this.raw = document.loadPage(number);
70
+ this.bounds = this.raw.getBounds();
71
+ this.label = this.raw.getLabel() || String(number + 1);
72
+ const text = this.raw.toStructuredText(
73
+ "preserve-whitespace,preserve-ligatures",
74
+ );
75
+ let block: TextBlock, line: Line;
76
+ text.walk({
77
+ beginTextBlock: (rect) => {
78
+ block = { rect, text: "", lines: [] };
79
+ this.blocks.push(block);
80
+ },
81
+ beginLine: (rect) => {
82
+ line = { rect, text: "", chars: [] };
83
+ block.lines.push(line);
84
+ this.lines.push(line);
85
+ },
86
+ onChar: (c, _origin, _font, _size, quad) => {
87
+ const xs = [quad[0], quad[2], quad[4], quad[6]],
88
+ ys = [quad[1], quad[3], quad[5], quad[7]];
89
+ line.chars.push({
90
+ text: c,
91
+ rect: [
92
+ Math.min(...xs),
93
+ Math.min(...ys),
94
+ Math.max(...xs),
95
+ Math.max(...ys),
96
+ ],
97
+ });
98
+ line.text += c;
99
+ },
100
+ endLine: () => {
101
+ block.text += line.text + "\n";
102
+ let word: Word | null = null;
103
+ for (const char of line.chars) {
104
+ if (/\s/u.test(char.text)) word = null;
105
+ else if (word) {
106
+ word.text += char.text;
107
+ word.rect = union(word.rect, char.rect);
108
+ } else {
109
+ word = { text: char.text, rect: [...char.rect] };
110
+ this.words.push(word);
111
+ }
112
+ }
113
+ },
114
+ });
115
+ text.destroy();
116
+ }
117
+ get width() {
118
+ return this.bounds[2] - this.bounds[0];
119
+ }
120
+ get twoColumns() {
121
+ if (this.columns === undefined) {
122
+ const middle = this.bounds[0] + this.width / 2;
123
+ const prose = this.lines.filter(
124
+ (l) =>
125
+ l.text.trim().split(/\s+/).length >= 5 &&
126
+ l.rect[2] - l.rect[0] > this.width * 0.25,
127
+ );
128
+ const left = prose.filter((l) => l.rect[2] < middle),
129
+ right = prose.filter((l) => l.rect[0] > middle);
130
+ this.columns =
131
+ left.filter((a) =>
132
+ right.some(
133
+ (b) =>
134
+ Math.min(a.rect[3], b.rect[3]) - Math.max(a.rect[1], b.rect[1]) >
135
+ 3,
136
+ ),
137
+ ).length >= 3;
138
+ }
139
+ return this.columns;
140
+ }
141
+ column(rect: Rect) {
142
+ if (!this.twoColumns) return -1;
143
+ const middle = this.bounds[0] + this.width / 2;
144
+ return rect[2] <= middle ? 0 : rect[0] >= middle ? 1 : -1;
145
+ }
146
+ columnBounds(column: number): [number, number] {
147
+ const middle = this.bounds[0] + this.width / 2;
148
+ return column === 0
149
+ ? [this.bounds[0], middle]
150
+ : column === 1
151
+ ? [middle, this.bounds[2]]
152
+ : [this.bounds[0], this.bounds[2]];
153
+ }
154
+ get height() {
155
+ return this.bounds[3] - this.bounds[1];
156
+ }
157
+ search(needle: string): Rect[] {
158
+ return this.raw
159
+ .search(needle, {})
160
+ .flat()
161
+ .map((q) => [
162
+ Math.min(q[0], q[2], q[4], q[6]),
163
+ Math.min(q[1], q[3], q[5], q[7]),
164
+ Math.max(q[0], q[2], q[4], q[6]),
165
+ Math.max(q[1], q[3], q[5], q[7]),
166
+ ]);
167
+ }
168
+ text(clip: Rect = this.bounds) {
169
+ return this.lines
170
+ .map((l) =>
171
+ l.chars
172
+ .filter((c) => containsCenter(clip, c.rect))
173
+ .map((c) => c.text)
174
+ .join(""),
175
+ )
176
+ .filter(Boolean)
177
+ .join("\n");
178
+ }
179
+ raster(clip: Rect, scale = 2, gray = false): Raster {
180
+ const matrix: mupdf.Matrix = [scale, 0, 0, scale, 0, 0],
181
+ rect: Rect = [
182
+ Math.floor(clip[0] * scale),
183
+ Math.floor(clip[1] * scale),
184
+ Math.ceil(clip[2] * scale),
185
+ Math.ceil(clip[3] * scale),
186
+ ],
187
+ pixmap = new mupdf.Pixmap(
188
+ gray ? mupdf.ColorSpace.DeviceGray : mupdf.ColorSpace.DeviceRGB,
189
+ rect,
190
+ false,
191
+ );
192
+ pixmap.clear(255);
193
+ const device = new mupdf.DrawDevice(mupdf.Matrix.identity, pixmap);
194
+ this.raw.run(device, matrix);
195
+ device.close();
196
+ device.destroy();
197
+ const result = {
198
+ width: pixmap.getWidth(),
199
+ height: pixmap.getHeight(),
200
+ channels: pixmap.getNumberOfComponents(),
201
+ stride: pixmap.getStride(),
202
+ pixels: new Uint8Array(pixmap.getPixels()),
203
+ };
204
+ pixmap.destroy();
205
+ return result;
206
+ }
207
+ svg(top: number, bottom: number) {
208
+ const buffer = new mupdf.Buffer(),
209
+ writer = new mupdf.DocumentWriter(buffer, "svg", "text=path"),
210
+ device = writer.beginPage([0, 0, this.width, bottom - top]);
211
+ this.raw.run(device, [1, 0, 0, 1, 0, -top]);
212
+ writer.endPage();
213
+ writer.close();
214
+ const svg = buffer.asString();
215
+ device.destroy();
216
+ writer.destroy();
217
+ buffer.destroy();
218
+ return svg;
219
+ }
220
+ close() {
221
+ this.raw.destroy();
222
+ }
223
+ }
224
+ export class PdfDocument {
225
+ raw: mupdf.Document;
226
+ private pages = new Map<number, PdfPage>();
227
+ outline: OutlineEntry[] = [];
228
+ constructor(input: string | Uint8Array) {
229
+ this.raw = mupdf.Document.openDocument(
230
+ typeof input === "string" ? fs.readFileSync(input) : input,
231
+ "application/pdf",
232
+ );
233
+ const walk = (
234
+ items: NonNullable<ReturnType<mupdf.Document["loadOutline"]>>,
235
+ level: number,
236
+ ) => {
237
+ for (const item of items) {
238
+ const uri = item.uri ?? "",
239
+ dest = this.destination(uri);
240
+ this.outline.push({
241
+ level,
242
+ title: item.title ?? "",
243
+ uri,
244
+ name:
245
+ new URLSearchParams(uri.replace(/^#/, "")).get("nameddest") ?? "",
246
+ page: dest?.page ?? item.page ?? -1,
247
+ y: dest?.y ?? 0,
248
+ });
249
+ if (item.down) walk(item.down, level + 1);
250
+ }
251
+ };
252
+ walk(this.raw.loadOutline() ?? [], 1);
253
+ }
254
+ get length() {
255
+ return this.raw.countPages();
256
+ }
257
+ page(number: number) {
258
+ if (!this.pages.has(number))
259
+ this.pages.set(number, new PdfPage(this.raw, number));
260
+ return this.pages.get(number)!;
261
+ }
262
+ destination(uri: string) {
263
+ if (!uri) return null;
264
+ try {
265
+ const dest = this.raw.resolveLinkDestination(uri);
266
+ return dest.page >= 0
267
+ ? {
268
+ page: dest.page,
269
+ y: Number.isFinite(dest.y) ? dest.y : 0,
270
+ x: Number.isFinite(dest.x) ? dest.x : 0,
271
+ }
272
+ : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+ close() {
278
+ for (const page of this.pages.values()) page.close();
279
+ this.raw.destroy();
280
+ }
281
+ }
@@ -0,0 +1,272 @@
1
+ import { opcodes } from "../core/diff.ts";
2
+ import type { Mark, Position, Rect } from "../types.ts";
3
+ import { containsCenter, PdfDocument, union, type Raster } from "./pdf.ts";
4
+ export const normalize = (text: string) =>
5
+ text.normalize("NFKC").replaceAll("\u00ad", "");
6
+ interface Glyph {
7
+ text: string;
8
+ rect: Rect;
9
+ pair: number;
10
+ page: number;
11
+ space: boolean;
12
+ }
13
+ interface Word {
14
+ text: string;
15
+ rect: Rect;
16
+ pair: number;
17
+ page: number;
18
+ }
19
+ export function extract(
20
+ document: PdfDocument,
21
+ positions: Position[],
22
+ ): [Glyph[], Word[]] {
23
+ const glyphs: Glyph[] = [],
24
+ words: Word[] = [];
25
+ positions.forEach((position, pair) => {
26
+ const page = document.page(position.page),
27
+ clip = position.clip;
28
+ for (const line of page.lines) {
29
+ let space = true;
30
+ for (const char of line.chars) {
31
+ if (!containsCenter(clip, char.rect)) {
32
+ space = true;
33
+ continue;
34
+ }
35
+ for (const value of normalize(char.text)) {
36
+ if (/\s/u.test(value)) space = true;
37
+ else {
38
+ glyphs.push({
39
+ text: value,
40
+ rect: char.rect,
41
+ space,
42
+ pair,
43
+ page: position.page,
44
+ });
45
+ space = false;
46
+ }
47
+ }
48
+ }
49
+ }
50
+ for (const word of page.words)
51
+ if (containsCenter(clip, word.rect))
52
+ words.push({
53
+ text: normalize(word.text),
54
+ rect: word.rect,
55
+ pair,
56
+ page: position.page,
57
+ });
58
+ });
59
+ for (let i = glyphs.length - 2; i > 0; i--) {
60
+ const a = glyphs[i - 1],
61
+ b = glyphs[i],
62
+ c = glyphs[i + 1];
63
+ if (
64
+ b.text === "-" &&
65
+ /\p{L}/u.test(a.text) &&
66
+ /\p{Ll}/u.test(c.text) &&
67
+ (b.pair !== c.pair || c.rect[1] > b.rect[1] + 3)
68
+ ) {
69
+ c.space = false;
70
+ glyphs.splice(i, 1);
71
+ }
72
+ }
73
+ return [glyphs, words];
74
+ }
75
+ function tokens(glyphs: Glyph[]) {
76
+ const result: { text: string; start: number; end: number }[] = [];
77
+ let previousWord = false;
78
+ glyphs.forEach((glyph, i) => {
79
+ const word = /[\p{L}\p{N}_]/u.test(glyph.text);
80
+ if (result.length && word && previousWord && !glyph.space) {
81
+ result.at(-1)!.text += glyph.text;
82
+ result.at(-1)!.end = i + 1;
83
+ } else result.push({ text: glyph.text, start: i, end: i + 1 });
84
+ previousWord = word;
85
+ });
86
+ return result;
87
+ }
88
+ export function mergeRects(rects: Rect[]): Rect[] {
89
+ const result: Rect[] = [];
90
+ for (const rect of [...rects].sort(
91
+ (a, b) => Math.round(a[1] / 2) - Math.round(b[1] / 2) || a[0] - b[0],
92
+ )) {
93
+ let merged = false;
94
+ for (let i = result.length - 1; i >= Math.max(0, result.length - 12); i--) {
95
+ const previous = result[i];
96
+ if (
97
+ Math.abs(previous[1] - rect[1]) < 1.7 &&
98
+ Math.abs(previous[3] - rect[3]) < 2 &&
99
+ rect[0] - previous[2] >= -1 &&
100
+ rect[0] - previous[2] < 4
101
+ ) {
102
+ result[i] = union(previous, rect);
103
+ merged = true;
104
+ break;
105
+ }
106
+ }
107
+ if (!merged) result.push([...rect]);
108
+ }
109
+ return result;
110
+ }
111
+ function marks(
112
+ glyphs: Glyph[],
113
+ indices: number[],
114
+ words: Word[],
115
+ kind: Mark["kind"],
116
+ ): Record<number, Mark[]> {
117
+ const selected = new Map<number, Rect[]>();
118
+ for (const i of new Set(indices)) {
119
+ const g = glyphs[i];
120
+ if (!selected.has(g.pair)) selected.set(g.pair, []);
121
+ selected.get(g.pair)!.push(g.rect);
122
+ }
123
+ return Object.fromEntries(
124
+ [...selected].map(([pair, rects]) => {
125
+ const boxes = words
126
+ .filter(
127
+ (w) =>
128
+ w.pair === pair && rects.some((r) => containsCenter(w.rect, r)),
129
+ )
130
+ .map((w) => w.rect);
131
+ return [
132
+ pair,
133
+ mergeRects(boxes.length ? boxes : rects).map((rect) => ({
134
+ kind,
135
+ rect,
136
+ })),
137
+ ];
138
+ }),
139
+ );
140
+ }
141
+ export function joined(words: Word[]) {
142
+ const result: string[] = [];
143
+ let previous: Word | null = null;
144
+ for (const word of words) {
145
+ const last = result.at(-1) ?? "";
146
+ if (
147
+ previous &&
148
+ /\p{L}-$/u.test(last) &&
149
+ /^\p{Ll}/u.test(word.text) &&
150
+ (word.pair !== previous.pair || word.rect[1] > previous.rect[3] - 2)
151
+ )
152
+ result[result.length - 1] = last.slice(0, -1) + word.text;
153
+ else result.push(word.text);
154
+ previous = word;
155
+ }
156
+ return result;
157
+ }
158
+ export function textChanges(
159
+ oldDocument: PdfDocument | null,
160
+ newDocument: PdfDocument | null,
161
+ left: Position[],
162
+ right: Position[],
163
+ ) {
164
+ const [a, aw] = left.length
165
+ ? extract(oldDocument!, left)
166
+ : ([[], []] as [Glyph[], Word[]]),
167
+ [b, bw] = right.length
168
+ ? extract(newDocument!, right)
169
+ : ([[], []] as [Glyph[], Word[]]),
170
+ at = tokens(a),
171
+ bt = tokens(b);
172
+ const removed: number[] = [],
173
+ added: number[] = [],
174
+ movedA: number[] = [],
175
+ movedB: number[] = [];
176
+ const indices = (t: ReturnType<typeof tokens>, i: number, j: number) =>
177
+ t
178
+ .slice(i, j)
179
+ .flatMap((t) =>
180
+ Array.from({ length: t.end - t.start }, (_, k) => t.start + k),
181
+ );
182
+ for (const [tag, i, j, x, y] of opcodes(
183
+ at.map((t) => t.text),
184
+ bt.map((t) => t.text),
185
+ )) {
186
+ const ai = indices(at, i, j),
187
+ bi = indices(bt, x, y);
188
+ if (tag !== "equal") {
189
+ removed.push(...ai);
190
+ added.push(...bi);
191
+ continue;
192
+ }
193
+ ai.forEach((u, index) => {
194
+ const v = bi[index],
195
+ old = a[u],
196
+ next = b[v],
197
+ p = left[old.pair],
198
+ q = right[next.pair],
199
+ r = old.rect,
200
+ s = next.rect;
201
+ if (
202
+ old.pair !== next.pair ||
203
+ [
204
+ r[0] - p.clip[0] - s[0] + q.clip[0],
205
+ r[1] - p.clip[1] - s[1] + q.clip[1],
206
+ r[2] - r[0] - s[2] + s[0],
207
+ r[3] - r[1] - s[3] + s[1],
208
+ ].some((d) => Math.abs(d) > 0.65)
209
+ ) {
210
+ movedA.push(u);
211
+ movedB.push(v);
212
+ }
213
+ });
214
+ }
215
+ const changed = Boolean(removed.length || added.length),
216
+ oldWords = joined(aw),
217
+ newWords = joined(bw),
218
+ edits = changed
219
+ ? opcodes(oldWords, newWords)
220
+ .filter((o) => o[0] !== "equal")
221
+ .map(([, i, j, x, y]) => {
222
+ const old = oldWords.slice(i, j).join(" "),
223
+ next = newWords.slice(x, y).join(" ");
224
+ return {
225
+ old: old.slice(0, 180),
226
+ new: next.slice(0, 180),
227
+ truncated: old.length > 180 || next.length > 180,
228
+ };
229
+ })
230
+ : [];
231
+ return {
232
+ old_marks: marks(
233
+ a,
234
+ changed ? removed : movedA,
235
+ aw,
236
+ changed ? "removed" : "moved",
237
+ ),
238
+ new_marks: marks(
239
+ b,
240
+ changed ? added : movedB,
241
+ bw,
242
+ changed ? "added" : "moved",
243
+ ),
244
+ text_changed: changed,
245
+ moved: Boolean(movedA.length || movedB.length),
246
+ text_available: Boolean(a.length || b.length),
247
+ edits,
248
+ };
249
+ }
250
+ export function pixelDifference(old: Raster | null, next: Raster | null) {
251
+ const width = Math.max(old?.width ?? 0, next?.width ?? 0),
252
+ height = Math.max(old?.height ?? 0, next?.height ?? 0);
253
+ let count = 0;
254
+ for (let y = 0; y < height; y++)
255
+ for (let x = 0; x < width; x++) {
256
+ let difference = 0;
257
+ for (let c = 0; c < 3; c++) {
258
+ const value = (image: Raster | null) =>
259
+ image && x < image.width && y < image.height
260
+ ? image.pixels[
261
+ y * image.stride +
262
+ x * image.channels +
263
+ Math.min(c, image.channels - 1)
264
+ ]
265
+ : 255;
266
+ difference +=
267
+ Math.abs(value(old) - value(next)) * [0.299, 0.587, 0.114][c];
268
+ }
269
+ if (difference >= 24) count++;
270
+ }
271
+ return count;
272
+ }