@u1f992/pdfdiff 0.1.0 → 0.2.1

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.1",
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": {
@@ -30,6 +31,7 @@
30
31
  "@rollup/plugin-node-resolve": "^16.0.1",
31
32
  "@rollup/plugin-typescript": "^12.1.4",
32
33
  "@types/node": "^22.18.7",
34
+ "coi-serviceworker": "^0.1.7",
33
35
  "http-server": "^14.1.1",
34
36
  "nodehog": "^0.1.2",
35
37
  "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
  ],
@@ -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,10 @@
1
1
  /// <reference lib="dom" />
2
2
 
3
- import * as pdfdiff from "./index.js";
3
+ import * as pdfdiff from "./index.ts";
4
+ import { VERSION } from "./version.ts";
5
+
6
+ const versionEl = document.getElementById("version");
7
+ if (versionEl) versionEl.textContent = "v" + VERSION;
4
8
 
5
9
  async function readFileAsUint8Array(file: File): Promise<Uint8Array> {
6
10
  return new Promise((resolve, reject) => {
@@ -62,6 +66,17 @@ document
62
66
  throw new Error();
63
67
  }
64
68
 
69
+ const workers = ((
70
+ val = (document.getElementById("workers") as HTMLInputElement | null)
71
+ ?.value,
72
+ ) => (typeof val !== "undefined" ? parseInt(val, 10) : undefined))();
73
+ if (
74
+ typeof workers !== "undefined" &&
75
+ (Number.isNaN(workers) || workers < 1)
76
+ ) {
77
+ throw new Error();
78
+ }
79
+
65
80
  const additionColorHex = (
66
81
  document.getElementById("addition-color") as HTMLInputElement | null
67
82
  )?.value;
@@ -93,6 +108,7 @@ document
93
108
  if (alpha !== undefined) options.alpha = alpha;
94
109
  if (pdfMask !== undefined) options.mask = pdfMask;
95
110
  if (align !== undefined) options.align = align;
111
+ if (workers !== undefined) options.workers = workers;
96
112
  if (additionColor || deletionColor || modificationColor) {
97
113
  options.pallet = {};
98
114
  if (additionColor) options.pallet.addition = additionColor;
package/src/cli.ts CHANGED
@@ -28,7 +28,8 @@ import {
28
28
  parseHex,
29
29
  formatHex,
30
30
  visualizeDifferences,
31
- } from "./index.js";
31
+ } from "./index.ts";
32
+ import { VERSION } from "./version.ts";
32
33
 
33
34
  const {
34
35
  positionals,
@@ -40,6 +41,7 @@ const {
40
41
  "addition-color": additionColorHex,
41
42
  "deletion-color": deletionColorHex,
42
43
  "modification-color": modificationColorHex,
44
+ workers: workers_,
43
45
  version,
44
46
  help,
45
47
  },
@@ -53,6 +55,7 @@ const {
53
55
  "addition-color": { type: "string" },
54
56
  "deletion-color": { type: "string" },
55
57
  "modification-color": { type: "string" },
58
+ workers: { type: "string" },
56
59
  version: { type: "boolean", short: "v" },
57
60
  help: { type: "boolean", short: "h" },
58
61
  },
@@ -72,22 +75,22 @@ OPTIONS:
72
75
  --addition-color <#HEX> default: ${formatHex(defaultOptions.pallet.addition)}
73
76
  --deletion-color <#HEX> default: ${formatHex(defaultOptions.pallet.deletion)}
74
77
  --modification-color <#HEX> default: ${formatHex(defaultOptions.pallet.modification)}
78
+ --workers <N> default: ${defaultOptions.workers}
75
79
  -v, --version
76
80
  -h, --help
81
+
82
+ NOTES:
83
+ Approximate per-worker memory:
84
+ a_size_MB + b_size_MB [+ mask_size_MB] (PDF buffers in wasm)
85
+ + 300 MB (mupdf + V8 base)
86
+ + (dpi / 150)^2 * 50 MB (pixmap working set)
87
+ The main process adds ~500 MB - 1 GB (varies with --workers).
88
+ Choose --workers so the total stays under ~80% of available memory.
77
89
  `);
78
90
  process.exit(0);
79
91
  }
80
92
  if (version) {
81
- try {
82
- const versionStr = JSON.parse(
83
- fs.readFileSync(new URL("../package.json", import.meta.url), {
84
- encoding: "utf-8",
85
- }),
86
- ).version;
87
- console.log(versionStr);
88
- } catch {
89
- console.log("unknown");
90
- }
93
+ console.log(VERSION);
91
94
  process.exit(0);
92
95
  }
93
96
 
@@ -137,6 +140,14 @@ if (
137
140
  throw new Error("Invalid color format");
138
141
  }
139
142
 
143
+ const workers =
144
+ typeof workers_ !== "undefined"
145
+ ? parseInt(workers_, 10)
146
+ : defaultOptions.workers;
147
+ if (Number.isNaN(workers) || workers < 1) {
148
+ throw new Error("Invalid workers value");
149
+ }
150
+
140
151
  fs.mkdirSync(outDir, { recursive: true });
141
152
  for await (const [
142
153
  i,
@@ -152,6 +163,7 @@ for await (const [
152
163
  deletion: deletionColor,
153
164
  modification: modificationColor,
154
165
  },
166
+ workers,
155
167
  }),
156
168
  1,
157
169
  )) {
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,10 +5,11 @@
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>
11
- <h1>pdfdiff</h1>
12
+ <h1>pdfdiff <small id="version"></small></h1>
12
13
  <div class="form-container">
13
14
  <form id="pdf-diff-form">
14
15
  <div>
@@ -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
+ });