@u1f992/pdfdiff 0.1.0 → 0.2.0

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.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Visualize and quantify differences between two PDF files.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "@rollup/plugin-node-resolve": "^16.0.1",
31
31
  "@rollup/plugin-typescript": "^12.1.4",
32
32
  "@types/node": "^22.18.7",
33
+ "coi-serviceworker": "^0.1.7",
33
34
  "http-server": "^14.1.1",
34
35
  "nodehog": "^0.1.2",
35
36
  "prettier": "^3.5.3",
package/rollup.config.js CHANGED
@@ -15,6 +15,10 @@ const plugins = [
15
15
  src: "node_modules/mupdf/dist/mupdf-wasm.wasm",
16
16
  dest: "dist",
17
17
  },
18
+ {
19
+ src: "node_modules/coi-serviceworker/coi-serviceworker.min.js",
20
+ dest: "dist",
21
+ },
18
22
  {
19
23
  src: "src/index.html",
20
24
  dest: "dist",
@@ -27,6 +31,26 @@ const plugins = [
27
31
  }),
28
32
  ];
29
33
 
34
+ const jimpAlias = alias({
35
+ entries: [
36
+ {
37
+ find: "jimp",
38
+ replacement: path.resolve("node_modules/jimp/dist/browser/index.js"),
39
+ },
40
+ ],
41
+ });
42
+
43
+ const webWorkerAlias = alias({
44
+ entries: [
45
+ {
46
+ find: "web-worker",
47
+ replacement: path.resolve(
48
+ "node_modules/web-worker/dist/browser/index.cjs",
49
+ ),
50
+ },
51
+ ],
52
+ });
53
+
30
54
  const rollupConfig = defineConfig([
31
55
  {
32
56
  input: "src/index.ts",
@@ -35,22 +59,8 @@ const rollupConfig = defineConfig([
35
59
  sourcemap: true,
36
60
  },
37
61
  plugins: [
38
- alias({
39
- entries: [
40
- {
41
- find: "jimp",
42
- replacement: path.resolve(
43
- "node_modules/jimp/dist/browser/index.js",
44
- ),
45
- },
46
- {
47
- find: "web-worker",
48
- replacement: path.resolve(
49
- "node_modules/web-worker/dist/browser/index.cjs",
50
- ),
51
- },
52
- ],
53
- }),
62
+ jimpAlias,
63
+ webWorkerAlias,
54
64
  typescript({ tsconfig: "./tsconfig.json" }),
55
65
  ...plugins,
56
66
  ],
@@ -62,16 +72,7 @@ const rollupConfig = defineConfig([
62
72
  sourcemap: true,
63
73
  },
64
74
  plugins: [
65
- alias({
66
- entries: [
67
- {
68
- find: "jimp",
69
- replacement: path.resolve(
70
- "node_modules/jimp/dist/browser/index.js",
71
- ),
72
- },
73
- ],
74
- }),
75
+ jimpAlias,
75
76
  typescript({ tsconfig: "./tsconfig.json" }),
76
77
  ...plugins,
77
78
  ],
@@ -96,22 +97,8 @@ const rollupConfig = defineConfig([
96
97
  sourcemap: true,
97
98
  },
98
99
  plugins: [
99
- alias({
100
- entries: [
101
- {
102
- find: "jimp",
103
- replacement: path.resolve(
104
- "node_modules/jimp/dist/browser/index.js",
105
- ),
106
- },
107
- {
108
- find: "web-worker",
109
- replacement: path.resolve(
110
- "node_modules/web-worker/dist/browser/index.cjs",
111
- ),
112
- },
113
- ],
114
- }),
100
+ jimpAlias,
101
+ webWorkerAlias,
115
102
  typescript({ tsconfig: "./tsconfig.json" }),
116
103
  ...plugins,
117
104
  ],
package/src/browser.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
- import * as pdfdiff from "./index.js";
3
+ import * as pdfdiff from "./index.ts";
4
4
 
5
5
  async function readFileAsUint8Array(file: File): Promise<Uint8Array> {
6
6
  return new Promise((resolve, reject) => {
@@ -62,6 +62,17 @@ document
62
62
  throw new Error();
63
63
  }
64
64
 
65
+ const workers = ((
66
+ val = (document.getElementById("workers") as HTMLInputElement | null)
67
+ ?.value,
68
+ ) => (typeof val !== "undefined" ? parseInt(val, 10) : undefined))();
69
+ if (
70
+ typeof workers !== "undefined" &&
71
+ (Number.isNaN(workers) || workers < 1)
72
+ ) {
73
+ throw new Error();
74
+ }
75
+
65
76
  const additionColorHex = (
66
77
  document.getElementById("addition-color") as HTMLInputElement | null
67
78
  )?.value;
@@ -93,6 +104,7 @@ document
93
104
  if (alpha !== undefined) options.alpha = alpha;
94
105
  if (pdfMask !== undefined) options.mask = pdfMask;
95
106
  if (align !== undefined) options.align = align;
107
+ if (workers !== undefined) options.workers = workers;
96
108
  if (additionColor || deletionColor || modificationColor) {
97
109
  options.pallet = {};
98
110
  if (additionColor) options.pallet.addition = additionColor;
package/src/cli.ts CHANGED
@@ -28,7 +28,7 @@ import {
28
28
  parseHex,
29
29
  formatHex,
30
30
  visualizeDifferences,
31
- } from "./index.js";
31
+ } from "./index.ts";
32
32
 
33
33
  const {
34
34
  positionals,
@@ -40,6 +40,7 @@ const {
40
40
  "addition-color": additionColorHex,
41
41
  "deletion-color": deletionColorHex,
42
42
  "modification-color": modificationColorHex,
43
+ workers: workers_,
43
44
  version,
44
45
  help,
45
46
  },
@@ -53,6 +54,7 @@ const {
53
54
  "addition-color": { type: "string" },
54
55
  "deletion-color": { type: "string" },
55
56
  "modification-color": { type: "string" },
57
+ workers: { type: "string" },
56
58
  version: { type: "boolean", short: "v" },
57
59
  help: { type: "boolean", short: "h" },
58
60
  },
@@ -72,8 +74,17 @@ OPTIONS:
72
74
  --addition-color <#HEX> default: ${formatHex(defaultOptions.pallet.addition)}
73
75
  --deletion-color <#HEX> default: ${formatHex(defaultOptions.pallet.deletion)}
74
76
  --modification-color <#HEX> default: ${formatHex(defaultOptions.pallet.modification)}
77
+ --workers <N> default: ${defaultOptions.workers}
75
78
  -v, --version
76
79
  -h, --help
80
+
81
+ NOTES:
82
+ Approximate per-worker memory:
83
+ a_size_MB + b_size_MB [+ mask_size_MB] (PDF buffers in wasm)
84
+ + 300 MB (mupdf + V8 base)
85
+ + (dpi / 150)^2 * 50 MB (pixmap working set)
86
+ The main process adds ~500 MB - 1 GB (varies with --workers).
87
+ Choose --workers so the total stays under ~80% of available memory.
77
88
  `);
78
89
  process.exit(0);
79
90
  }
@@ -137,6 +148,14 @@ if (
137
148
  throw new Error("Invalid color format");
138
149
  }
139
150
 
151
+ const workers =
152
+ typeof workers_ !== "undefined"
153
+ ? parseInt(workers_, 10)
154
+ : defaultOptions.workers;
155
+ if (Number.isNaN(workers) || workers < 1) {
156
+ throw new Error("Invalid workers value");
157
+ }
158
+
140
159
  fs.mkdirSync(outDir, { recursive: true });
141
160
  for await (const [
142
161
  i,
@@ -152,6 +171,7 @@ for await (const [
152
171
  deletion: deletionColor,
153
172
  modification: modificationColor,
154
173
  },
174
+ workers,
155
175
  }),
156
176
  1,
157
177
  )) {
package/src/diff.ts CHANGED
@@ -15,11 +15,9 @@
15
15
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
- import * as jimp from "jimp";
19
-
20
- import { type JimpInstance } from "./jimp.js";
21
- import { alignSize, createEmptyImage, type AlignStrategy } from "./image.js";
22
- import { type RGBAColor } from "./rgba-color.js";
18
+ import { type JimpInstance } from "./jimp.ts";
19
+ import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
20
+ import { type RGBAColor } from "./rgba-color.ts";
23
21
 
24
22
  export type Pallet = {
25
23
  addition: RGBAColor;
@@ -35,34 +33,51 @@ export function drawDifference(
35
33
  align: AlignStrategy,
36
34
  ) {
37
35
  const [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
36
+ const width = aNew.width;
37
+ const height = aNew.height;
38
+ const aData = aNew.bitmap.data;
39
+ const bData = bNew.bitmap.data;
40
+ const mData = maskNew.bitmap.data;
38
41
 
39
- const addColor = jimp.rgbaToInt(...pallet.addition);
40
- const delColor = jimp.rgbaToInt(...pallet.deletion);
41
- const modColor = jimp.rgbaToInt(...pallet.modification);
42
+ const diffImage = createEmptyImage(width, height);
43
+ const dData = diffImage.bitmap.data;
42
44
 
43
- const diffImage = createEmptyImage(aNew.width, aNew.height);
44
- const addition = [] as [number, number][];
45
- const deletion = [] as [number, number][];
46
- const modification = [] as [number, number][];
45
+ const addition: [number, number][] = [];
46
+ const deletion: [number, number][] = [];
47
+ const modification: [number, number][] = [];
47
48
 
48
- for (let x = 0; x < aNew.width; x++) {
49
- for (let y = 0; y < aNew.height; y++) {
50
- const intA = aNew.getPixelColor(x, y);
51
- const intB = bNew.getPixelColor(x, y);
52
- const colorA = jimp.intToRGBA(intA);
53
- const colorB = jimp.intToRGBA(intB);
54
- const masked = jimp.intToRGBA(maskNew.getPixelColor(x, y)).a !== 0;
55
- if (masked || intA === intB || (colorA.a === 0 && colorB.a === 0)) {
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
+ ) {
56
61
  continue;
57
62
  }
58
- const [target, color] =
59
- colorA.a === 0 && colorB.a !== 0
60
- ? [addition, addColor]
61
- : colorA.a !== 0 && colorB.a === 0
62
- ? [deletion, delColor]
63
- : [modification, modColor];
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;
75
+ }
64
76
  target.push([x, y]);
65
- diffImage.setPixelColor(color, 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];
66
81
  }
67
82
  }
68
83
 
package/src/image.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import * as jimp from "jimp";
19
- import { type JimpInstance } from "./jimp.js";
19
+ import { type JimpInstance } from "./jimp.ts";
20
20
 
21
21
  export function createEmptyImage(width: number, height: number) {
22
22
  return new jimp.Jimp({
@@ -117,12 +117,34 @@ export function composeLayers(
117
117
  canvasHeight: number,
118
118
  layers: [JimpInstance, number][],
119
119
  ) {
120
- return layers.reduce(
121
- (acc, [image, opacity]) =>
122
- acc.composite(image, 0, 0, {
123
- mode: jimp.BlendMode.SRC_OVER,
124
- opacitySource: opacity,
125
- }),
126
- createEmptyImage(canvasWidth, canvasHeight),
127
- );
120
+ const canvas = createEmptyImage(canvasWidth, canvasHeight);
121
+ const dData = canvas.bitmap.data;
122
+ for (const [image, opacity] of layers) {
123
+ const sData = image.bitmap.data;
124
+ const srcWidth = image.width;
125
+ const w = Math.min(canvasWidth, srcWidth);
126
+ const h = Math.min(canvasHeight, image.height);
127
+ for (let y = 0; y < h; y++) {
128
+ for (let x = 0; x < w; x++) {
129
+ const dIdx = (y * canvasWidth + x) * 4;
130
+ const sIdx = (y * srcWidth + x) * 4;
131
+ const sa = (sData[sIdx + 3]! / 255) * opacity;
132
+ if (sa === 0) continue;
133
+ const da = dData[dIdx + 3]! / 255;
134
+ const oa = sa + da * (1 - sa);
135
+ if (oa === 0) continue;
136
+ const sw = sa / oa;
137
+ const dw = (da * (1 - sa)) / oa;
138
+ dData[dIdx] = Math.round(sData[sIdx]! * sw + dData[dIdx]! * dw);
139
+ dData[dIdx + 1] = Math.round(
140
+ sData[sIdx + 1]! * sw + dData[dIdx + 1]! * dw,
141
+ );
142
+ dData[dIdx + 2] = Math.round(
143
+ sData[sIdx + 2]! * sw + dData[dIdx + 2]! * dw,
144
+ );
145
+ dData[dIdx + 3] = Math.round(oa * 255);
146
+ }
147
+ }
148
+ }
149
+ return canvas;
128
150
  }
package/src/index.html CHANGED
@@ -5,6 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>pdfdiff</title>
7
7
  <link rel="stylesheet" href="./style.css" />
8
+ <script src="./coi-serviceworker.min.js"></script>
8
9
  <script type="module" src="./browser.js"></script>
9
10
  </head>
10
11
  <body>
@@ -46,6 +47,10 @@
46
47
  <option value="bottom-right">bottom-right</option>
47
48
  </select>
48
49
  </div>
50
+ <div>
51
+ <label for="workers">Workers:</label>
52
+ <input type="number" id="workers" value="1" min="1" />
53
+ </div>
49
54
  <div>
50
55
  <label for="addition-color">Addition:</label>
51
56
  <input type="color" id="addition-color" value="#4cae4f" />
@@ -0,0 +1,97 @@
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 assert from "node:assert/strict";
19
+ import fs from "node:fs";
20
+ import test from "node:test";
21
+
22
+ import {
23
+ defaultOptions,
24
+ formatHex,
25
+ isValidAlignStrategy,
26
+ parseHex,
27
+ visualizeDifferences,
28
+ withIndex,
29
+ } from "./index.ts";
30
+
31
+ const fixtures = new URL("../test/", import.meta.url);
32
+ const readFixture = (name: string) =>
33
+ new Uint8Array(fs.readFileSync(new URL(name, fixtures)));
34
+
35
+ test("re-exports are exposed as runtime values", () => {
36
+ assert.equal(typeof withIndex, "function");
37
+ assert.equal(typeof isValidAlignStrategy, "function");
38
+ assert.equal(typeof parseHex, "function");
39
+ assert.equal(typeof formatHex, "function");
40
+ });
41
+
42
+ test("defaultOptions", () => {
43
+ assert.deepEqual(defaultOptions, {
44
+ dpi: 150,
45
+ alpha: true,
46
+ mask: undefined,
47
+ align: "resize",
48
+ pallet: {
49
+ addition: [0x4c, 0xae, 0x4f, 0xff],
50
+ deletion: [0xff, 0x57, 0x24, 0xff],
51
+ modification: [0xff, 0xc1, 0x05, 0xff],
52
+ },
53
+ workers: 1,
54
+ });
55
+ });
56
+
57
+ test("isValidAlignStrategy", async (ctx) => {
58
+ for (const s of [
59
+ "resize",
60
+ "top-left",
61
+ "top-center",
62
+ "top-right",
63
+ "middle-left",
64
+ "middle-center",
65
+ "middle-right",
66
+ "bottom-left",
67
+ "bottom-center",
68
+ "bottom-right",
69
+ ]) {
70
+ await ctx.test(s, () => {
71
+ assert.equal(isValidAlignStrategy(s), true);
72
+ });
73
+ }
74
+ await ctx.test("invalid", () => {
75
+ assert.equal(isValidAlignStrategy("invalid"), false);
76
+ });
77
+ });
78
+
79
+ test("visualizeDifferences pins counts for fixtures at dpi 300", async () => {
80
+ const a = readFixture("a.pdf");
81
+ const b = readFixture("b.pdf");
82
+ const mask = readFixture("mask.pdf");
83
+
84
+ const pages: { addition: number; deletion: number; modification: number }[] =
85
+ [];
86
+ for await (const page of visualizeDifferences(a, b, { dpi: 300, mask })) {
87
+ pages.push({
88
+ addition: page.addition.length,
89
+ deletion: page.deletion.length,
90
+ modification: page.modification.length,
91
+ });
92
+ }
93
+
94
+ assert.deepEqual(pages, [
95
+ { addition: 7500, deletion: 7500, modification: 7500 },
96
+ ]);
97
+ });