@u1f992/pdfdiff 0.2.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@u1f992/pdfdiff",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Visualize and quantify differences between two PDF files.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "@rollup/plugin-typescript": "^12.1.4",
33
33
  "@types/node": "^22.18.7",
34
34
  "coi-serviceworker": "^0.1.7",
35
+ "fflate": "^0.8.2",
35
36
  "http-server": "^14.1.1",
36
37
  "nodehog": "^0.1.2",
37
38
  "prettier": "^3.5.3",
@@ -41,6 +42,7 @@
41
42
  "typescript": "^5.9.2"
42
43
  },
43
44
  "dependencies": {
45
+ "@jsquash/png": "^3.1.1",
44
46
  "ix": "^7.0.0",
45
47
  "jimp": "^1.6.0",
46
48
  "mupdf": "^1.26.2",
package/rollup.config.js CHANGED
@@ -90,6 +90,26 @@ const rollupConfig = defineConfig([
90
90
  commonjs(),
91
91
  ],
92
92
  },
93
+ {
94
+ input: "src/cli-png-worker.ts",
95
+ output: {
96
+ file: "dist/cli-png-worker.js",
97
+ sourcemap: true,
98
+ },
99
+ plugins: [
100
+ typescript({ tsconfig: "./tsconfig.json" }),
101
+ nodeResolve(),
102
+ commonjs(),
103
+ copy({
104
+ targets: [
105
+ {
106
+ src: "node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm",
107
+ dest: "dist",
108
+ },
109
+ ],
110
+ }),
111
+ ],
112
+ },
93
113
  {
94
114
  input: "src/browser.ts",
95
115
  output: {
package/src/browser.ts CHANGED
@@ -1,11 +1,79 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
+ import encode from "@jsquash/png/encode";
4
+ import { zipSync } from "fflate";
5
+
3
6
  import * as pdfdiff from "./index.ts";
4
7
  import { VERSION } from "./version.ts";
5
8
 
9
+ async function encodeBitmapToPng(img: {
10
+ width: number;
11
+ height: number;
12
+ bitmap: { data: Uint8Array | Uint8ClampedArray };
13
+ }): Promise<Uint8Array> {
14
+ const data = img.bitmap.data;
15
+ const view =
16
+ data instanceof Uint8ClampedArray
17
+ ? data
18
+ : new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
19
+ const png = await encode(new ImageData(view, img.width, img.height));
20
+ return new Uint8Array(png);
21
+ }
22
+
6
23
  const versionEl = document.getElementById("version");
7
24
  if (versionEl) versionEl.textContent = "v" + VERSION;
8
25
 
26
+ const hideNoDiffEl = document.getElementById(
27
+ "hide-no-diff",
28
+ ) as HTMLInputElement | null;
29
+ const applyHideNoDiff = () => {
30
+ document.body.classList.toggle("hide-no-diff", !!hideNoDiffEl?.checked);
31
+ };
32
+ hideNoDiffEl?.addEventListener("change", applyHideNoDiff);
33
+ applyHideNoDiff();
34
+
35
+ const downloadButton = document.getElementById(
36
+ "download-zip",
37
+ ) as HTMLButtonElement | null;
38
+ type ResultPage = {
39
+ a: Uint8Array;
40
+ b: Uint8Array;
41
+ diff: Uint8Array;
42
+ hasDiff: boolean;
43
+ };
44
+ let lastResultPages: Map<number, ResultPage> | null = null;
45
+ const updateDownloadLabel = () => {
46
+ if (!downloadButton) return;
47
+ downloadButton.textContent = hideNoDiffEl?.checked
48
+ ? "Download zip (diff only)"
49
+ : "Download zip";
50
+ };
51
+ hideNoDiffEl?.addEventListener("change", updateDownloadLabel);
52
+ updateDownloadLabel();
53
+
54
+ downloadButton?.addEventListener("click", () => {
55
+ if (!lastResultPages) return;
56
+ const diffOnly = !!hideNoDiffEl?.checked;
57
+ const files: Record<string, Uint8Array> = {};
58
+ for (const [i, page] of lastResultPages) {
59
+ if (diffOnly && !page.hasDiff) continue;
60
+ files[`${i}/a.png`] = page.a;
61
+ files[`${i}/b.png`] = page.b;
62
+ files[`${i}/diff.png`] = page.diff;
63
+ }
64
+ if (Object.keys(files).length === 0) return;
65
+ const zipped = zipSync(files, { level: 0 });
66
+ const blob = new Blob([new Uint8Array(zipped)], { type: "application/zip" });
67
+ const url = URL.createObjectURL(blob);
68
+ const a = document.createElement("a");
69
+ a.href = url;
70
+ a.download = diffOnly ? "pdfdiff-result-diff-only.zip" : "pdfdiff-result.zip";
71
+ document.body.appendChild(a);
72
+ a.click();
73
+ document.body.removeChild(a);
74
+ URL.revokeObjectURL(url);
75
+ });
76
+
9
77
  async function readFileAsUint8Array(file: File): Promise<Uint8Array> {
10
78
  return new Promise((resolve, reject) => {
11
79
  const reader = new FileReader();
@@ -28,6 +96,19 @@ document
28
96
  const errorElement = document.getElementById("error-message");
29
97
  if (errorElement) errorElement.textContent = "";
30
98
 
99
+ const submitButton = (event.currentTarget as HTMLFormElement).querySelector(
100
+ 'button[type="submit"]',
101
+ ) as HTMLButtonElement | null;
102
+ const originalSubmitText = submitButton?.textContent ?? "";
103
+ if (submitButton) {
104
+ submitButton.disabled = true;
105
+ submitButton.textContent = "Preparing...";
106
+ }
107
+ if (downloadButton) downloadButton.disabled = true;
108
+ lastResultPages = null;
109
+ const resultPages = new Map<number, ResultPage>();
110
+ let completed = false;
111
+
31
112
  try {
32
113
  const pdfAFile = (
33
114
  document.getElementById("pdf-a") as HTMLInputElement | null
@@ -123,10 +204,13 @@ document
123
204
  pdfdiff.visualizeDifferences(pdfA, pdfB, options),
124
205
  1,
125
206
  )) {
207
+ if (submitButton) submitButton.textContent = `Page ${i}...`;
126
208
  const pageResult = document.createElement("details");
127
209
  pageResult.className = "diff-details";
128
- pageResult.open =
129
- addition.length + deletion.length + modification.length > 0;
210
+ const totalDiff =
211
+ addition.length + deletion.length + modification.length;
212
+ if (totalDiff === 0) pageResult.classList.add("no-diff");
213
+ pageResult.open = totalDiff > 0;
130
214
 
131
215
  const summary = document.createElement("summary");
132
216
 
@@ -165,23 +249,39 @@ document
165
249
 
166
250
  const imagesRow = document.createElement("tr");
167
251
 
252
+ const aPng = await encodeBitmapToPng(a);
253
+ const bPng = await encodeBitmapToPng(b);
254
+ const diffPng = await encodeBitmapToPng(diff);
255
+ resultPages.set(i, {
256
+ a: aPng,
257
+ b: bPng,
258
+ diff: diffPng,
259
+ hasDiff: totalDiff > 0,
260
+ });
261
+
168
262
  const cellA = document.createElement("td");
169
263
  const imageA = document.createElement("img");
170
- imageA.src = await a.getBase64("image/png");
264
+ imageA.src = URL.createObjectURL(
265
+ new Blob([new Uint8Array(aPng)], { type: "image/png" }),
266
+ );
171
267
  imageA.className = "checkerboard-bg";
172
268
  cellA.appendChild(imageA);
173
269
  imagesRow.appendChild(cellA);
174
270
 
175
271
  const cellB = document.createElement("td");
176
272
  const imageB = document.createElement("img");
177
- imageB.src = await b.getBase64("image/png");
273
+ imageB.src = URL.createObjectURL(
274
+ new Blob([new Uint8Array(bPng)], { type: "image/png" }),
275
+ );
178
276
  imageB.className = "checkerboard-bg";
179
277
  cellB.appendChild(imageB);
180
278
  imagesRow.appendChild(cellB);
181
279
 
182
280
  const cellDiff = document.createElement("td");
183
281
  const imageDiff = document.createElement("img");
184
- imageDiff.src = await diff.getBase64("image/png");
282
+ imageDiff.src = URL.createObjectURL(
283
+ new Blob([new Uint8Array(diffPng)], { type: "image/png" }),
284
+ );
185
285
  imageDiff.className = "checkerboard-bg";
186
286
  cellDiff.appendChild(imageDiff);
187
287
  imagesRow.appendChild(cellDiff);
@@ -191,10 +291,20 @@ document
191
291
  pageResult.appendChild(imagesTable);
192
292
  resultsContainer?.appendChild(pageResult);
193
293
  }
294
+ completed = true;
194
295
  } catch (e) {
195
296
  console.error(e);
196
297
  if (errorElement) {
197
298
  errorElement.textContent = `Error: ${(e as Error).message}`;
198
299
  }
300
+ } finally {
301
+ if (submitButton) {
302
+ submitButton.disabled = false;
303
+ submitButton.textContent = originalSubmitText;
304
+ }
305
+ if (completed && resultPages.size > 0) {
306
+ lastResultPages = resultPages;
307
+ if (downloadButton) downloadButton.disabled = false;
308
+ }
199
309
  }
200
310
  });
@@ -0,0 +1,59 @@
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 fs from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ import { parentPort } from "node:worker_threads";
21
+
22
+ import encode, { init } from "@jsquash/png/encode";
23
+
24
+ export type EncodeJob = {
25
+ width: number;
26
+ height: number;
27
+ data: ArrayBuffer;
28
+ path: string;
29
+ };
30
+
31
+ export type EncodeReply = { ok: true } | { ok: false; error: string };
32
+
33
+ const wasmPath = fileURLToPath(
34
+ new URL("./squoosh_png_bg.wasm", import.meta.url),
35
+ );
36
+ await init(fs.readFileSync(wasmPath));
37
+
38
+ if (!parentPort) {
39
+ throw new Error("cli-png-worker must be run as a worker_threads worker");
40
+ }
41
+
42
+ const port = parentPort;
43
+
44
+ port.on("message", async (job: EncodeJob) => {
45
+ try {
46
+ const png = await encode(
47
+ new ImageData(new Uint8ClampedArray(job.data), job.width, job.height),
48
+ );
49
+ fs.writeFileSync(job.path, new Uint8Array(png));
50
+ const reply: EncodeReply = { ok: true };
51
+ port.postMessage(reply);
52
+ } catch (e) {
53
+ const reply: EncodeReply = {
54
+ ok: false,
55
+ error: e instanceof Error ? `${e.message}\n${e.stack}` : String(e),
56
+ };
57
+ port.postMessage(reply);
58
+ }
59
+ });
package/src/cli.ts CHANGED
@@ -20,7 +20,9 @@
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import util from "node:util";
23
+ import { Worker as ThreadWorker } from "node:worker_threads";
23
24
 
25
+ import type { EncodeJob, EncodeReply } from "./cli-png-worker.ts";
24
26
  import {
25
27
  isValidAlignStrategy,
26
28
  defaultOptions,
@@ -28,9 +30,65 @@ import {
28
30
  parseHex,
29
31
  formatHex,
30
32
  visualizeDifferences,
33
+ perf,
31
34
  } from "./index.ts";
35
+ import { sliceBackingBuffer } from "./transferable.ts";
32
36
  import { VERSION } from "./version.ts";
33
37
 
38
+ class PngWriterPool {
39
+ private readonly workers: ThreadWorker[] = [];
40
+ private readonly idle: ThreadWorker[] = [];
41
+ private readonly waiting: Array<(w: ThreadWorker) => void> = [];
42
+
43
+ constructor(size: number, scriptUrl: URL) {
44
+ for (let i = 0; i < size; i++) {
45
+ const w = new ThreadWorker(scriptUrl);
46
+ this.workers.push(w);
47
+ this.idle.push(w);
48
+ }
49
+ }
50
+
51
+ private acquire(): Promise<ThreadWorker> {
52
+ const w = this.idle.pop();
53
+ if (w) return Promise.resolve(w);
54
+ return new Promise<ThreadWorker>((resolve) => this.waiting.push(resolve));
55
+ }
56
+
57
+ private release(w: ThreadWorker) {
58
+ const next = this.waiting.shift();
59
+ if (next) next(w);
60
+ else this.idle.push(w);
61
+ }
62
+
63
+ async submit(job: EncodeJob): Promise<void> {
64
+ const w = await this.acquire();
65
+ return new Promise<void>((resolve, reject) => {
66
+ const onMessage = (msg: EncodeReply) => {
67
+ w.off("message", onMessage);
68
+ w.off("error", onError);
69
+ this.release(w);
70
+ if (msg.ok) resolve();
71
+ else reject(new Error(msg.error));
72
+ };
73
+ const onError = (err: Error) => {
74
+ w.off("message", onMessage);
75
+ w.off("error", onError);
76
+ this.release(w);
77
+ reject(err);
78
+ };
79
+ w.on("message", onMessage);
80
+ w.once("error", onError);
81
+ w.postMessage(job, [job.data]);
82
+ });
83
+ }
84
+
85
+ async terminate(): Promise<void> {
86
+ await Promise.all(this.workers.map((w) => w.terminate()));
87
+ }
88
+ }
89
+
90
+ const _wallSpan = perf.span("cli.wallTotal_ms");
91
+
34
92
  const {
35
93
  positionals,
36
94
  values: {
@@ -149,6 +207,13 @@ if (Number.isNaN(workers) || workers < 1) {
149
207
  }
150
208
 
151
209
  fs.mkdirSync(outDir, { recursive: true });
210
+ const writerPool = new PngWriterPool(
211
+ workers,
212
+ new URL("./cli-png-worker.js", import.meta.url),
213
+ );
214
+ const pendingWrites: Promise<void>[] = [];
215
+
216
+ const _loopSpan = perf.span("cli.loopWall_ms");
152
217
  for await (const [
153
218
  i,
154
219
  { a, b, diff, addition, deletion, modification },
@@ -172,16 +237,44 @@ for await (const [
172
237
  );
173
238
  const dir = path.join(outDir, i.toString(10));
174
239
  fs.mkdirSync(dir, { recursive: true });
175
- fs.writeFileSync(
176
- path.join(dir, "a.png"),
177
- new Uint8Array(await a.getBuffer("image/png")),
178
- );
179
- fs.writeFileSync(
180
- path.join(dir, "b.png"),
181
- new Uint8Array(await b.getBuffer("image/png")),
182
- );
183
- fs.writeFileSync(
184
- path.join(dir, "diff.png"),
185
- new Uint8Array(await diff.getBuffer("image/png")),
240
+ const sSubmit = perf.span("cli.poolSubmit_ms");
241
+ const aBuf = sliceBackingBuffer(a.bitmap.data);
242
+ const bBuf = sliceBackingBuffer(b.bitmap.data);
243
+ const dBuf = sliceBackingBuffer(diff.bitmap.data);
244
+ pendingWrites.push(
245
+ writerPool.submit({
246
+ width: a.width,
247
+ height: a.height,
248
+ data: aBuf,
249
+ path: path.join(dir, "a.png"),
250
+ }),
251
+ writerPool.submit({
252
+ width: b.width,
253
+ height: b.height,
254
+ data: bBuf,
255
+ path: path.join(dir, "b.png"),
256
+ }),
257
+ writerPool.submit({
258
+ width: diff.width,
259
+ height: diff.height,
260
+ data: dBuf,
261
+ path: path.join(dir, "diff.png"),
262
+ }),
186
263
  );
264
+ sSubmit.stop();
265
+ }
266
+ const sDrain = perf.span("cli.poolDrain_ms");
267
+ await Promise.all(pendingWrites);
268
+ sDrain.stop();
269
+ await writerPool.terminate();
270
+ _loopSpan.stop();
271
+ _wallSpan.stop();
272
+
273
+ if (perf.enabled) {
274
+ const counters = perf.dump();
275
+ process.stderr.write("\n=== PERF ===\n");
276
+ const keys = Object.keys(counters).sort();
277
+ const out: Record<string, number> = {};
278
+ for (const k of keys) out[k] = Math.round(counters[k]! * 1000) / 1000;
279
+ process.stderr.write(JSON.stringify(out, null, 2) + "\n");
187
280
  }
package/src/diff.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { type JimpInstance } from "./jimp.ts";
19
19
  import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
20
+ import { perf } from "./perf.ts";
20
21
  import { type RGBAColor } from "./rgba-color.ts";
21
22
 
22
23
  export type Pallet = {
@@ -28,58 +29,122 @@ export type Pallet = {
28
29
  export function drawDifference(
29
30
  a: JimpInstance,
30
31
  b: JimpInstance,
31
- mask: JimpInstance,
32
+ mask: JimpInstance | null,
32
33
  pallet: Readonly<Pallet>,
33
34
  align: AlignStrategy,
34
35
  ) {
35
- const [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
36
+ const sAlign = perf.span("diff.align_ms");
37
+ let aNew: JimpInstance;
38
+ let bNew: JimpInstance;
39
+ let maskNew: JimpInstance | null;
40
+ if (mask !== null) {
41
+ [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
42
+ } else {
43
+ [aNew, bNew] = alignSize([a, b], align);
44
+ maskNew = null;
45
+ }
46
+ sAlign.stop();
47
+
36
48
  const width = aNew.width;
37
49
  const height = aNew.height;
38
50
  const aData = aNew.bitmap.data;
39
51
  const bData = bNew.bitmap.data;
40
- const mData = maskNew.bitmap.data;
52
+ const mData = maskNew !== null ? maskNew.bitmap.data : null;
41
53
 
54
+ const sCreate = perf.span("diff.createEmpty_ms");
42
55
  const diffImage = createEmptyImage(width, height);
43
56
  const dData = diffImage.bitmap.data;
57
+ sCreate.stop();
44
58
 
45
59
  const addition: [number, number][] = [];
46
60
  const deletion: [number, number][] = [];
47
61
  const modification: [number, number][] = [];
48
62
 
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;
63
+ const sScan = perf.span("diff.scan_ms");
64
+ let diffPixels = 0;
65
+
66
+ if (mData !== null) {
67
+ for (let x = 0; x < width; x++) {
68
+ for (let y = 0; y < height; y++) {
69
+ const idx = (y * width + x) * 4;
70
+ if (mData[idx + 3]! !== 0) continue;
71
+ const aAlpha = aData[idx + 3]!;
72
+ const bAlpha = bData[idx + 3]!;
73
+ if (
74
+ aAlpha === bAlpha &&
75
+ aData[idx] === bData[idx] &&
76
+ aData[idx + 1] === bData[idx + 1] &&
77
+ aData[idx + 2] === bData[idx + 2]
78
+ ) {
79
+ continue;
80
+ }
81
+ if (aAlpha === 0 && bAlpha === 0) continue;
82
+ let target: [number, number][];
83
+ let color: Readonly<RGBAColor>;
84
+ if (aAlpha === 0) {
85
+ target = addition;
86
+ color = pallet.addition;
87
+ } else if (bAlpha === 0) {
88
+ target = deletion;
89
+ color = pallet.deletion;
90
+ } else {
91
+ target = modification;
92
+ color = pallet.modification;
93
+ }
94
+ target.push([x, y]);
95
+ diffPixels++;
96
+ dData[idx] = color[0];
97
+ dData[idx + 1] = color[1];
98
+ dData[idx + 2] = color[2];
99
+ dData[idx + 3] = color[3];
62
100
  }
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;
101
+ }
102
+ } else {
103
+ for (let x = 0; x < width; x++) {
104
+ for (let y = 0; y < height; y++) {
105
+ const idx = (y * width + x) * 4;
106
+ const aAlpha = aData[idx + 3]!;
107
+ const bAlpha = bData[idx + 3]!;
108
+ if (
109
+ aAlpha === bAlpha &&
110
+ aData[idx] === bData[idx] &&
111
+ aData[idx + 1] === bData[idx + 1] &&
112
+ aData[idx + 2] === bData[idx + 2]
113
+ ) {
114
+ continue;
115
+ }
116
+ if (aAlpha === 0 && bAlpha === 0) continue;
117
+ let target: [number, number][];
118
+ let color: Readonly<RGBAColor>;
119
+ if (aAlpha === 0) {
120
+ target = addition;
121
+ color = pallet.addition;
122
+ } else if (bAlpha === 0) {
123
+ target = deletion;
124
+ color = pallet.deletion;
125
+ } else {
126
+ target = modification;
127
+ color = pallet.modification;
128
+ }
129
+ target.push([x, y]);
130
+ diffPixels++;
131
+ dData[idx] = color[0];
132
+ dData[idx + 1] = color[1];
133
+ dData[idx + 2] = color[2];
134
+ dData[idx + 3] = color[3];
75
135
  }
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
136
  }
82
137
  }
138
+ sScan.stop();
139
+ perf.incr("diff.diffPixels", diffPixels);
140
+ perf.incr("diff.totalPixels", width * height);
141
+ perf.incr("diff.pages");
83
142
 
84
- return { diff: diffImage, addition, deletion, modification };
143
+ return {
144
+ diff: diffImage,
145
+ addition,
146
+ deletion,
147
+ modification,
148
+ hasDiff: diffPixels > 0,
149
+ };
85
150
  }
package/src/image.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import * as jimp from "jimp";
19
19
  import { type JimpInstance } from "./jimp.ts";
20
+ import { perf } from "./perf.ts";
20
21
 
21
22
  export function createEmptyImage(width: number, height: number) {
22
23
  return new jimp.Jimp({
@@ -117,6 +118,7 @@ export function composeLayers(
117
118
  canvasHeight: number,
118
119
  layers: [JimpInstance, number][],
119
120
  ) {
121
+ const _span = perf.span("image.compose_ms");
120
122
  const canvas = createEmptyImage(canvasWidth, canvasHeight);
121
123
  const dData = canvas.bitmap.data;
122
124
  for (const [image, opacity] of layers) {
@@ -146,5 +148,6 @@ export function composeLayers(
146
148
  }
147
149
  }
148
150
  }
151
+ _span.stop();
149
152
  return canvas;
150
153
  }
package/src/index.html CHANGED
@@ -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,6 +22,7 @@ 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";
26
27
  import { VERSION } from "./version.ts";
27
28
  import type { JimpInstance } from "./jimp.ts";
@@ -34,7 +35,7 @@ import type {
34
35
  ReadyMessage,
35
36
  } from "./worker.ts";
36
37
 
37
- export { withIndex, isValidAlignStrategy, parseHex, formatHex };
38
+ export { withIndex, isValidAlignStrategy, parseHex, formatHex, perf };
38
39
 
39
40
  type Options = {
40
41
  dpi: number;
@@ -155,7 +156,8 @@ function workerUrl(): URL {
155
156
  }
156
157
 
157
158
  function pageResultToResult(msg: PageResultMessage): Result {
158
- return {
159
+ const sP = perf.span("main.pageResultToResult_ms");
160
+ const r = {
159
161
  a: jimp.Jimp.fromBitmap({
160
162
  width: msg.a.width,
161
163
  height: msg.a.height,
@@ -175,6 +177,10 @@ function pageResultToResult(msg: PageResultMessage): Result {
175
177
  deletion: msg.deletion,
176
178
  modification: msg.modification,
177
179
  };
180
+ sP.stop();
181
+ perf.incr("main.resultsReceived");
182
+ if (msg.perf) perf.merge(msg.perf);
183
+ return r;
178
184
  }
179
185
 
180
186
  export async function* visualizeDifferences(
@@ -259,6 +265,7 @@ export async function* visualizeDifferences(
259
265
  resolve(result);
260
266
  } else {
261
267
  buffered.set(idx, result);
268
+ perf.setMax("main.bufferedPeak", buffered.size);
262
269
  }
263
270
  } catch (e) {
264
271
  workerError = e;
@@ -278,10 +285,14 @@ export async function* visualizeDifferences(
278
285
  buffered.delete(i);
279
286
  r = buf;
280
287
  } else {
288
+ const sWait = perf.span("main.yieldWaitMain_ms");
281
289
  r = await new Promise<Result>((resolve) => resolvers.set(i, resolve));
290
+ sWait.stop();
282
291
  if (workerError !== null) throw workerError;
283
292
  }
293
+ const sYield = perf.span("main.consumerTime_ms");
284
294
  yield r;
295
+ sYield.stop();
285
296
  }
286
297
  await Promise.all(loops);
287
298
  } finally {