@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@u1f992/pdfdiff",
3
- "version": "0.2.0",
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": "rollup -c",
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
- pageResult.open =
125
- 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;
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 = await a.getBase64("image/png");
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 = await b.getBase64("image/png");
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 = await diff.getBase64("image/png");
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
- try {
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
- fs.writeFileSync(
184
- path.join(dir, "a.png"),
185
- new Uint8Array(await a.getBuffer("image/png")),
186
- );
187
- fs.writeFileSync(
188
- path.join(dir, "b.png"),
189
- new Uint8Array(await b.getBuffer("image/png")),
190
- );
191
- fs.writeFileSync(
192
- path.join(dir, "diff.png"),
193
- 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
+ }),
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 [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
  }