@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.
- package/.github/workflows/gh-pages.yml +6 -1
- package/dist/browser.js +1219 -19
- 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 +240 -26
- 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 +6 -1
- package/dist/index.js +123 -15
- 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 +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/worker.d.ts +9 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +274 -88
- package/dist/worker.js.map +1 -1
- package/package.json +5 -2
- package/rollup.config.js +20 -0
- package/scripts/version.ts +35 -0
- package/src/browser.ts +119 -5
- package/src/cli-png-worker.ts +59 -0
- package/src/cli.ts +106 -21
- package/src/diff.ts +99 -34
- package/src/image.ts +3 -0
- package/src/index.html +6 -1
- package/src/index.ts +53 -27
- 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 +77 -54
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": {
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test",
|
|
11
11
|
"test:cli": "node src/cli.js test/a.pdf test/b.pdf out --mask test/mask.pdf --dpi 300 && echo \"expected: Page 1, Addition: 7500, Deletion: 7500, Modification: 7500\"",
|
|
12
|
-
"build": "
|
|
12
|
+
"build:version": "node scripts/version.ts",
|
|
13
|
+
"build": "npm run build:version && rollup -c",
|
|
13
14
|
"serve": "npm run build && http-server dist"
|
|
14
15
|
},
|
|
15
16
|
"repository": {
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"@rollup/plugin-typescript": "^12.1.4",
|
|
32
33
|
"@types/node": "^22.18.7",
|
|
33
34
|
"coi-serviceworker": "^0.1.7",
|
|
35
|
+
"fflate": "^0.8.2",
|
|
34
36
|
"http-server": "^14.1.1",
|
|
35
37
|
"nodehog": "^0.1.2",
|
|
36
38
|
"prettier": "^3.5.3",
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
"typescript": "^5.9.2"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
45
|
+
"@jsquash/png": "^3.1.1",
|
|
43
46
|
"ix": "^7.0.0",
|
|
44
47
|
"jimp": "^1.6.0",
|
|
45
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: {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const version = JSON.parse(readFileSync("package.json", "utf-8")).version;
|
|
5
|
+
|
|
6
|
+
let suffix = "";
|
|
7
|
+
try {
|
|
8
|
+
execSync(`git describe --tags --match "v${version}" --exact-match`, {
|
|
9
|
+
stdio: "ignore",
|
|
10
|
+
});
|
|
11
|
+
} catch {
|
|
12
|
+
try {
|
|
13
|
+
const hash = execSync("git rev-parse --short HEAD", {
|
|
14
|
+
encoding: "utf-8",
|
|
15
|
+
}).trim();
|
|
16
|
+
suffix = `+${hash}`;
|
|
17
|
+
} catch {
|
|
18
|
+
// not in a git repo
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const dirty = execSync("git status --porcelain", {
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
}).trim();
|
|
26
|
+
if (dirty) {
|
|
27
|
+
suffix += ".dirty";
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// not in a git repo
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const full = version + suffix;
|
|
34
|
+
writeFileSync("src/version.ts", `export const VERSION = "${full}";\n`, "utf-8");
|
|
35
|
+
console.log(full);
|
package/src/browser.ts
CHANGED
|
@@ -1,6 +1,78 @@
|
|
|
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";
|
|
7
|
+
import { VERSION } from "./version.ts";
|
|
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
|
+
|
|
23
|
+
const versionEl = document.getElementById("version");
|
|
24
|
+
if (versionEl) versionEl.textContent = "v" + VERSION;
|
|
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
|
+
});
|
|
4
76
|
|
|
5
77
|
async function readFileAsUint8Array(file: File): Promise<Uint8Array> {
|
|
6
78
|
return new Promise((resolve, reject) => {
|
|
@@ -24,6 +96,19 @@ document
|
|
|
24
96
|
const errorElement = document.getElementById("error-message");
|
|
25
97
|
if (errorElement) errorElement.textContent = "";
|
|
26
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
|
+
|
|
27
112
|
try {
|
|
28
113
|
const pdfAFile = (
|
|
29
114
|
document.getElementById("pdf-a") as HTMLInputElement | null
|
|
@@ -119,10 +204,13 @@ document
|
|
|
119
204
|
pdfdiff.visualizeDifferences(pdfA, pdfB, options),
|
|
120
205
|
1,
|
|
121
206
|
)) {
|
|
207
|
+
if (submitButton) submitButton.textContent = `Page ${i}...`;
|
|
122
208
|
const pageResult = document.createElement("details");
|
|
123
209
|
pageResult.className = "diff-details";
|
|
124
|
-
|
|
125
|
-
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;
|
|
126
214
|
|
|
127
215
|
const summary = document.createElement("summary");
|
|
128
216
|
|
|
@@ -161,23 +249,39 @@ document
|
|
|
161
249
|
|
|
162
250
|
const imagesRow = document.createElement("tr");
|
|
163
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
|
+
|
|
164
262
|
const cellA = document.createElement("td");
|
|
165
263
|
const imageA = document.createElement("img");
|
|
166
|
-
imageA.src =
|
|
264
|
+
imageA.src = URL.createObjectURL(
|
|
265
|
+
new Blob([new Uint8Array(aPng)], { type: "image/png" }),
|
|
266
|
+
);
|
|
167
267
|
imageA.className = "checkerboard-bg";
|
|
168
268
|
cellA.appendChild(imageA);
|
|
169
269
|
imagesRow.appendChild(cellA);
|
|
170
270
|
|
|
171
271
|
const cellB = document.createElement("td");
|
|
172
272
|
const imageB = document.createElement("img");
|
|
173
|
-
imageB.src =
|
|
273
|
+
imageB.src = URL.createObjectURL(
|
|
274
|
+
new Blob([new Uint8Array(bPng)], { type: "image/png" }),
|
|
275
|
+
);
|
|
174
276
|
imageB.className = "checkerboard-bg";
|
|
175
277
|
cellB.appendChild(imageB);
|
|
176
278
|
imagesRow.appendChild(cellB);
|
|
177
279
|
|
|
178
280
|
const cellDiff = document.createElement("td");
|
|
179
281
|
const imageDiff = document.createElement("img");
|
|
180
|
-
imageDiff.src =
|
|
282
|
+
imageDiff.src = URL.createObjectURL(
|
|
283
|
+
new Blob([new Uint8Array(diffPng)], { type: "image/png" }),
|
|
284
|
+
);
|
|
181
285
|
imageDiff.className = "checkerboard-bg";
|
|
182
286
|
cellDiff.appendChild(imageDiff);
|
|
183
287
|
imagesRow.appendChild(cellDiff);
|
|
@@ -187,10 +291,20 @@ document
|
|
|
187
291
|
pageResult.appendChild(imagesTable);
|
|
188
292
|
resultsContainer?.appendChild(pageResult);
|
|
189
293
|
}
|
|
294
|
+
completed = true;
|
|
190
295
|
} catch (e) {
|
|
191
296
|
console.error(e);
|
|
192
297
|
if (errorElement) {
|
|
193
298
|
errorElement.textContent = `Error: ${(e as Error).message}`;
|
|
194
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
|
+
}
|
|
195
309
|
}
|
|
196
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,7 +30,64 @@ import {
|
|
|
28
30
|
parseHex,
|
|
29
31
|
formatHex,
|
|
30
32
|
visualizeDifferences,
|
|
33
|
+
perf,
|
|
31
34
|
} from "./index.ts";
|
|
35
|
+
import { sliceBackingBuffer } from "./transferable.ts";
|
|
36
|
+
import { VERSION } from "./version.ts";
|
|
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");
|
|
32
91
|
|
|
33
92
|
const {
|
|
34
93
|
positionals,
|
|
@@ -89,16 +148,7 @@ NOTES:
|
|
|
89
148
|
process.exit(0);
|
|
90
149
|
}
|
|
91
150
|
if (version) {
|
|
92
|
-
|
|
93
|
-
const versionStr = JSON.parse(
|
|
94
|
-
fs.readFileSync(new URL("../package.json", import.meta.url), {
|
|
95
|
-
encoding: "utf-8",
|
|
96
|
-
}),
|
|
97
|
-
).version;
|
|
98
|
-
console.log(versionStr);
|
|
99
|
-
} catch {
|
|
100
|
-
console.log("unknown");
|
|
101
|
-
}
|
|
151
|
+
console.log(VERSION);
|
|
102
152
|
process.exit(0);
|
|
103
153
|
}
|
|
104
154
|
|
|
@@ -157,6 +207,13 @@ if (Number.isNaN(workers) || workers < 1) {
|
|
|
157
207
|
}
|
|
158
208
|
|
|
159
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");
|
|
160
217
|
for await (const [
|
|
161
218
|
i,
|
|
162
219
|
{ a, b, diff, addition, deletion, modification },
|
|
@@ -180,16 +237,44 @@ for await (const [
|
|
|
180
237
|
);
|
|
181
238
|
const dir = path.join(outDir, i.toString(10));
|
|
182
239
|
fs.mkdirSync(dir, { recursive: true });
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}),
|
|
194
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");
|
|
195
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
|
}
|