@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/.github/workflows/gh-pages.yml +6 -1
- package/dist/browser.js +1183 -7
- package/dist/browser.js.map +1 -1
- package/dist/cli-png-worker.d.ts +13 -0
- package/dist/cli-png-worker.d.ts.map +1 -0
- package/dist/cli-png-worker.js +303 -0
- package/dist/cli-png-worker.js.map +1 -0
- package/dist/cli.js +206 -5
- package/dist/cli.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/image.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +5 -0
- package/dist/index.js +90 -3
- package/dist/index.js.map +1 -1
- package/dist/pdf.d.ts.map +1 -1
- package/dist/perf.d.ts +16 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/squoosh_png_bg.wasm +0 -0
- package/dist/style.css +12 -0
- package/dist/transferable.d.ts +7 -0
- package/dist/transferable.d.ts.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +239 -64
- package/dist/worker.js.map +1 -1
- package/package.json +3 -1
- package/rollup.config.js +20 -0
- package/src/browser.ts +115 -5
- package/src/cli-png-worker.ts +59 -0
- package/src/cli.ts +104 -11
- package/src/diff.ts +99 -34
- package/src/image.ts +3 -0
- package/src/index.html +5 -0
- package/src/index.ts +13 -2
- package/src/pdf.ts +9 -1
- package/src/perf.ts +94 -0
- package/src/style.css +12 -0
- package/src/transferable.ts +30 -0
- package/src/worker.ts +34 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@u1f992/pdfdiff",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
|
|
129
|
-
addition.length + deletion.length + modification.length
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
aData[idx
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|