@u1f992/pdfdiff 0.2.1 → 0.3.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 (72) hide show
  1. package/.clang-format +3 -0
  2. package/.github/workflows/gh-pages.yml +6 -1
  3. package/LICENSE +68 -81
  4. package/dist/browser.js +1405 -3099
  5. package/dist/browser.js.map +1 -1
  6. package/dist/cli-png-worker.d.ts +13 -0
  7. package/dist/cli-png-worker.d.ts.map +1 -0
  8. package/dist/cli-png-worker.js +287 -0
  9. package/dist/cli-png-worker.js.map +1 -0
  10. package/dist/cli.js +401 -3110
  11. package/dist/cli.js.map +1 -1
  12. package/dist/core.wasm +0 -0
  13. package/dist/decode.d.ts +9 -0
  14. package/dist/decode.d.ts.map +1 -0
  15. package/dist/diff.d.ts +2 -1
  16. package/dist/diff.d.ts.map +1 -1
  17. package/dist/gs-wasm/gs.js +5821 -0
  18. package/dist/gs-wasm/gs.wasm +0 -0
  19. package/dist/gs-wasm/index.js +120 -0
  20. package/dist/gs-wasm/index.js.map +1 -0
  21. package/dist/gs-wasm/worker.js +764 -0
  22. package/dist/gs-wasm/worker.js.map +1 -0
  23. package/dist/image.d.ts.map +1 -1
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.html +6 -1
  27. package/dist/index.js +310 -3094
  28. package/dist/index.js.map +1 -1
  29. package/dist/iterable.d.ts.map +1 -1
  30. package/dist/jimp.d.ts +23 -1
  31. package/dist/jimp.d.ts.map +1 -1
  32. package/dist/pdf.d.ts +15 -4
  33. package/dist/pdf.d.ts.map +1 -1
  34. package/dist/perf.d.ts +16 -0
  35. package/dist/perf.d.ts.map +1 -0
  36. package/dist/rgba-color.d.ts.map +1 -1
  37. package/dist/squoosh_png_bg.wasm +0 -0
  38. package/dist/style.css +12 -0
  39. package/dist/transferable.d.ts +11 -0
  40. package/dist/transferable.d.ts.map +1 -0
  41. package/dist/version.d.ts +1 -1
  42. package/dist/worker.d.ts +8 -8
  43. package/dist/worker.d.ts.map +1 -1
  44. package/dist/worker.js +144 -3210
  45. package/dist/worker.js.map +1 -1
  46. package/package.json +11 -4
  47. package/rollup.config.js +83 -5
  48. package/scripts/build-wasm.sh +32 -0
  49. package/src/browser.ts +122 -9
  50. package/src/cli-png-worker.ts +42 -0
  51. package/src/cli.ts +113 -34
  52. package/src/decode.ts +15 -0
  53. package/src/diff.ts +99 -51
  54. package/src/image.ts +4 -18
  55. package/src/index.html +6 -1
  56. package/src/index.test.ts +10 -18
  57. package/src/index.ts +176 -76
  58. package/src/iterable.test.ts +0 -17
  59. package/src/iterable.ts +0 -17
  60. package/src/jimp.ts +25 -7
  61. package/src/pdf.ts +99 -62
  62. package/src/perf.ts +77 -0
  63. package/src/rgba-color.test.ts +0 -17
  64. package/src/rgba-color.ts +0 -17
  65. package/src/style.css +12 -0
  66. package/src/transferable.ts +15 -0
  67. package/src/worker.ts +106 -100
  68. package/wasm/Makefile +34 -0
  69. package/wasm/bindings.cpp +76 -0
  70. package/wasm/core.c +176 -0
  71. package/wasm/core.h +69 -0
  72. package/dist/mupdf-wasm.wasm +0 -0
package/src/diff.ts CHANGED
@@ -1,22 +1,6 @@
1
- /*
2
- * Copyright (C) 2025 Koutaro Mukai
3
- *
4
- * This program is free software: you can redistribute it and/or modify
5
- * it under the terms of the GNU General Public License as published by
6
- * the Free Software Foundation, either version 3 of the License, or
7
- * (at your option) any later version.
8
- *
9
- * This program is distributed in the hope that it will be useful,
10
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- * GNU General Public License for more details.
13
- *
14
- * You should have received a copy of the GNU General Public License
15
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- */
17
-
18
1
  import { type JimpInstance } from "./jimp.ts";
19
2
  import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
3
+ import { perf } from "./perf.ts";
20
4
  import { type RGBAColor } from "./rgba-color.ts";
21
5
 
22
6
  export type Pallet = {
@@ -28,58 +12,122 @@ export type Pallet = {
28
12
  export function drawDifference(
29
13
  a: JimpInstance,
30
14
  b: JimpInstance,
31
- mask: JimpInstance,
15
+ mask: JimpInstance | null,
32
16
  pallet: Readonly<Pallet>,
33
17
  align: AlignStrategy,
34
18
  ) {
35
- const [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
19
+ const sAlign = perf.span("diff.align_ms");
20
+ let aNew: JimpInstance;
21
+ let bNew: JimpInstance;
22
+ let maskNew: JimpInstance | null;
23
+ if (mask !== null) {
24
+ [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
25
+ } else {
26
+ [aNew, bNew] = alignSize([a, b], align);
27
+ maskNew = null;
28
+ }
29
+ sAlign.stop();
30
+
36
31
  const width = aNew.width;
37
32
  const height = aNew.height;
38
33
  const aData = aNew.bitmap.data;
39
34
  const bData = bNew.bitmap.data;
40
- const mData = maskNew.bitmap.data;
35
+ const mData = maskNew !== null ? maskNew.bitmap.data : null;
41
36
 
37
+ const sCreate = perf.span("diff.createEmpty_ms");
42
38
  const diffImage = createEmptyImage(width, height);
43
39
  const dData = diffImage.bitmap.data;
40
+ sCreate.stop();
44
41
 
45
42
  const addition: [number, number][] = [];
46
43
  const deletion: [number, number][] = [];
47
44
  const modification: [number, number][] = [];
48
45
 
49
- for (let x = 0; x < width; x++) {
50
- for (let y = 0; y < height; y++) {
51
- const idx = (y * width + x) * 4;
52
- if (mData[idx + 3]! !== 0) continue;
53
- const aAlpha = aData[idx + 3]!;
54
- const bAlpha = bData[idx + 3]!;
55
- if (
56
- aAlpha === bAlpha &&
57
- aData[idx] === bData[idx] &&
58
- aData[idx + 1] === bData[idx + 1] &&
59
- aData[idx + 2] === bData[idx + 2]
60
- ) {
61
- continue;
46
+ const sScan = perf.span("diff.scan_ms");
47
+ let diffPixels = 0;
48
+
49
+ if (mData !== null) {
50
+ for (let x = 0; x < width; x++) {
51
+ for (let y = 0; y < height; y++) {
52
+ const idx = (y * width + x) * 4;
53
+ if (mData[idx + 3]! !== 0) continue;
54
+ const aAlpha = aData[idx + 3]!;
55
+ const bAlpha = bData[idx + 3]!;
56
+ if (
57
+ aAlpha === bAlpha &&
58
+ aData[idx] === bData[idx] &&
59
+ aData[idx + 1] === bData[idx + 1] &&
60
+ aData[idx + 2] === bData[idx + 2]
61
+ ) {
62
+ continue;
63
+ }
64
+ if (aAlpha === 0 && bAlpha === 0) continue;
65
+ let target: [number, number][];
66
+ let color: Readonly<RGBAColor>;
67
+ if (aAlpha === 0) {
68
+ target = addition;
69
+ color = pallet.addition;
70
+ } else if (bAlpha === 0) {
71
+ target = deletion;
72
+ color = pallet.deletion;
73
+ } else {
74
+ target = modification;
75
+ color = pallet.modification;
76
+ }
77
+ target.push([x, y]);
78
+ diffPixels++;
79
+ dData[idx] = color[0];
80
+ dData[idx + 1] = color[1];
81
+ dData[idx + 2] = color[2];
82
+ dData[idx + 3] = color[3];
62
83
  }
63
- if (aAlpha === 0 && bAlpha === 0) continue;
64
- let target: [number, number][];
65
- let color: Readonly<RGBAColor>;
66
- if (aAlpha === 0) {
67
- target = addition;
68
- color = pallet.addition;
69
- } else if (bAlpha === 0) {
70
- target = deletion;
71
- color = pallet.deletion;
72
- } else {
73
- target = modification;
74
- color = pallet.modification;
84
+ }
85
+ } else {
86
+ for (let x = 0; x < width; x++) {
87
+ for (let y = 0; y < height; y++) {
88
+ const idx = (y * width + x) * 4;
89
+ const aAlpha = aData[idx + 3]!;
90
+ const bAlpha = bData[idx + 3]!;
91
+ if (
92
+ aAlpha === bAlpha &&
93
+ aData[idx] === bData[idx] &&
94
+ aData[idx + 1] === bData[idx + 1] &&
95
+ aData[idx + 2] === bData[idx + 2]
96
+ ) {
97
+ continue;
98
+ }
99
+ if (aAlpha === 0 && bAlpha === 0) continue;
100
+ let target: [number, number][];
101
+ let color: Readonly<RGBAColor>;
102
+ if (aAlpha === 0) {
103
+ target = addition;
104
+ color = pallet.addition;
105
+ } else if (bAlpha === 0) {
106
+ target = deletion;
107
+ color = pallet.deletion;
108
+ } else {
109
+ target = modification;
110
+ color = pallet.modification;
111
+ }
112
+ target.push([x, y]);
113
+ diffPixels++;
114
+ dData[idx] = color[0];
115
+ dData[idx + 1] = color[1];
116
+ dData[idx + 2] = color[2];
117
+ dData[idx + 3] = color[3];
75
118
  }
76
- target.push([x, y]);
77
- dData[idx] = color[0];
78
- dData[idx + 1] = color[1];
79
- dData[idx + 2] = color[2];
80
- dData[idx + 3] = color[3];
81
119
  }
82
120
  }
121
+ sScan.stop();
122
+ perf.incr("diff.diffPixels", diffPixels);
123
+ perf.incr("diff.totalPixels", width * height);
124
+ perf.incr("diff.pages");
83
125
 
84
- return { diff: diffImage, addition, deletion, modification };
126
+ return {
127
+ diff: diffImage,
128
+ addition,
129
+ deletion,
130
+ modification,
131
+ hasDiff: diffPixels > 0,
132
+ };
85
133
  }
package/src/image.ts CHANGED
@@ -1,22 +1,6 @@
1
- /*
2
- * Copyright (C) 2025 Koutaro Mukai
3
- *
4
- * This program is free software: you can redistribute it and/or modify
5
- * it under the terms of the GNU General Public License as published by
6
- * the Free Software Foundation, either version 3 of the License, or
7
- * (at your option) any later version.
8
- *
9
- * This program is distributed in the hope that it will be useful,
10
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- * GNU General Public License for more details.
13
- *
14
- * You should have received a copy of the GNU General Public License
15
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- */
17
-
18
1
  import * as jimp from "jimp";
19
2
  import { type JimpInstance } from "./jimp.ts";
3
+ import { perf } from "./perf.ts";
20
4
 
21
5
  export function createEmptyImage(width: number, height: number) {
22
6
  return new jimp.Jimp({
@@ -68,7 +52,7 @@ function alignImage(
68
52
  targetWidth: number,
69
53
  targetHeight: number,
70
54
  align: AlignStrategy,
71
- ) {
55
+ ): JimpInstance {
72
56
  if (align === "resize") {
73
57
  return img.resize({ w: targetWidth, h: targetHeight });
74
58
  } else {
@@ -117,6 +101,7 @@ export function composeLayers(
117
101
  canvasHeight: number,
118
102
  layers: [JimpInstance, number][],
119
103
  ) {
104
+ const _span = perf.span("image.compose_ms");
120
105
  const canvas = createEmptyImage(canvasWidth, canvasHeight);
121
106
  const dData = canvas.bitmap.data;
122
107
  for (const [image, opacity] of layers) {
@@ -146,5 +131,6 @@ export function composeLayers(
146
131
  }
147
132
  }
148
133
  }
134
+ _span.stop();
149
135
  return canvas;
150
136
  }
package/src/index.html CHANGED
@@ -49,7 +49,7 @@
49
49
  </div>
50
50
  <div>
51
51
  <label for="workers">Workers:</label>
52
- <input type="number" id="workers" value="1" min="1" />
52
+ <input type="number" id="workers" placeholder="auto" min="1" />
53
53
  </div>
54
54
  <div>
55
55
  <label for="addition-color">Addition:</label>
@@ -64,6 +64,11 @@
64
64
  <input type="color" id="modification-color" value="#ffc105" />
65
65
  </div>
66
66
  <button type="submit">Submit</button>
67
+ <label>
68
+ <input type="checkbox" id="hide-no-diff" />
69
+ Hide pages with no diff
70
+ </label>
71
+ <button type="button" id="download-zip" disabled>Download zip</button>
67
72
  </form>
68
73
  </div>
69
74
  <div class="error" id="error-message"></div>
package/src/index.test.ts CHANGED
@@ -1,26 +1,10 @@
1
- /*
2
- * Copyright (C) 2025 Koutaro Mukai
3
- *
4
- * This program is free software: you can redistribute it and/or modify
5
- * it under the terms of the GNU General Public License as published by
6
- * the Free Software Foundation, either version 3 of the License, or
7
- * (at your option) any later version.
8
- *
9
- * This program is distributed in the hope that it will be useful,
10
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- * GNU General Public License for more details.
13
- *
14
- * You should have received a copy of the GNU General Public License
15
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- */
17
-
18
1
  import assert from "node:assert/strict";
19
2
  import fs from "node:fs";
20
3
  import test from "node:test";
21
4
 
22
5
  import {
23
6
  defaultOptions,
7
+ defaultWorkers,
24
8
  formatHex,
25
9
  isValidAlignStrategy,
26
10
  parseHex,
@@ -50,10 +34,18 @@ test("defaultOptions", () => {
50
34
  deletion: [0xff, 0x57, 0x24, 0xff],
51
35
  modification: [0xff, 0xc1, 0x05, 0xff],
52
36
  },
53
- workers: 1,
37
+ workers: defaultWorkers,
54
38
  });
55
39
  });
56
40
 
41
+ test("defaultWorkers scales with cores, capped at 4", () => {
42
+ assert.equal(
43
+ defaultWorkers,
44
+ Math.max(1, Math.min(globalThis.navigator?.hardwareConcurrency ?? 1, 4)),
45
+ );
46
+ assert.ok(defaultWorkers >= 1 && defaultWorkers <= 4);
47
+ });
48
+
57
49
  test("isValidAlignStrategy", async (ctx) => {
58
50
  for (const s of [
59
51
  "resize",
package/src/index.ts CHANGED
@@ -1,28 +1,13 @@
1
- /*
2
- * Copyright (C) 2025 Koutaro Mukai
3
- *
4
- * This program is free software: you can redistribute it and/or modify
5
- * it under the terms of the GNU General Public License as published by
6
- * the Free Software Foundation, either version 3 of the License, or
7
- * (at your option) any later version.
8
- *
9
- * This program is distributed in the hope that it will be useful,
10
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- * GNU General Public License for more details.
13
- *
14
- * You should have received a copy of the GNU General Public License
15
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- */
17
-
18
1
  import * as jimp from "jimp";
19
- import * as mupdf from "mupdf";
20
2
  import Worker from "web-worker";
21
3
 
22
4
  import { type Pallet } from "./diff.ts";
23
5
  import { isValidAlignStrategy, type AlignStrategy } from "./image.ts";
24
6
  import { withIndex } from "./iterable.ts";
7
+ import { countPages, renderPageRangePng } from "./pdf.ts";
8
+ import { perf } from "./perf.ts";
25
9
  import { parseHex, formatHex } from "./rgba-color.ts";
10
+ import { sliceBackingBuffer } from "./transferable.ts";
26
11
  import { VERSION } from "./version.ts";
27
12
  import type { JimpInstance } from "./jimp.ts";
28
13
  import type {
@@ -34,7 +19,7 @@ import type {
34
19
  ReadyMessage,
35
20
  } from "./worker.ts";
36
21
 
37
- export { withIndex, isValidAlignStrategy, parseHex, formatHex };
22
+ export { withIndex, isValidAlignStrategy, parseHex, formatHex, perf };
38
23
 
39
24
  type Options = {
40
25
  dpi: number;
@@ -54,6 +39,15 @@ type Result = {
54
39
  modification: [number, number][];
55
40
  };
56
41
 
42
+ // Default parallelism scales with the machine: rendering and diffing run across
43
+ // several workers, so the out-of-the-box run uses the CPU rather than a single
44
+ // core. Capped at 4 to keep the default memory footprint and oversubscription
45
+ // modest; raise --workers explicitly for large jobs on big machines.
46
+ export const defaultWorkers = Math.max(
47
+ 1,
48
+ Math.min(globalThis.navigator?.hardwareConcurrency ?? 1, 4),
49
+ );
50
+
57
51
  export const defaultOptions: Options = {
58
52
  dpi: 150,
59
53
  alpha: true,
@@ -64,25 +58,9 @@ export const defaultOptions: Options = {
64
58
  deletion: [0xff, 0x57, 0x24, 0xff],
65
59
  modification: [0xff, 0xc1, 0x05, 0xff],
66
60
  },
67
- workers: 1,
61
+ workers: defaultWorkers,
68
62
  };
69
63
 
70
- function asSharedBytes(bytes: Uint8Array): Uint8Array {
71
- const isNode =
72
- typeof globalThis.process !== "undefined" &&
73
- !!globalThis.process.versions?.node;
74
- const coiOk =
75
- (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated ===
76
- true;
77
- if (typeof SharedArrayBuffer !== "undefined" && (isNode || coiOk)) {
78
- const sab = new SharedArrayBuffer(bytes.byteLength);
79
- const view = new Uint8Array(sab);
80
- view.set(bytes);
81
- return view;
82
- }
83
- return new Uint8Array(bytes);
84
- }
85
-
86
64
  type WorkerResponse =
87
65
  | LoadedMessage
88
66
  | ReadyMessage
@@ -135,12 +113,29 @@ class WorkerHandle {
135
113
  });
136
114
  }
137
115
 
138
- processPage(index: number): Promise<PageResultMessage> {
116
+ processDiff(
117
+ index: number,
118
+ a: Uint8Array<ArrayBuffer> | null,
119
+ b: Uint8Array<ArrayBuffer> | null,
120
+ mask: Uint8Array<ArrayBuffer> | null,
121
+ ): Promise<PageResultMessage> {
139
122
  return new Promise<PageResultMessage>((resolve, reject) => {
140
123
  this.pendingResolve = resolve as (data: WorkerResponse) => void;
141
124
  this.pendingReject = reject;
142
- const msg: PageMessage = { type: "page", index };
143
- this.worker.postMessage(msg);
125
+ const aBuf = a !== null ? sliceBackingBuffer(a) : null;
126
+ const bBuf = b !== null ? sliceBackingBuffer(b) : null;
127
+ const maskBuf = mask !== null ? sliceBackingBuffer(mask) : null;
128
+ const msg: PageMessage = {
129
+ type: "page",
130
+ index,
131
+ a: aBuf,
132
+ b: bBuf,
133
+ mask: maskBuf,
134
+ };
135
+ const transfer = [aBuf, bBuf, maskBuf].filter(
136
+ (buf): buf is ArrayBuffer => buf !== null,
137
+ );
138
+ this.worker.postMessage(msg, transfer);
144
139
  });
145
140
  }
146
141
 
@@ -154,8 +149,18 @@ function workerUrl(): URL {
154
149
  return new URL(`${file}?v=${encodeURIComponent(VERSION)}`, import.meta.url);
155
150
  }
156
151
 
152
+ function unpackCoords(buf: ArrayBuffer): [number, number][] {
153
+ const arr = new Int32Array(buf);
154
+ const out: [number, number][] = new Array(arr.length >>> 1);
155
+ for (let i = 0, j = 0; j < out.length; i += 2, j++) {
156
+ out[j] = [arr[i]!, arr[i + 1]!];
157
+ }
158
+ return out;
159
+ }
160
+
157
161
  function pageResultToResult(msg: PageResultMessage): Result {
158
- return {
162
+ const sP = perf.span("main.pageResultToResult_ms");
163
+ const r = {
159
164
  a: jimp.Jimp.fromBitmap({
160
165
  width: msg.a.width,
161
166
  height: msg.a.height,
@@ -171,10 +176,14 @@ function pageResultToResult(msg: PageResultMessage): Result {
171
176
  height: msg.diff.height,
172
177
  data: new Uint8Array(msg.diff.data),
173
178
  }) as JimpInstance,
174
- addition: msg.addition,
175
- deletion: msg.deletion,
176
- modification: msg.modification,
179
+ addition: unpackCoords(msg.addition),
180
+ deletion: unpackCoords(msg.deletion),
181
+ modification: unpackCoords(msg.modification),
177
182
  };
183
+ sP.stop();
184
+ perf.incr("main.resultsReceived");
185
+ if (msg.perf) perf.merge(msg.perf);
186
+ return r;
178
187
  }
179
188
 
180
189
  export async function* visualizeDifferences(
@@ -196,62 +205,144 @@ export async function* visualizeDifferences(
196
205
  workers: options?.workers ?? defaultOptions.workers,
197
206
  };
198
207
 
199
- const probe = mupdf.PDFDocument.openDocument(a, "application/pdf");
200
- const probeB = mupdf.PDFDocument.openDocument(b, "application/pdf");
201
- const probeMask =
208
+ const [aPages, bPages, maskPages] = await Promise.all([
209
+ countPages(a),
210
+ countPages(b),
202
211
  typeof merged.mask !== "undefined"
203
- ? mupdf.PDFDocument.openDocument(merged.mask, "application/pdf")
204
- : new mupdf.PDFDocument();
205
- const maxPages = Math.max(
206
- probe.countPages(),
207
- probeB.countPages(),
208
- probeMask.countPages(),
209
- );
210
- probe.destroy();
211
- probeB.destroy();
212
- probeMask.destroy();
212
+ ? countPages(merged.mask)
213
+ : Promise.resolve(0),
214
+ ]);
215
+ const maxPages = Math.max(aPages, bPages, maskPages);
213
216
 
214
217
  if (maxPages === 0) return;
215
218
 
216
- const aBytes = asSharedBytes(a);
217
- const bBytes = asSharedBytes(b);
218
- const maskBytes =
219
- typeof merged.mask !== "undefined" ? asSharedBytes(merged.mask) : null;
219
+ const mask = merged.mask;
220
+ const hasMask = typeof mask !== "undefined" && maskPages > 0;
221
+ const numDocs = hasMask ? 3 : 2;
220
222
 
221
223
  const initMsg: InitMessage = {
222
224
  type: "init",
223
- aBytes,
224
- bBytes,
225
- maskBytes,
226
- dpi: merged.dpi,
227
- alpha: merged.alpha,
228
225
  pallet: merged.pallet,
229
226
  align: merged.align,
230
227
  };
231
228
 
232
229
  const N = Math.max(1, Math.min(merged.workers, maxPages));
233
230
  const url = workerUrl();
234
- const worker0 = new WorkerHandle(url);
235
- await worker0.init(initMsg);
236
-
237
- const buffered = new Map<number, Result>();
238
- let nextToAssign = 0;
239
-
240
- const workers: WorkerHandle[] = [worker0];
241
- for (let i = 1; i < N; i++) {
231
+ const workers: WorkerHandle[] = [];
232
+ for (let i = 0; i < N; i++) {
242
233
  const w = new WorkerHandle(url);
243
234
  await w.init(initMsg);
244
235
  workers.push(w);
245
236
  }
246
237
 
238
+ let aborted: unknown = null;
239
+
240
+ // One PNG slot per page per document, fulfilled as render chunks complete.
241
+ // Pages past a document's page count resolve to null (an empty/transparent
242
+ // page). The defensive catch keeps a chunk failure from surfacing as an
243
+ // unhandled rejection before a diff lane awaits the slot.
244
+ type Slot = {
245
+ p: Promise<Uint8Array<ArrayBuffer> | null>;
246
+ resolve: (v: Uint8Array<ArrayBuffer> | null) => void;
247
+ reject: (e: unknown) => void;
248
+ };
249
+ const makeSlots = (count: number): Slot[] =>
250
+ Array.from({ length: maxPages }, (_, i) => {
251
+ if (i >= count) {
252
+ return { p: Promise.resolve(null), resolve: () => {}, reject: () => {} };
253
+ }
254
+ let resolve!: (v: Uint8Array<ArrayBuffer> | null) => void;
255
+ let reject!: (e: unknown) => void;
256
+ const p = new Promise<Uint8Array<ArrayBuffer> | null>((res, rej) => {
257
+ resolve = res;
258
+ reject = rej;
259
+ });
260
+ p.catch(() => {});
261
+ return { p, resolve, reject };
262
+ });
263
+ const slots = {
264
+ a: makeSlots(aPages),
265
+ b: makeSlots(bPages),
266
+ mask: makeSlots(hasMask ? maskPages : 0),
267
+ };
268
+
269
+ // Render chunk tasks: batch several pages per gs() call to amortize startup,
270
+ // interleaving A/B/mask by page range so the early pages of every document
271
+ // become available together (which keeps the diff stage fed).
272
+ // Render concurrency. Aim for ~2x as many chunks as render slots so pages
273
+ // arrive in waves and the diff/decode stage overlaps later renders instead of
274
+ // waiting for one big batch. A floor keeps each chunk large enough to amortize
275
+ // Ghostscript's per-call startup: when there are many slots relative to pages,
276
+ // the slots are already saturated, so batching beats finer streaming.
277
+ const MIN_CHUNK = 4;
278
+ const R = Math.max(merged.workers, numDocs);
279
+ const totalRenderPages = aPages + bPages + (hasMask ? maskPages : 0);
280
+ const chunkSize = Math.max(
281
+ 1,
282
+ Math.min(maxPages, Math.max(MIN_CHUNK, Math.ceil(totalRenderPages / (2 * R)))),
283
+ );
284
+ type Task = { bytes: Uint8Array; start: number; end: number; slots: Slot[] };
285
+ const tasks: Task[] = [];
286
+ const pushChunk = (
287
+ bytes: Uint8Array | undefined,
288
+ count: number,
289
+ target: Slot[],
290
+ start: number,
291
+ ) => {
292
+ if (bytes === undefined || start >= count) return;
293
+ tasks.push({
294
+ bytes,
295
+ start,
296
+ end: Math.min(start + chunkSize, count) - 1,
297
+ slots: target,
298
+ });
299
+ };
300
+ for (let start = 0; start < maxPages; start += chunkSize) {
301
+ pushChunk(a, aPages, slots.a, start);
302
+ pushChunk(b, bPages, slots.b, start);
303
+ if (hasMask) pushChunk(mask, maskPages, slots.mask, start);
304
+ }
305
+
306
+ let taskIdx = 0;
307
+ const renderLoops = Array.from(
308
+ { length: Math.min(R, tasks.length) },
309
+ async () => {
310
+ while (taskIdx < tasks.length && aborted === null) {
311
+ const t = tasks[taskIdx++]!;
312
+ try {
313
+ const pngs = await renderPageRangePng(
314
+ t.bytes,
315
+ t.start,
316
+ t.end,
317
+ merged.dpi,
318
+ merged.alpha,
319
+ );
320
+ for (let i = t.start; i <= t.end; i++) {
321
+ t.slots[i]!.resolve(pngs.get(i) ?? null);
322
+ }
323
+ } catch (e) {
324
+ aborted = e;
325
+ for (let i = t.start; i <= t.end; i++) t.slots[i]!.reject(e);
326
+ }
327
+ }
328
+ },
329
+ );
330
+
331
+ const buffered = new Map<number, Result>();
332
+ let nextToAssign = 0;
247
333
  const resolvers = new Map<number, (r: Result) => void>();
248
334
  let workerError: unknown = null;
249
335
 
250
- const loops = workers.map(async (w) => {
336
+ const diffLoops = workers.map(async (w) => {
251
337
  while (nextToAssign < maxPages && workerError === null) {
252
338
  const idx = nextToAssign++;
253
339
  try {
254
- const msg = await w.processPage(idx);
340
+ const [aPng, bPng, maskPng] = await Promise.all([
341
+ slots.a[idx]!.p,
342
+ slots.b[idx]!.p,
343
+ slots.mask[idx]!.p,
344
+ ]);
345
+ const msg = await w.processDiff(idx, aPng, bPng, maskPng);
255
346
  const result = pageResultToResult(msg);
256
347
  const resolve = resolvers.get(idx);
257
348
  if (resolve) {
@@ -259,9 +350,11 @@ export async function* visualizeDifferences(
259
350
  resolve(result);
260
351
  } else {
261
352
  buffered.set(idx, result);
353
+ perf.setMax("main.bufferedPeak", buffered.size);
262
354
  }
263
355
  } catch (e) {
264
356
  workerError = e;
357
+ aborted = e;
265
358
  for (const [, resolve] of resolvers) resolve(null as never);
266
359
  resolvers.clear();
267
360
  return;
@@ -278,13 +371,20 @@ export async function* visualizeDifferences(
278
371
  buffered.delete(i);
279
372
  r = buf;
280
373
  } else {
374
+ const sWait = perf.span("main.yieldWaitMain_ms");
281
375
  r = await new Promise<Result>((resolve) => resolvers.set(i, resolve));
376
+ sWait.stop();
282
377
  if (workerError !== null) throw workerError;
283
378
  }
379
+ const sYield = perf.span("main.consumerTime_ms");
284
380
  yield r;
381
+ sYield.stop();
285
382
  }
286
- await Promise.all(loops);
383
+ await Promise.all(diffLoops);
384
+ await Promise.all(renderLoops);
287
385
  } finally {
386
+ aborted = aborted ?? new Error("aborted");
387
+ await Promise.allSettled(renderLoops);
288
388
  for (const w of workers) w.terminate();
289
389
  }
290
390
  }
@@ -1,20 +1,3 @@
1
- /*
2
- * Copyright (C) 2025 Koutaro Mukai
3
- *
4
- * This program is free software: you can redistribute it and/or modify
5
- * it under the terms of the GNU General Public License as published by
6
- * the Free Software Foundation, either version 3 of the License, or
7
- * (at your option) any later version.
8
- *
9
- * This program is distributed in the hope that it will be useful,
10
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- * GNU General Public License for more details.
13
- *
14
- * You should have received a copy of the GNU General Public License
15
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
- */
17
-
18
1
  import assert from "assert";
19
2
  import test from "node:test";
20
3