@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/iterable.ts CHANGED
@@ -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
  export async function* withIndex<T>(iter: AsyncIterable<T>, start = 0) {
19
2
  let index = start;
20
3
  for await (const item of iter) {
package/src/jimp.ts CHANGED
@@ -1,15 +1,33 @@
1
1
  import * as jimp from "jimp";
2
2
 
3
+ /**
4
+ * Narrowed view of `jimp.JimpInstance` used throughout the project.
5
+ *
6
+ * Two constraints are tightened relative to the upstream type:
7
+ *
8
+ * 1. `bitmap.data` is asserted to be `Uint8Array<ArrayBuffer>` (never
9
+ * SAB-backed). jimp allocates pixels via `Buffer`, which is always
10
+ * backed by a real ArrayBuffer in practice. Pinning the generic
11
+ * parameter here lets `sliceBackingBuffer` (and `postMessage` transfer
12
+ * lists) infer ArrayBuffer instead of ArrayBufferLike.
13
+ *
14
+ * 2. `resize` and `composite` return `JimpInstance` (this narrowed type)
15
+ * rather than upstream `jimp.JimpInstance`, so chaining preserves the
16
+ * bitmap-backing constraint above.
17
+ */
3
18
  export type JimpInstance = Pick<
4
19
  jimp.JimpInstance,
5
- | "width"
6
- | "height"
7
- | "bitmap"
8
- | "getPixelColor"
9
- | "setPixelColor"
10
- | "resize"
11
- | "composite"
20
+ "width" | "height" | "getPixelColor" | "setPixelColor"
12
21
  > & {
22
+ bitmap: {
23
+ data: Uint8Array<ArrayBuffer>;
24
+ width: number;
25
+ height: number;
26
+ };
27
+ resize: (options: Parameters<jimp.JimpInstance["resize"]>[0]) => JimpInstance;
28
+ composite: (
29
+ ...args: Parameters<jimp.JimpInstance["composite"]>
30
+ ) => JimpInstance;
13
31
  getBuffer: (mime: "image/png") => ReturnType<jimp.JimpInstance["getBuffer"]>;
14
32
  getBase64: (mime: "image/png") => ReturnType<jimp.JimpInstance["getBase64"]>;
15
33
  };
package/src/pdf.ts CHANGED
@@ -1,74 +1,111 @@
1
+ import { gs } from "@u1f992/gs-wasm";
2
+
3
+ import { perf } from "./perf.ts";
4
+
1
5
  /*
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.
6
+ * Pages are rendered with Ghostscript (gs-wasm). Each gs() invocation spins up
7
+ * its own worker and Ghostscript instance, so we render one page per call and
8
+ * let the caller drive concurrency (e.g. by rendering A/B/mask together and by
9
+ * running multiple page workers).
8
10
  *
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/>.
11
+ * Ghostscript (via the `web-worker` package) must be invoked from a context
12
+ * that can itself spawn a worker. In Node's `web-worker` this is only the main
13
+ * thread, so rendering happens on the main thread and the resulting PNG bytes
14
+ * are handed off to the diff workers for decoding. The PDF is placed in
15
+ * Ghostscript's in-memory FS as `input.pdf` and the page is read back as a PNG.
16
16
  */
17
+ const INPUT_VM_PATH = "input.pdf";
17
18
 
18
- import * as jimp from "jimp";
19
- import * as mupdf from "mupdf";
20
-
21
- import type { JimpInstance } from "./jimp.ts";
22
-
23
- export function* loadPages(pdf: mupdf.Document) {
24
- for (let i = 0; i < pdf.countPages(); i++) {
25
- yield pdf.loadPage(i);
19
+ /**
20
+ * Count the pages of a PDF using Ghostscript's `pdfpagecount`. Runs with
21
+ * `-dNODISPLAY` (no rendering) so it is cheap relative to a page render.
22
+ */
23
+ export async function countPages(pdf: Uint8Array): Promise<number> {
24
+ const span = perf.span("pdf.countPages_ms");
25
+ const out: number[] = [];
26
+ const { exitCode } = await gs({
27
+ args: [
28
+ "-q",
29
+ "-dNODISPLAY",
30
+ "-dNOSAFER",
31
+ "-c",
32
+ `(${INPUT_VM_PATH}) (r) file runpdfbegin pdfpagecount = quit`,
33
+ ],
34
+ inputFiles: { [INPUT_VM_PATH]: pdf },
35
+ onStdout: (charCode) => {
36
+ if (charCode !== null) out.push(charCode);
37
+ },
38
+ });
39
+ span.stop();
40
+ if (exitCode !== 0) {
41
+ throw new Error(`gs countPages failed (exit ${exitCode})`);
26
42
  }
43
+ const text = String.fromCharCode(...out).trim();
44
+ const n = Number.parseInt(text, 10);
45
+ if (!Number.isFinite(n) || n < 0) {
46
+ throw new Error(`gs countPages: unexpected output ${JSON.stringify(text)}`);
47
+ }
48
+ return n;
27
49
  }
28
50
 
29
- function pixmapToRGBA(pixmap: mupdf.Pixmap): Uint8Array {
30
- const width = pixmap.getWidth();
31
- const height = pixmap.getHeight();
32
- const stride = pixmap.getStride();
33
- const hasAlpha = pixmap.getAlpha() !== 0;
34
- const samples = pixmap.getPixels();
51
+ /**
52
+ * Render an inclusive range of (0-based) pages of a PDF to PNG bytes in a single
53
+ * Ghostscript invocation, returning a map keyed by 0-based page index. Batching
54
+ * several pages per call amortizes Ghostscript's startup and PDF parsing, which
55
+ * dominate a single-page render. `alpha` selects the device: `pngalpha` keeps
56
+ * the page background transparent (so the diff can tell "no content" from
57
+ * "content" via the alpha channel), while `png16m` renders opaque. Decoding to
58
+ * RGBA is left to the caller (the diff workers) so it can run off this thread.
59
+ */
60
+ export async function renderPageRangePng(
61
+ pdf: Uint8Array,
62
+ firstIndex: number,
63
+ lastIndex: number,
64
+ dpi: number,
65
+ alpha: boolean,
66
+ ): Promise<Map<number, Uint8Array<ArrayBuffer>>> {
67
+ const device = alpha ? "pngalpha" : "png16m";
68
+ const first = firstIndex + 1; // Ghostscript page numbers are 1-based.
69
+ const last = lastIndex + 1;
35
70
 
36
- if (hasAlpha && stride === width * 4) {
37
- return new Uint8Array(samples);
38
- }
71
+ // `%d` in the output pattern is the 1-based index of the page *within this
72
+ // run* (it restarts at 1 regardless of -dFirstPage), so the k-th output maps
73
+ // back to absolute page (first + k - 1).
74
+ const pageCount = last - first + 1;
75
+ const names: string[] = [];
76
+ for (let k = 1; k <= pageCount; k++) names.push(`out-${k}.png`);
39
77
 
40
- const out = new Uint8Array(width * height * 4);
41
- const srcBpp = pixmap.getNumberOfComponents() + (hasAlpha ? 1 : 0);
42
- for (let y = 0; y < height; y++) {
43
- const srcRow = y * stride;
44
- const dstRow = y * width * 4;
45
- for (let x = 0; x < width; x++) {
46
- const s = srcRow + x * srcBpp;
47
- const d = dstRow + x * 4;
48
- out[d] = samples[s]!;
49
- out[d + 1] = samples[s + 1]!;
50
- out[d + 2] = samples[s + 2]!;
51
- out[d + 3] = hasAlpha ? samples[s + 3]! : 255;
78
+ const sRender = perf.span("pdf.gsRender_ms");
79
+ const { exitCode, outputFiles } = await gs({
80
+ args: [
81
+ "-dNOPAUSE",
82
+ "-dBATCH",
83
+ "-dQUIET",
84
+ `-dFirstPage=${first}`,
85
+ `-dLastPage=${last}`,
86
+ `-sDEVICE=${device}`,
87
+ `-r${dpi}`,
88
+ "-dTextAlphaBits=4",
89
+ "-dGraphicsAlphaBits=4",
90
+ "-sOutputFile=out-%d.png",
91
+ INPUT_VM_PATH,
92
+ ],
93
+ inputFiles: { [INPUT_VM_PATH]: pdf },
94
+ outputFilePaths: names,
95
+ });
96
+ sRender.stop();
97
+ if (exitCode !== 0) {
98
+ throw new Error(`gs render failed (pages ${first}-${last}, exit ${exitCode})`);
99
+ }
100
+ const result = new Map<number, Uint8Array<ArrayBuffer>>();
101
+ for (let k = 1; k <= pageCount; k++) {
102
+ const png = outputFiles[`out-${k}.png`];
103
+ if (!png) {
104
+ throw new Error(`gs render produced no output (page ${first + k - 1})`);
52
105
  }
106
+ result.set(firstIndex + (k - 1), png);
53
107
  }
54
- return out;
55
- }
56
-
57
- export async function pageToImage(
58
- page: mupdf.Page,
59
- dpi: number,
60
- alpha: boolean,
61
- ) {
62
- const zoom = dpi / 72;
63
- const pixmap = page.toPixmap(
64
- [zoom, 0, 0, zoom, 0, 0],
65
- mupdf.ColorSpace.DeviceRGB,
66
- alpha,
67
- );
68
- const width = pixmap.getWidth();
69
- const height = pixmap.getHeight();
70
- const data = pixmapToRGBA(pixmap);
71
- pixmap.destroy();
72
- page.destroy();
73
- return jimp.Jimp.fromBitmap({ width, height, data }) as JimpInstance;
108
+ perf.incr("pdf.gsCalls");
109
+ perf.incr("pdf.pagesRendered", pageCount);
110
+ return result;
74
111
  }
package/src/perf.ts ADDED
@@ -0,0 +1,77 @@
1
+ const _enabled = (() => {
2
+ try {
3
+ if (
4
+ typeof process !== "undefined" &&
5
+ process.env &&
6
+ process.env.PDFDIFF_PROFILE === "1"
7
+ ) {
8
+ return true;
9
+ }
10
+ } catch {
11
+ // process not available (e.g. in some browser worker environments)
12
+ }
13
+ const g = globalThis as { __PDFDIFF_PROFILE__?: boolean };
14
+ return g.__PDFDIFF_PROFILE__ === true;
15
+ })();
16
+
17
+ export type Counters = Readonly<Record<string, number>>;
18
+
19
+ export type Span = { stop(): void };
20
+
21
+ type Perf = {
22
+ readonly enabled: boolean;
23
+ span(key: string): Span;
24
+ incr(key: string, delta?: number): void;
25
+ setMax(key: string, value: number): void;
26
+ merge(other: Counters): void;
27
+ dump(): Counters;
28
+ reset(): void;
29
+ };
30
+
31
+ const _counters: Record<string, number> = Object.create(null);
32
+
33
+ const _NOOP_SPAN: Span = Object.freeze({ stop() {} });
34
+ const _noop = () => {};
35
+ const _emptyDump = (): Counters => Object.freeze({});
36
+
37
+ const _realPerf: Perf = {
38
+ enabled: true,
39
+ span(key) {
40
+ const t0 = performance.now();
41
+ return {
42
+ stop() {
43
+ _counters[key] = (_counters[key] ?? 0) + (performance.now() - t0);
44
+ },
45
+ };
46
+ },
47
+ incr(key, delta = 1) {
48
+ _counters[key] = (_counters[key] ?? 0) + delta;
49
+ },
50
+ setMax(key, value) {
51
+ const cur = _counters[key];
52
+ if (cur === undefined || value > cur) _counters[key] = value;
53
+ },
54
+ merge(other) {
55
+ for (const k of Object.keys(other)) {
56
+ _counters[k] = (_counters[k] ?? 0) + other[k]!;
57
+ }
58
+ },
59
+ dump() {
60
+ return { ..._counters };
61
+ },
62
+ reset() {
63
+ for (const k of Object.keys(_counters)) delete _counters[k];
64
+ },
65
+ };
66
+
67
+ const _noopPerf: Perf = {
68
+ enabled: false,
69
+ span: () => _NOOP_SPAN,
70
+ incr: _noop,
71
+ setMax: _noop,
72
+ merge: _noop,
73
+ dump: _emptyDump,
74
+ reset: _noop,
75
+ };
76
+
77
+ export const perf: Perf = _enabled ? _realPerf : _noopPerf;
@@ -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
 
package/src/rgba-color.ts CHANGED
@@ -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
  export type RGBAColor = [number, number, number, number];
19
2
 
20
3
  export const parseHex = (hex: string) => {
package/src/style.css CHANGED
@@ -11,9 +11,21 @@
11
11
  .diff-table {
12
12
  border: 1px solid black;
13
13
  border-collapse: collapse;
14
+ width: 100%;
15
+ table-layout: fixed;
14
16
  }
15
17
 
16
18
  .diff-table th,
17
19
  .diff-table td {
18
20
  border: 1px solid black;
19
21
  }
22
+
23
+ .diff-table img {
24
+ display: block;
25
+ max-width: 100%;
26
+ height: auto;
27
+ }
28
+
29
+ body.hide-no-diff .diff-details.no-diff {
30
+ display: none;
31
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Slice an ArrayBufferView into a standalone backing buffer of the same kind
3
+ * (ArrayBuffer in, ArrayBuffer out; SharedArrayBuffer in, SharedArrayBuffer
4
+ * out). The buffer kind is preserved through the generic parameter.
5
+ */
6
+ export function sliceBackingBuffer<TArrayBuffer extends ArrayBufferLike>(src: {
7
+ buffer: TArrayBuffer;
8
+ byteOffset: number;
9
+ byteLength: number;
10
+ }): TArrayBuffer {
11
+ return src.buffer.slice(
12
+ src.byteOffset,
13
+ src.byteOffset + src.byteLength,
14
+ ) as TArrayBuffer;
15
+ }
package/src/worker.ts CHANGED
@@ -1,38 +1,14 @@
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
- import * as mupdf from "mupdf";
19
-
20
- import { drawDifference, type Pallet } from "./diff.ts";
21
- import {
22
- composeLayers,
23
- createEmptyImage,
24
- type AlignStrategy,
25
- } from "./image.ts";
1
+ import { decodePng } from "./decode.ts";
2
+ import { type Pallet } from "./diff.ts";
3
+ import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
26
4
  import type { JimpInstance } from "./jimp.ts";
27
- import { pageToImage } from "./pdf.ts";
5
+ import { perf, type Counters } from "./perf.ts";
6
+ import { type RGBAColor } from "./rgba-color.ts";
7
+ import { sliceBackingBuffer } from "./transferable.ts";
8
+ import createWasmModule, { type MainModule } from "./wasm/core.js";
28
9
 
29
10
  export type InitMessage = {
30
11
  type: "init";
31
- aBytes: Uint8Array;
32
- bBytes: Uint8Array;
33
- maskBytes: Uint8Array | null;
34
- dpi: number;
35
- alpha: boolean;
36
12
  pallet: Pallet;
37
13
  align: AlignStrategy;
38
14
  };
@@ -40,6 +16,11 @@ export type InitMessage = {
40
16
  export type PageMessage = {
41
17
  type: "page";
42
18
  index: number;
19
+ // PNG bytes rendered on the main thread, or null when the source PDF has no
20
+ // such page (the diff then treats it as an empty/transparent page).
21
+ a: ArrayBuffer | null;
22
+ b: ArrayBuffer | null;
23
+ mask: ArrayBuffer | null;
43
24
  };
44
25
 
45
26
  export type LoadedMessage = {
@@ -56,9 +37,10 @@ export type PageResultMessage = {
56
37
  a: { width: number; height: number; data: ArrayBuffer };
57
38
  b: { width: number; height: number; data: ArrayBuffer };
58
39
  diff: { width: number; height: number; data: ArrayBuffer };
59
- addition: [number, number][];
60
- deletion: [number, number][];
61
- modification: [number, number][];
40
+ addition: ArrayBuffer;
41
+ deletion: ArrayBuffer;
42
+ modification: ArrayBuffer;
43
+ perf?: Counters | undefined;
62
44
  };
63
45
 
64
46
  export type ErrorMessage = {
@@ -66,74 +48,102 @@ export type ErrorMessage = {
66
48
  message: string;
67
49
  };
68
50
 
69
- let pdfA: mupdf.Document;
70
- let pdfB: mupdf.Document;
71
- let pdfMask: mupdf.Document;
51
+ type WasmProcessResult = {
52
+ overlay: Uint8Array<ArrayBuffer>;
53
+ addition: Int32Array<ArrayBuffer>;
54
+ deletion: Int32Array<ArrayBuffer>;
55
+ modification: Int32Array<ArrayBuffer>;
56
+ };
57
+
72
58
  let opts: {
73
- dpi: number;
74
- alpha: boolean;
75
59
  pallet: Pallet;
76
60
  align: AlignStrategy;
77
61
  };
78
62
 
79
- function toTransferable(
80
- src: Buffer | Uint8Array | Uint8ClampedArray | number[],
81
- ): ArrayBuffer {
82
- const view =
83
- src instanceof Uint8Array || src instanceof Uint8ClampedArray
84
- ? src
85
- : Uint8Array.from(src as ArrayLike<number>);
86
- const out = new ArrayBuffer(view.byteLength);
87
- new Uint8Array(out).set(view);
88
- return out;
63
+ let wasm: MainModule | null = null;
64
+ async function getWasm(): Promise<MainModule> {
65
+ if (!wasm) wasm = await createWasmModule();
66
+ return wasm;
89
67
  }
90
68
 
91
- async function processPage(index: number): Promise<PageResultMessage> {
92
- const [pageA, pageB, pageMask] = (await Promise.all([
93
- index < pdfA.countPages()
94
- ? pageToImage(pdfA.loadPage(index), opts.dpi, opts.alpha)
95
- : createEmptyImage(1, 1),
96
- index < pdfB.countPages()
97
- ? pageToImage(pdfB.loadPage(index), opts.dpi, opts.alpha)
98
- : createEmptyImage(1, 1),
99
- index < pdfMask.countPages()
100
- ? pageToImage(pdfMask.loadPage(index), opts.dpi, opts.alpha)
101
- : createEmptyImage(1, 1),
102
- ])) as [JimpInstance, JimpInstance, JimpInstance];
103
-
104
- const {
105
- diff: diffLayer,
106
- addition,
107
- deletion,
108
- modification,
109
- } = drawDifference(pageA, pageB, pageMask, opts.pallet, opts.align);
110
- const diff = composeLayers(pageA.width, pageA.height, [
111
- [pageA, 0.2],
112
- [pageB, 0.2],
113
- [diffLayer, 1],
114
- ]);
69
+ function packColor([r, g, b, a]: RGBAColor): number {
70
+ return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
71
+ }
72
+
73
+ async function processPage(msg: PageMessage): Promise<PageResultMessage> {
74
+ const index = msg.index;
75
+ const sLoad = perf.span("worker.decodeAll_ms");
76
+ const [pageA, pageB, pageMaskOrNull] = (await Promise.all([
77
+ msg.a !== null ? decodePng(msg.a) : createEmptyImage(1, 1),
78
+ msg.b !== null ? decodePng(msg.b) : createEmptyImage(1, 1),
79
+ msg.mask !== null ? decodePng(msg.mask) : Promise.resolve(null),
80
+ ])) as [JimpInstance, JimpInstance, JimpInstance | null];
81
+ sLoad.stop();
82
+
83
+ const sAlign = perf.span("worker.alignSize_ms");
84
+ let aAligned: JimpInstance;
85
+ let bAligned: JimpInstance;
86
+ let maskAligned: JimpInstance | null;
87
+ if (pageMaskOrNull !== null) {
88
+ [aAligned, bAligned, maskAligned] = alignSize(
89
+ [pageA, pageB, pageMaskOrNull],
90
+ opts.align,
91
+ );
92
+ } else {
93
+ [aAligned, bAligned] = alignSize([pageA, pageB], opts.align);
94
+ maskAligned = null;
95
+ }
96
+ sAlign.stop();
97
+
98
+ const width = aAligned.width;
99
+ const height = aAligned.height;
100
+ const aData = aAligned.bitmap.data;
101
+ const bData = bAligned.bitmap.data;
102
+ const maskData = maskAligned !== null ? maskAligned.bitmap.data : null;
103
+
104
+ const sProcess = perf.span("worker.processPage_ms");
105
+ const wasmModule = await getWasm();
106
+ const result = wasmModule.processPage(
107
+ aData,
108
+ bData,
109
+ maskData,
110
+ width,
111
+ height,
112
+ packColor(opts.pallet.addition),
113
+ packColor(opts.pallet.deletion),
114
+ packColor(opts.pallet.modification),
115
+ ) as WasmProcessResult | number;
116
+ if (typeof result === "number") {
117
+ throw new Error(`wasm processPage failed: ${result}`);
118
+ }
119
+ sProcess.stop();
120
+
121
+ const sXfer = perf.span("worker.toTransferable_ms");
122
+ const aBuf = sliceBackingBuffer(aData);
123
+ const bBuf = sliceBackingBuffer(bData);
124
+ const dBuf = sliceBackingBuffer(result.overlay);
125
+ const addBuf = sliceBackingBuffer(result.addition);
126
+ const delBuf = sliceBackingBuffer(result.deletion);
127
+ const modBuf = sliceBackingBuffer(result.modification);
128
+ sXfer.stop();
129
+ perf.incr("worker.pages");
130
+
131
+ let pagePerf: Counters | undefined;
132
+ if (perf.enabled) {
133
+ pagePerf = perf.dump();
134
+ perf.reset();
135
+ }
115
136
 
116
137
  return {
117
138
  type: "pageResult",
118
139
  index,
119
- a: {
120
- width: pageA.width,
121
- height: pageA.height,
122
- data: toTransferable(pageA.bitmap.data),
123
- },
124
- b: {
125
- width: pageB.width,
126
- height: pageB.height,
127
- data: toTransferable(pageB.bitmap.data),
128
- },
129
- diff: {
130
- width: diff.width,
131
- height: diff.height,
132
- data: toTransferable(diff.bitmap.data),
133
- },
134
- addition,
135
- deletion,
136
- modification,
140
+ a: { width, height, data: aBuf },
141
+ b: { width, height, data: bBuf },
142
+ diff: { width, height, data: dBuf },
143
+ addition: addBuf,
144
+ deletion: delBuf,
145
+ modification: modBuf,
146
+ perf: pagePerf,
137
147
  };
138
148
  }
139
149
 
@@ -143,26 +153,22 @@ self.addEventListener(
143
153
  try {
144
154
  const msg = e.data;
145
155
  if (msg.type === "init") {
146
- pdfA = mupdf.PDFDocument.openDocument(msg.aBytes, "application/pdf");
147
- pdfB = mupdf.PDFDocument.openDocument(msg.bBytes, "application/pdf");
148
- pdfMask = msg.maskBytes
149
- ? mupdf.PDFDocument.openDocument(msg.maskBytes, "application/pdf")
150
- : new mupdf.PDFDocument();
151
156
  opts = {
152
- dpi: msg.dpi,
153
- alpha: msg.alpha,
154
157
  pallet: msg.pallet,
155
158
  align: msg.align,
156
159
  };
157
- if (pdfA.countPages() > 0) pdfA.loadPage(0).destroy();
160
+ await getWasm();
158
161
  const ready: ReadyMessage = { type: "ready" };
159
162
  self.postMessage(ready);
160
163
  } else if (msg.type === "page") {
161
- const result = await processPage(msg.index);
164
+ const result = await processPage(msg);
162
165
  self.postMessage(result, [
163
166
  result.a.data,
164
167
  result.b.data,
165
168
  result.diff.data,
169
+ result.addition,
170
+ result.deletion,
171
+ result.modification,
166
172
  ]);
167
173
  }
168
174
  } catch (err) {