@u1f992/pdfdiff 0.2.0 → 0.2.2

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 (45) hide show
  1. package/.github/workflows/gh-pages.yml +6 -1
  2. package/dist/browser.js +1219 -19
  3. package/dist/browser.js.map +1 -1
  4. package/dist/cli-png-worker.d.ts +13 -0
  5. package/dist/cli-png-worker.d.ts.map +1 -0
  6. package/dist/cli-png-worker.js +303 -0
  7. package/dist/cli-png-worker.js.map +1 -0
  8. package/dist/cli.js +240 -26
  9. package/dist/cli.js.map +1 -1
  10. package/dist/diff.d.ts +2 -1
  11. package/dist/diff.d.ts.map +1 -1
  12. package/dist/image.d.ts.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.html +6 -1
  16. package/dist/index.js +123 -15
  17. package/dist/index.js.map +1 -1
  18. package/dist/pdf.d.ts.map +1 -1
  19. package/dist/perf.d.ts +16 -0
  20. package/dist/perf.d.ts.map +1 -0
  21. package/dist/squoosh_png_bg.wasm +0 -0
  22. package/dist/style.css +12 -0
  23. package/dist/transferable.d.ts +7 -0
  24. package/dist/transferable.d.ts.map +1 -0
  25. package/dist/version.d.ts +2 -0
  26. package/dist/version.d.ts.map +1 -0
  27. package/dist/worker.d.ts +9 -0
  28. package/dist/worker.d.ts.map +1 -1
  29. package/dist/worker.js +274 -88
  30. package/dist/worker.js.map +1 -1
  31. package/package.json +5 -2
  32. package/rollup.config.js +20 -0
  33. package/scripts/version.ts +35 -0
  34. package/src/browser.ts +119 -5
  35. package/src/cli-png-worker.ts +59 -0
  36. package/src/cli.ts +106 -21
  37. package/src/diff.ts +99 -34
  38. package/src/image.ts +3 -0
  39. package/src/index.html +6 -1
  40. package/src/index.ts +53 -27
  41. package/src/pdf.ts +9 -1
  42. package/src/perf.ts +94 -0
  43. package/src/style.css +12 -0
  44. package/src/transferable.ts +30 -0
  45. package/src/worker.ts +77 -54
package/src/index.html CHANGED
@@ -9,7 +9,7 @@
9
9
  <script type="module" src="./browser.js"></script>
10
10
  </head>
11
11
  <body>
12
- <h1>pdfdiff</h1>
12
+ <h1>pdfdiff <small id="version"></small></h1>
13
13
  <div class="form-container">
14
14
  <form id="pdf-diff-form">
15
15
  <div>
@@ -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.ts CHANGED
@@ -22,16 +22,20 @@ import Worker from "web-worker";
22
22
  import { type Pallet } from "./diff.ts";
23
23
  import { isValidAlignStrategy, type AlignStrategy } from "./image.ts";
24
24
  import { withIndex } from "./iterable.ts";
25
+ import { perf } from "./perf.ts";
25
26
  import { parseHex, formatHex } from "./rgba-color.ts";
27
+ import { VERSION } from "./version.ts";
26
28
  import type { JimpInstance } from "./jimp.ts";
27
29
  import type {
30
+ ErrorMessage,
28
31
  InitMessage,
32
+ LoadedMessage,
29
33
  PageMessage,
30
34
  PageResultMessage,
31
35
  ReadyMessage,
32
36
  } from "./worker.ts";
33
37
 
34
- export { withIndex, isValidAlignStrategy, parseHex, formatHex };
38
+ export { withIndex, isValidAlignStrategy, parseHex, formatHex, perf };
35
39
 
36
40
  type Options = {
37
41
  dpi: number;
@@ -80,37 +84,53 @@ function asSharedBytes(bytes: Uint8Array): Uint8Array {
80
84
  return new Uint8Array(bytes);
81
85
  }
82
86
 
87
+ type WorkerResponse =
88
+ | LoadedMessage
89
+ | ReadyMessage
90
+ | PageResultMessage
91
+ | ErrorMessage;
92
+
83
93
  class WorkerHandle {
84
94
  worker: InstanceType<typeof Worker>;
85
- private pendingResolve:
86
- | ((data: ReadyMessage | PageResultMessage) => void)
87
- | null = null;
95
+ private loaded: Promise<void>;
96
+ private pendingResolve: ((data: WorkerResponse) => void) | null = null;
88
97
  private pendingReject: ((reason: unknown) => void) | null = null;
89
98
 
90
99
  constructor(url: URL) {
91
100
  this.worker = new Worker(url, { type: "module" });
92
- this.worker.addEventListener(
93
- "message",
94
- (e: MessageEvent<ReadyMessage | PageResultMessage>) => {
101
+ this.loaded = new Promise<void>((resolveLoaded, rejectLoaded) => {
102
+ const onMessage = (e: MessageEvent<WorkerResponse>) => {
103
+ const data = e.data;
104
+ if (data.type === "loaded") {
105
+ resolveLoaded();
106
+ return;
107
+ }
95
108
  const resolve = this.pendingResolve;
109
+ const reject = this.pendingReject;
96
110
  this.pendingResolve = null;
97
111
  this.pendingReject = null;
98
- resolve?.(e.data);
99
- },
100
- );
101
- this.worker.addEventListener("error", (e: ErrorEvent) => {
102
- const reject = this.pendingReject;
103
- this.pendingResolve = null;
104
- this.pendingReject = null;
105
- reject?.(e.error ?? new Error(e.message));
112
+ if (data.type === "error") {
113
+ reject?.(new Error(`worker: ${data.message}`));
114
+ } else {
115
+ resolve?.(data);
116
+ }
117
+ };
118
+ this.worker.addEventListener("message", onMessage);
119
+ this.worker.addEventListener("error", (e: ErrorEvent) => {
120
+ const err = e.error ?? new Error(e.message);
121
+ rejectLoaded(err);
122
+ const reject = this.pendingReject;
123
+ this.pendingResolve = null;
124
+ this.pendingReject = null;
125
+ reject?.(err);
126
+ });
106
127
  });
107
128
  }
108
129
 
109
- init(msg: InitMessage): Promise<ReadyMessage> {
130
+ async init(msg: InitMessage): Promise<ReadyMessage> {
131
+ await this.loaded;
110
132
  return new Promise<ReadyMessage>((resolve, reject) => {
111
- this.pendingResolve = resolve as (
112
- data: ReadyMessage | PageResultMessage,
113
- ) => void;
133
+ this.pendingResolve = resolve as (data: WorkerResponse) => void;
114
134
  this.pendingReject = reject;
115
135
  this.worker.postMessage(msg);
116
136
  });
@@ -118,9 +138,7 @@ class WorkerHandle {
118
138
 
119
139
  processPage(index: number): Promise<PageResultMessage> {
120
140
  return new Promise<PageResultMessage>((resolve, reject) => {
121
- this.pendingResolve = resolve as (
122
- data: ReadyMessage | PageResultMessage,
123
- ) => void;
141
+ this.pendingResolve = resolve as (data: WorkerResponse) => void;
124
142
  this.pendingReject = reject;
125
143
  const msg: PageMessage = { type: "page", index };
126
144
  this.worker.postMessage(msg);
@@ -133,14 +151,13 @@ class WorkerHandle {
133
151
  }
134
152
 
135
153
  function workerUrl(): URL {
136
- return new URL(
137
- import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js",
138
- import.meta.url,
139
- );
154
+ const file = import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js";
155
+ return new URL(`${file}?v=${encodeURIComponent(VERSION)}`, import.meta.url);
140
156
  }
141
157
 
142
158
  function pageResultToResult(msg: PageResultMessage): Result {
143
- return {
159
+ const sP = perf.span("main.pageResultToResult_ms");
160
+ const r = {
144
161
  a: jimp.Jimp.fromBitmap({
145
162
  width: msg.a.width,
146
163
  height: msg.a.height,
@@ -160,6 +177,10 @@ function pageResultToResult(msg: PageResultMessage): Result {
160
177
  deletion: msg.deletion,
161
178
  modification: msg.modification,
162
179
  };
180
+ sP.stop();
181
+ perf.incr("main.resultsReceived");
182
+ if (msg.perf) perf.merge(msg.perf);
183
+ return r;
163
184
  }
164
185
 
165
186
  export async function* visualizeDifferences(
@@ -244,6 +265,7 @@ export async function* visualizeDifferences(
244
265
  resolve(result);
245
266
  } else {
246
267
  buffered.set(idx, result);
268
+ perf.setMax("main.bufferedPeak", buffered.size);
247
269
  }
248
270
  } catch (e) {
249
271
  workerError = e;
@@ -263,10 +285,14 @@ export async function* visualizeDifferences(
263
285
  buffered.delete(i);
264
286
  r = buf;
265
287
  } else {
288
+ const sWait = perf.span("main.yieldWaitMain_ms");
266
289
  r = await new Promise<Result>((resolve) => resolvers.set(i, resolve));
290
+ sWait.stop();
267
291
  if (workerError !== null) throw workerError;
268
292
  }
293
+ const sYield = perf.span("main.consumerTime_ms");
269
294
  yield r;
295
+ sYield.stop();
270
296
  }
271
297
  await Promise.all(loops);
272
298
  } finally {
package/src/pdf.ts CHANGED
@@ -19,6 +19,7 @@ import * as jimp from "jimp";
19
19
  import * as mupdf from "mupdf";
20
20
 
21
21
  import type { JimpInstance } from "./jimp.ts";
22
+ import { perf } from "./perf.ts";
22
23
 
23
24
  export function* loadPages(pdf: mupdf.Document) {
24
25
  for (let i = 0; i < pdf.countPages(); i++) {
@@ -60,6 +61,7 @@ export async function pageToImage(
60
61
  alpha: boolean,
61
62
  ) {
62
63
  const zoom = dpi / 72;
64
+ const sToPixmap = perf.span("pdf.toPixmap_ms");
63
65
  const pixmap = page.toPixmap(
64
66
  [zoom, 0, 0, zoom, 0, 0],
65
67
  mupdf.ColorSpace.DeviceRGB,
@@ -67,8 +69,14 @@ export async function pageToImage(
67
69
  );
68
70
  const width = pixmap.getWidth();
69
71
  const height = pixmap.getHeight();
72
+ sToPixmap.stop();
73
+ const sRgba = perf.span("pdf.pixmapToRGBA_ms");
70
74
  const data = pixmapToRGBA(pixmap);
71
75
  pixmap.destroy();
72
76
  page.destroy();
73
- return jimp.Jimp.fromBitmap({ width, height, data }) as JimpInstance;
77
+ sRgba.stop();
78
+ const sFromBitmap = perf.span("pdf.fromBitmap_ms");
79
+ const result = jimp.Jimp.fromBitmap({ width, height, data }) as JimpInstance;
80
+ sFromBitmap.stop();
81
+ return result;
74
82
  }
package/src/perf.ts ADDED
@@ -0,0 +1,94 @@
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
+ const _enabled = (() => {
19
+ try {
20
+ if (
21
+ typeof process !== "undefined" &&
22
+ process.env &&
23
+ process.env.PDFDIFF_PROFILE === "1"
24
+ ) {
25
+ return true;
26
+ }
27
+ } catch {
28
+ // process not available (e.g. in some browser worker environments)
29
+ }
30
+ const g = globalThis as { __PDFDIFF_PROFILE__?: boolean };
31
+ return g.__PDFDIFF_PROFILE__ === true;
32
+ })();
33
+
34
+ export type Counters = Readonly<Record<string, number>>;
35
+
36
+ export type Span = { stop(): void };
37
+
38
+ type Perf = {
39
+ readonly enabled: boolean;
40
+ span(key: string): Span;
41
+ incr(key: string, delta?: number): void;
42
+ setMax(key: string, value: number): void;
43
+ merge(other: Counters): void;
44
+ dump(): Counters;
45
+ reset(): void;
46
+ };
47
+
48
+ const _counters: Record<string, number> = Object.create(null);
49
+
50
+ const _NOOP_SPAN: Span = Object.freeze({ stop() {} });
51
+ const _noop = () => {};
52
+ const _emptyDump = (): Counters => Object.freeze({});
53
+
54
+ const _realPerf: Perf = {
55
+ enabled: true,
56
+ span(key) {
57
+ const t0 = performance.now();
58
+ return {
59
+ stop() {
60
+ _counters[key] = (_counters[key] ?? 0) + (performance.now() - t0);
61
+ },
62
+ };
63
+ },
64
+ incr(key, delta = 1) {
65
+ _counters[key] = (_counters[key] ?? 0) + delta;
66
+ },
67
+ setMax(key, value) {
68
+ const cur = _counters[key];
69
+ if (cur === undefined || value > cur) _counters[key] = value;
70
+ },
71
+ merge(other) {
72
+ for (const k of Object.keys(other)) {
73
+ _counters[k] = (_counters[k] ?? 0) + other[k]!;
74
+ }
75
+ },
76
+ dump() {
77
+ return { ..._counters };
78
+ },
79
+ reset() {
80
+ for (const k of Object.keys(_counters)) delete _counters[k];
81
+ },
82
+ };
83
+
84
+ const _noopPerf: Perf = {
85
+ enabled: false,
86
+ span: () => _NOOP_SPAN,
87
+ incr: _noop,
88
+ setMax: _noop,
89
+ merge: _noop,
90
+ dump: _emptyDump,
91
+ reset: _noop,
92
+ };
93
+
94
+ export const perf: Perf = _enabled ? _realPerf : _noopPerf;
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,30 @@
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
+ /**
19
+ * Slice a typed-array view into a standalone backing buffer of the same kind
20
+ * (ArrayBuffer in, ArrayBuffer out; SharedArrayBuffer in, SharedArrayBuffer
21
+ * out). The buffer kind is preserved through the generic parameter.
22
+ */
23
+ export function sliceBackingBuffer<TArrayBuffer extends ArrayBufferLike>(
24
+ src: Uint8Array<TArrayBuffer> | Uint8ClampedArray<TArrayBuffer>,
25
+ ): TArrayBuffer {
26
+ return src.buffer.slice(
27
+ src.byteOffset,
28
+ src.byteOffset + src.byteLength,
29
+ ) as TArrayBuffer;
30
+ }
package/src/worker.ts CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  } from "./image.ts";
26
26
  import type { JimpInstance } from "./jimp.ts";
27
27
  import { pageToImage } from "./pdf.ts";
28
+ import { perf, type Counters } from "./perf.ts";
29
+ import { sliceBackingBuffer } from "./transferable.ts";
28
30
 
29
31
  export type InitMessage = {
30
32
  type: "init";
@@ -42,6 +44,10 @@ export type PageMessage = {
42
44
  index: number;
43
45
  };
44
46
 
47
+ export type LoadedMessage = {
48
+ type: "loaded";
49
+ };
50
+
45
51
  export type ReadyMessage = {
46
52
  type: "ready";
47
53
  };
@@ -55,6 +61,12 @@ export type PageResultMessage = {
55
61
  addition: [number, number][];
56
62
  deletion: [number, number][];
57
63
  modification: [number, number][];
64
+ perf?: Counters | undefined;
65
+ };
66
+
67
+ export type ErrorMessage = {
68
+ type: "error";
69
+ message: string;
58
70
  };
59
71
 
60
72
  let pdfA: mupdf.Document;
@@ -67,19 +79,8 @@ let opts: {
67
79
  align: AlignStrategy;
68
80
  };
69
81
 
70
- function toTransferable(
71
- src: Buffer | Uint8Array | Uint8ClampedArray | number[],
72
- ): ArrayBuffer {
73
- const view =
74
- src instanceof Uint8Array || src instanceof Uint8ClampedArray
75
- ? src
76
- : Uint8Array.from(src as ArrayLike<number>);
77
- const out = new ArrayBuffer(view.byteLength);
78
- new Uint8Array(out).set(view);
79
- return out;
80
- }
81
-
82
82
  async function processPage(index: number): Promise<PageResultMessage> {
83
+ const sLoad = perf.span("worker.pageToImageAll_ms");
83
84
  const [pageA, pageB, pageMask] = (await Promise.all([
84
85
  index < pdfA.countPages()
85
86
  ? pageToImage(pdfA.loadPage(index), opts.dpi, opts.alpha)
@@ -89,71 +90,93 @@ async function processPage(index: number): Promise<PageResultMessage> {
89
90
  : createEmptyImage(1, 1),
90
91
  index < pdfMask.countPages()
91
92
  ? pageToImage(pdfMask.loadPage(index), opts.dpi, opts.alpha)
92
- : createEmptyImage(1, 1),
93
- ])) as [JimpInstance, JimpInstance, JimpInstance];
93
+ : Promise.resolve(null),
94
+ ])) as [JimpInstance, JimpInstance, JimpInstance | null];
95
+ sLoad.stop();
94
96
 
97
+ const sDiff = perf.span("worker.drawDifference_ms");
95
98
  const {
96
99
  diff: diffLayer,
97
100
  addition,
98
101
  deletion,
99
102
  modification,
103
+ hasDiff,
100
104
  } = drawDifference(pageA, pageB, pageMask, opts.pallet, opts.align);
101
- const diff = composeLayers(pageA.width, pageA.height, [
105
+ sDiff.stop();
106
+
107
+ const sCompose = perf.span("worker.composeLayers_ms");
108
+ const layers: [JimpInstance, number][] = [
102
109
  [pageA, 0.2],
103
110
  [pageB, 0.2],
104
- [diffLayer, 1],
105
- ]);
111
+ ];
112
+ if (hasDiff) layers.push([diffLayer, 1]);
113
+ const diff = composeLayers(pageA.width, pageA.height, layers);
114
+ sCompose.stop();
115
+
116
+ const sXfer = perf.span("worker.toTransferable_ms");
117
+ const aBuf = sliceBackingBuffer(pageA.bitmap.data);
118
+ const bBuf = sliceBackingBuffer(pageB.bitmap.data);
119
+ const dBuf = sliceBackingBuffer(diff.bitmap.data);
120
+ sXfer.stop();
121
+ perf.incr("worker.pages");
122
+
123
+ let pagePerf: Counters | undefined;
124
+ if (perf.enabled) {
125
+ pagePerf = perf.dump();
126
+ perf.reset();
127
+ }
106
128
 
107
129
  return {
108
130
  type: "pageResult",
109
131
  index,
110
- a: {
111
- width: pageA.width,
112
- height: pageA.height,
113
- data: toTransferable(pageA.bitmap.data),
114
- },
115
- b: {
116
- width: pageB.width,
117
- height: pageB.height,
118
- data: toTransferable(pageB.bitmap.data),
119
- },
120
- diff: {
121
- width: diff.width,
122
- height: diff.height,
123
- data: toTransferable(diff.bitmap.data),
124
- },
132
+ a: { width: pageA.width, height: pageA.height, data: aBuf },
133
+ b: { width: pageB.width, height: pageB.height, data: bBuf },
134
+ diff: { width: diff.width, height: diff.height, data: dBuf },
125
135
  addition,
126
136
  deletion,
127
137
  modification,
138
+ perf: pagePerf,
128
139
  };
129
140
  }
130
141
 
131
142
  self.addEventListener(
132
143
  "message",
133
144
  async (e: MessageEvent<InitMessage | PageMessage>) => {
134
- const msg = e.data;
135
- if (msg.type === "init") {
136
- pdfA = mupdf.PDFDocument.openDocument(msg.aBytes, "application/pdf");
137
- pdfB = mupdf.PDFDocument.openDocument(msg.bBytes, "application/pdf");
138
- pdfMask = msg.maskBytes
139
- ? mupdf.PDFDocument.openDocument(msg.maskBytes, "application/pdf")
140
- : new mupdf.PDFDocument();
141
- opts = {
142
- dpi: msg.dpi,
143
- alpha: msg.alpha,
144
- pallet: msg.pallet,
145
- align: msg.align,
145
+ try {
146
+ const msg = e.data;
147
+ if (msg.type === "init") {
148
+ pdfA = mupdf.PDFDocument.openDocument(msg.aBytes, "application/pdf");
149
+ pdfB = mupdf.PDFDocument.openDocument(msg.bBytes, "application/pdf");
150
+ pdfMask = msg.maskBytes
151
+ ? mupdf.PDFDocument.openDocument(msg.maskBytes, "application/pdf")
152
+ : new mupdf.PDFDocument();
153
+ opts = {
154
+ dpi: msg.dpi,
155
+ alpha: msg.alpha,
156
+ pallet: msg.pallet,
157
+ align: msg.align,
158
+ };
159
+ if (pdfA.countPages() > 0) pdfA.loadPage(0).destroy();
160
+ const ready: ReadyMessage = { type: "ready" };
161
+ self.postMessage(ready);
162
+ } else if (msg.type === "page") {
163
+ const result = await processPage(msg.index);
164
+ self.postMessage(result, [
165
+ result.a.data,
166
+ result.b.data,
167
+ result.diff.data,
168
+ ]);
169
+ }
170
+ } catch (err) {
171
+ const errorMsg: ErrorMessage = {
172
+ type: "error",
173
+ message:
174
+ err instanceof Error ? `${err.message}\n${err.stack}` : String(err),
146
175
  };
147
- if (pdfA.countPages() > 0) pdfA.loadPage(0).destroy();
148
- const ready: ReadyMessage = { type: "ready" };
149
- self.postMessage(ready);
150
- } else if (msg.type === "page") {
151
- const result = await processPage(msg.index);
152
- self.postMessage(result, [
153
- result.a.data,
154
- result.b.data,
155
- result.diff.data,
156
- ]);
176
+ self.postMessage(errorMsg);
157
177
  }
158
178
  },
159
179
  );
180
+
181
+ const loaded: LoadedMessage = { type: "loaded" };
182
+ self.postMessage(loaded);