@u1f992/pdfdiff 0.0.1 → 0.1.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.
Files changed (67) hide show
  1. package/.github/workflows/gh-pages.yml +60 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.vscode/extensions.json +3 -0
  4. package/.vscode/settings.json +20 -0
  5. package/LICENSE +674 -0
  6. package/README.md +24 -45
  7. package/dist/browser.d.ts +2 -0
  8. package/dist/browser.d.ts.map +1 -0
  9. package/dist/browser.js +3621 -0
  10. package/dist/browser.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +39804 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/diff.d.ts +15 -0
  16. package/dist/diff.d.ts.map +1 -0
  17. package/dist/image.d.ts +13 -0
  18. package/dist/image.d.ts.map +1 -0
  19. package/dist/index.d.ts +26 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.html +67 -0
  22. package/dist/index.js +3493 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/iterable.d.ts +2 -0
  25. package/dist/iterable.d.ts.map +1 -0
  26. package/dist/iterable.test.d.ts +2 -0
  27. package/dist/iterable.test.d.ts.map +1 -0
  28. package/dist/jimp.d.ts +6 -0
  29. package/dist/jimp.d.ts.map +1 -0
  30. package/dist/mupdf-wasm.wasm +0 -0
  31. package/dist/pdf.d.ts +377 -0
  32. package/dist/pdf.d.ts.map +1 -0
  33. package/dist/rgba-color.d.ts +4 -0
  34. package/dist/rgba-color.d.ts.map +1 -0
  35. package/dist/rgba-color.test.d.ts +2 -0
  36. package/dist/rgba-color.test.d.ts.map +1 -0
  37. package/dist/style.css +19 -0
  38. package/dist/worker.d.ts +2 -0
  39. package/dist/worker.d.ts.map +1 -0
  40. package/dist/worker.js +380 -0
  41. package/dist/worker.js.map +1 -0
  42. package/package.json +44 -7
  43. package/prettier.config.js +3 -0
  44. package/prototyping/README.md +1 -0
  45. package/prototyping/flat-map-concurrency.js +218 -0
  46. package/prototyping/worker.js +10 -0
  47. package/rollup.config.js +121 -0
  48. package/src/browser.ts +184 -0
  49. package/src/cli.ts +175 -0
  50. package/src/diff.ts +70 -0
  51. package/src/image.ts +128 -0
  52. package/src/index.html +67 -0
  53. package/src/index.ts +186 -0
  54. package/src/iterable.test.ts +40 -0
  55. package/src/iterable.ts +24 -0
  56. package/src/jimp.ts +14 -0
  57. package/src/pdf.ts +42 -0
  58. package/src/rgba-color.test.ts +43 -0
  59. package/src/rgba-color.ts +63 -0
  60. package/src/style.css +19 -0
  61. package/src/worker.ts +62 -0
  62. package/test/a.pdf +0 -0
  63. package/test/b.pdf +0 -0
  64. package/test/base.xcf +0 -0
  65. package/test/expected.png +0 -0
  66. package/test/mask.pdf +0 -0
  67. package/tsconfig.json +50 -0
package/src/cli.ts ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /*
4
+ * Copyright (C) 2025 Koutaro Mukai
5
+ *
6
+ * This program is free software: you can redistribute it and/or modify
7
+ * it under the terms of the GNU General Public License as published by
8
+ * the Free Software Foundation, either version 3 of the License, or
9
+ * (at your option) any later version.
10
+ *
11
+ * This program is distributed in the hope that it will be useful,
12
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ * GNU General Public License for more details.
15
+ *
16
+ * You should have received a copy of the GNU General Public License
17
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import util from "node:util";
23
+
24
+ import {
25
+ isValidAlignStrategy,
26
+ defaultOptions,
27
+ withIndex,
28
+ parseHex,
29
+ formatHex,
30
+ visualizeDifferences,
31
+ } from "./index.js";
32
+
33
+ const {
34
+ positionals,
35
+ values: {
36
+ dpi: dpi_,
37
+ alpha: alpha_,
38
+ mask: mask_,
39
+ align: align_,
40
+ "addition-color": additionColorHex,
41
+ "deletion-color": deletionColorHex,
42
+ "modification-color": modificationColorHex,
43
+ version,
44
+ help,
45
+ },
46
+ } = util.parseArgs({
47
+ allowPositionals: true,
48
+ options: {
49
+ dpi: { type: "string" },
50
+ alpha: { type: "boolean" },
51
+ mask: { type: "string" },
52
+ align: { type: "string" },
53
+ "addition-color": { type: "string" },
54
+ "deletion-color": { type: "string" },
55
+ "modification-color": { type: "string" },
56
+ version: { type: "boolean", short: "v" },
57
+ help: { type: "boolean", short: "h" },
58
+ },
59
+ });
60
+
61
+ if (help) {
62
+ console.log(`USAGE:
63
+ pdfdiff <A> <B> <OUTDIR> [OPTIONS]
64
+
65
+ OPTIONS:
66
+ --dpi <DPI> default: ${defaultOptions.dpi}
67
+ --alpha default: ${defaultOptions.alpha}
68
+ --mask <PATH> default: ${defaultOptions.mask}
69
+ --align <resize | top-left | top-center | top-right
70
+ | middle-left | middle-center | middle-right
71
+ | bottom-left | bottom-center | bottom-right> default: ${defaultOptions.align}
72
+ --addition-color <#HEX> default: ${formatHex(defaultOptions.pallet.addition)}
73
+ --deletion-color <#HEX> default: ${formatHex(defaultOptions.pallet.deletion)}
74
+ --modification-color <#HEX> default: ${formatHex(defaultOptions.pallet.modification)}
75
+ -v, --version
76
+ -h, --help
77
+ `);
78
+ process.exit(0);
79
+ }
80
+ 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
+ }
91
+ process.exit(0);
92
+ }
93
+
94
+ if (positionals.length !== 3) {
95
+ throw new Error("Expected 3 positional arguments: <A> <B> <OUTDIR>");
96
+ }
97
+
98
+ const pdfA = fs.readFileSync(path.resolve(positionals[0]!));
99
+ const pdfB = fs.readFileSync(path.resolve(positionals[1]!));
100
+ const outDir = path.resolve(positionals[2]!);
101
+
102
+ const dpi =
103
+ typeof dpi_ !== "undefined" ? parseInt(dpi_, 10) : defaultOptions.dpi;
104
+ if (Number.isNaN(dpi)) {
105
+ throw new Error("Invalid DPI value");
106
+ }
107
+
108
+ const alpha = alpha_ ?? defaultOptions.alpha;
109
+
110
+ const pdfMask =
111
+ typeof mask_ !== "undefined"
112
+ ? fs.readFileSync(path.resolve(mask_))
113
+ : undefined;
114
+
115
+ const align = align_ ?? defaultOptions.align;
116
+ if (!isValidAlignStrategy(align)) {
117
+ throw new Error(`Invalid alignment strategy`);
118
+ }
119
+
120
+ const additionColor =
121
+ typeof additionColorHex !== "undefined"
122
+ ? parseHex(additionColorHex)
123
+ : defaultOptions.pallet.addition;
124
+ const deletionColor =
125
+ typeof deletionColorHex !== "undefined"
126
+ ? parseHex(deletionColorHex)
127
+ : defaultOptions.pallet.deletion;
128
+ const modificationColor =
129
+ typeof modificationColorHex !== "undefined"
130
+ ? parseHex(modificationColorHex)
131
+ : defaultOptions.pallet.modification;
132
+ if (
133
+ additionColor === null ||
134
+ deletionColor === null ||
135
+ modificationColor === null
136
+ ) {
137
+ throw new Error("Invalid color format");
138
+ }
139
+
140
+ fs.mkdirSync(outDir, { recursive: true });
141
+ for await (const [
142
+ i,
143
+ { a, b, diff, addition, deletion, modification },
144
+ ] of withIndex(
145
+ visualizeDifferences(pdfA, pdfB, {
146
+ dpi,
147
+ alpha,
148
+ mask: pdfMask,
149
+ align,
150
+ pallet: {
151
+ addition: additionColor,
152
+ deletion: deletionColor,
153
+ modification: modificationColor,
154
+ },
155
+ }),
156
+ 1,
157
+ )) {
158
+ console.log(
159
+ `Page ${i}, Addition: ${addition.length}, Deletion: ${deletion.length}, Modification: ${modification.length}`,
160
+ );
161
+ const dir = path.join(outDir, i.toString(10));
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ fs.writeFileSync(
164
+ path.join(dir, "a.png"),
165
+ new Uint8Array(await a.getBuffer("image/png")),
166
+ );
167
+ fs.writeFileSync(
168
+ path.join(dir, "b.png"),
169
+ new Uint8Array(await b.getBuffer("image/png")),
170
+ );
171
+ fs.writeFileSync(
172
+ path.join(dir, "diff.png"),
173
+ new Uint8Array(await diff.getBuffer("image/png")),
174
+ );
175
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,70 @@
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 * 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";
23
+
24
+ export type Pallet = {
25
+ addition: RGBAColor;
26
+ deletion: RGBAColor;
27
+ modification: RGBAColor;
28
+ };
29
+
30
+ export function drawDifference(
31
+ a: JimpInstance,
32
+ b: JimpInstance,
33
+ mask: JimpInstance,
34
+ pallet: Readonly<Pallet>,
35
+ align: AlignStrategy,
36
+ ) {
37
+ const [aNew, bNew, maskNew] = alignSize([a, b, mask], align);
38
+
39
+ const addColor = jimp.rgbaToInt(...pallet.addition);
40
+ const delColor = jimp.rgbaToInt(...pallet.deletion);
41
+ const modColor = jimp.rgbaToInt(...pallet.modification);
42
+
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][];
47
+
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)) {
56
+ continue;
57
+ }
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];
64
+ target.push([x, y]);
65
+ diffImage.setPixelColor(color, x, y);
66
+ }
67
+ }
68
+
69
+ return { diff: diffImage, addition, deletion, modification };
70
+ }
package/src/image.ts ADDED
@@ -0,0 +1,128 @@
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 * as jimp from "jimp";
19
+ import { type JimpInstance } from "./jimp.js";
20
+
21
+ export function createEmptyImage(width: number, height: number) {
22
+ return new jimp.Jimp({
23
+ width,
24
+ height,
25
+ color: jimp.rgbaToInt(0, 0, 0, 0),
26
+ }) as JimpInstance;
27
+ }
28
+
29
+ export function fillWithEmpty(
30
+ images:
31
+ | [JimpInstance, JimpInstance]
32
+ | [JimpInstance, null]
33
+ | [null, JimpInstance],
34
+ ): [JimpInstance, JimpInstance];
35
+ export function fillWithEmpty(
36
+ images:
37
+ | [JimpInstance, JimpInstance, JimpInstance]
38
+ | [JimpInstance, JimpInstance, null]
39
+ | [JimpInstance, null, JimpInstance]
40
+ | [JimpInstance, null, null]
41
+ | [null, JimpInstance, JimpInstance]
42
+ | [null, JimpInstance, null]
43
+ | [null, null, JimpInstance],
44
+ ): [JimpInstance, JimpInstance, JimpInstance];
45
+ export function fillWithEmpty(images: (JimpInstance | null)[]): JimpInstance[] {
46
+ return images.map((img) => (img !== null ? img : createEmptyImage(1, 1)));
47
+ }
48
+
49
+ const alignStrategyValues = new Set([
50
+ "resize",
51
+ "top-left",
52
+ "top-center",
53
+ "top-right",
54
+ "middle-left",
55
+ "middle-center",
56
+ "middle-right",
57
+ "bottom-left",
58
+ "bottom-center",
59
+ "bottom-right",
60
+ ] as const);
61
+ type UnwrapSet<T> = T extends Set<infer U> ? U : never;
62
+ export type AlignStrategy = UnwrapSet<typeof alignStrategyValues>;
63
+ export const isValidAlignStrategy = (str: string): str is AlignStrategy =>
64
+ (alignStrategyValues as Set<string>).has(str);
65
+
66
+ function alignImage(
67
+ img: JimpInstance,
68
+ targetWidth: number,
69
+ targetHeight: number,
70
+ align: AlignStrategy,
71
+ ) {
72
+ if (align === "resize") {
73
+ return img.resize({ w: targetWidth, h: targetHeight });
74
+ } else {
75
+ const newImg = createEmptyImage(targetWidth, targetHeight);
76
+ const x = align.includes("center")
77
+ ? Math.floor((targetWidth - img.width) / 2)
78
+ : align.includes("right")
79
+ ? targetWidth - img.width
80
+ : 0;
81
+ const y = align.includes("middle")
82
+ ? Math.floor((targetHeight - img.height) / 2)
83
+ : align.includes("bottom")
84
+ ? targetHeight - img.height
85
+ : 0;
86
+ newImg.composite(img, x, y);
87
+ return newImg;
88
+ }
89
+ }
90
+
91
+ export function alignSize(
92
+ images: [JimpInstance, JimpInstance],
93
+ align: AlignStrategy,
94
+ ): [JimpInstance, JimpInstance];
95
+ export function alignSize(
96
+ images: [JimpInstance, JimpInstance, JimpInstance],
97
+ align: AlignStrategy,
98
+ ): [JimpInstance, JimpInstance, JimpInstance];
99
+ export function alignSize(
100
+ images: JimpInstance[],
101
+ align: AlignStrategy,
102
+ ): JimpInstance[] {
103
+ if (images.length === 0) {
104
+ return [];
105
+ }
106
+ const largerWidth = Math.max(...images.map((img) => img.width));
107
+ const largerHeight = Math.max(...images.map((img) => img.height));
108
+ return images.map((img) =>
109
+ img.width === largerWidth && img.height === largerHeight
110
+ ? img
111
+ : alignImage(img, largerWidth, largerHeight, align),
112
+ );
113
+ }
114
+
115
+ export function composeLayers(
116
+ canvasWidth: number,
117
+ canvasHeight: number,
118
+ layers: [JimpInstance, number][],
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
+ );
128
+ }
package/src/index.html ADDED
@@ -0,0 +1,67 @@
1
+ <!doctype html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>pdfdiff</title>
7
+ <link rel="stylesheet" href="./style.css" />
8
+ <script type="module" src="./browser.js"></script>
9
+ </head>
10
+ <body>
11
+ <h1>pdfdiff</h1>
12
+ <div class="form-container">
13
+ <form id="pdf-diff-form">
14
+ <div>
15
+ <label for="pdf-a">A:</label>
16
+ <input type="file" id="pdf-a" accept="application/pdf" required />
17
+ </div>
18
+ <div>
19
+ <label for="pdf-b">B:</label>
20
+ <input type="file" id="pdf-b" accept="application/pdf" required />
21
+ </div>
22
+ <div>
23
+ <label for="pdf-mask">Mask:</label>
24
+ <input type="file" id="pdf-mask" accept="application/pdf" />
25
+ </div>
26
+ <div>
27
+ <label for="dpi">DPI:</label>
28
+ <input type="number" id="dpi" value="150" />
29
+ </div>
30
+ <div>
31
+ <label for="alpha">Alpha:</label>
32
+ <input type="checkbox" id="alpha" checked />
33
+ </div>
34
+ <div>
35
+ <label for="align">Align:</label>
36
+ <select id="align">
37
+ <option value="resize">resize</option>
38
+ <option value="top-left">top-left</option>
39
+ <option value="top-center">top-center</option>
40
+ <option value="top-right">top-right</option>
41
+ <option value="middle-left">middle-left</option>
42
+ <option value="middle-center">middle-center</option>
43
+ <option value="middle-right">middle-right</option>
44
+ <option value="bottom-left">bottom-left</option>
45
+ <option value="bottom-center">bottom-center</option>
46
+ <option value="bottom-right">bottom-right</option>
47
+ </select>
48
+ </div>
49
+ <div>
50
+ <label for="addition-color">Addition:</label>
51
+ <input type="color" id="addition-color" value="#4cae4f" />
52
+ </div>
53
+ <div>
54
+ <label for="deletion-color">Deletion:</label>
55
+ <input type="color" id="deletion-color" value="#ff5724" />
56
+ </div>
57
+ <div>
58
+ <label for="modification-color">Modification:</label>
59
+ <input type="color" id="modification-color" value="#ffc105" />
60
+ </div>
61
+ <button type="submit">Submit</button>
62
+ </form>
63
+ </div>
64
+ <div class="error" id="error-message"></div>
65
+ <div class="results" id="results"></div>
66
+ </body>
67
+ </html>
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
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 * as jimp from "jimp";
19
+ import * as mupdf from "mupdf";
20
+ import Worker from "web-worker";
21
+
22
+ import {
23
+ createEmptyImage,
24
+ isValidAlignStrategy,
25
+ type AlignStrategy,
26
+ } from "./image.js";
27
+ import { withIndex } from "./iterable.js";
28
+ import { pageToImage } from "./pdf.js";
29
+ import { parseHex, formatHex } from "./rgba-color.js";
30
+ import type { Pallet } from "./diff.js";
31
+ import type { JimpInstance } from "./jimp.js";
32
+
33
+ export { withIndex, isValidAlignStrategy, parseHex, formatHex };
34
+
35
+ type Options = {
36
+ dpi: number;
37
+ alpha: boolean;
38
+ mask: Uint8Array | undefined;
39
+ align: AlignStrategy;
40
+ pallet: Pallet;
41
+ };
42
+
43
+ type Result = {
44
+ a: JimpInstance;
45
+ b: JimpInstance;
46
+ diff: JimpInstance;
47
+ addition: [number, number][];
48
+ deletion: [number, number][];
49
+ modification: [number, number][];
50
+ };
51
+
52
+ export const defaultOptions: Options = {
53
+ dpi: 150,
54
+ alpha: true,
55
+ mask: undefined,
56
+ align: "resize",
57
+ pallet: {
58
+ addition: [0x4c, 0xae, 0x4f, 0xff],
59
+ deletion: [0xff, 0x57, 0x24, 0xff],
60
+ modification: [0xff, 0xc1, 0x05, 0xff],
61
+ },
62
+ };
63
+
64
+ export async function* visualizeDifferences(
65
+ a: Uint8Array,
66
+ b: Uint8Array,
67
+ options: Partial<Omit<Options, "pallet"> & { pallet: Partial<Pallet> }>,
68
+ ) {
69
+ const mergedOptions = {
70
+ dpi: options?.dpi ?? defaultOptions.dpi,
71
+ alpha: options?.alpha ?? defaultOptions.alpha,
72
+ mask: options?.mask ?? defaultOptions.mask,
73
+ align: options?.align ?? defaultOptions.align,
74
+ pallet: {
75
+ addition: options?.pallet?.addition ?? defaultOptions.pallet.addition,
76
+ deletion: options?.pallet?.deletion ?? defaultOptions.pallet.deletion,
77
+ modification:
78
+ options?.pallet?.modification ?? defaultOptions.pallet.modification,
79
+ },
80
+ };
81
+
82
+ const pdfA = mupdf.PDFDocument.openDocument(a, "application/pdf");
83
+ const pdfB = mupdf.PDFDocument.openDocument(b, "application/pdf");
84
+ const pdfMask =
85
+ typeof mergedOptions.mask !== "undefined"
86
+ ? mupdf.PDFDocument.openDocument(mergedOptions.mask, "application/pdf")
87
+ : new mupdf.PDFDocument();
88
+
89
+ const maxPages = Math.max(
90
+ pdfA.countPages(),
91
+ pdfB.countPages(),
92
+ pdfMask.countPages(),
93
+ );
94
+
95
+ async function processPage(pageIndex: number) {
96
+ const [pageA, pageB, pageMask] = await Promise.all([
97
+ pageIndex < pdfA.countPages()
98
+ ? pageToImage(
99
+ pdfA.loadPage(pageIndex),
100
+ mergedOptions.dpi,
101
+ mergedOptions.alpha,
102
+ )
103
+ : createEmptyImage(1, 1),
104
+ pageIndex < pdfB.countPages()
105
+ ? pageToImage(
106
+ pdfB.loadPage(pageIndex),
107
+ mergedOptions.dpi,
108
+ mergedOptions.alpha,
109
+ )
110
+ : createEmptyImage(1, 1),
111
+ pageIndex < pdfMask.countPages()
112
+ ? pageToImage(
113
+ pdfMask.loadPage(pageIndex),
114
+ mergedOptions.dpi,
115
+ mergedOptions.alpha,
116
+ )
117
+ : createEmptyImage(1, 1),
118
+ ]);
119
+
120
+ // NOTE: getBufferはcopyなので、Workerに移譲した後もa, bを使用して問題ない
121
+ // https://github.com/jimp-dev/jimp/blob/b6b0e418a5f1259211a133b20cddb4f4e5c25679/packages/core/src/index.ts#L444
122
+ const [bufA, bufB, bufMask] = await Promise.all([
123
+ pageA
124
+ .getBuffer(jimp.JimpMime.png)
125
+ .then((buf) => new Uint8Array(buf).buffer),
126
+ pageB
127
+ .getBuffer(jimp.JimpMime.png)
128
+ .then((buf) => new Uint8Array(buf).buffer),
129
+ pageMask
130
+ .getBuffer(jimp.JimpMime.png)
131
+ .then((buf) => new Uint8Array(buf).buffer),
132
+ ]);
133
+
134
+ const { bufDiff, addition, deletion, modification } = (await new Promise(
135
+ (resolve, reject) => {
136
+ const url = new URL("./worker.js", import.meta.url);
137
+ const worker = new Worker(url, { type: "module" });
138
+ worker.addEventListener("message", (e) => {
139
+ resolve(e.data);
140
+ worker.terminate();
141
+ });
142
+ worker.addEventListener("error", (e) => {
143
+ reject(e);
144
+ worker.terminate();
145
+ });
146
+ worker.postMessage(
147
+ {
148
+ bufA,
149
+ bufB,
150
+ bufMask,
151
+ pallet: mergedOptions.pallet,
152
+ align: mergedOptions.align,
153
+ },
154
+ [bufA, bufB, bufMask],
155
+ );
156
+ },
157
+ )) as {
158
+ bufDiff: ArrayBuffer;
159
+ addition: [number, number][];
160
+ deletion: [number, number][];
161
+ modification: [number, number][];
162
+ };
163
+ const diff = await jimp.Jimp.fromBuffer(bufDiff);
164
+ return { a: pageA, b: pageB, diff, addition, deletion, modification };
165
+ }
166
+
167
+ // ページ処理を並列発行し、順序を保証して出力
168
+ const concurrency = navigator.hardwareConcurrency;
169
+ const pending = /** @type {Promise<VisualizeDifferencesResult>[]} */ [];
170
+ let nextPageToProcess = 0;
171
+ let nextPageToYield = 0;
172
+
173
+ while (nextPageToYield < maxPages) {
174
+ // プールに空きがあれば新しいページ処理を追加
175
+ while (nextPageToProcess < maxPages && pending.length < concurrency) {
176
+ pending.push(processPage(nextPageToProcess));
177
+ nextPageToProcess++;
178
+ }
179
+
180
+ // 次に出力すべきページのPromiseを待つ
181
+ const result = await pending[0];
182
+ pending.shift();
183
+ yield result as Result;
184
+ nextPageToYield++;
185
+ }
186
+ }
@@ -0,0 +1,40 @@
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 "assert";
19
+ import test from "node:test";
20
+
21
+ import { withIndex } from "./iterable.ts";
22
+
23
+ test("withIndex", async () => {
24
+ assert.deepStrictEqual(
25
+ // @ts-ignore
26
+ await Array.fromAsync(
27
+ withIndex(
28
+ (async function* () {
29
+ yield "a";
30
+ yield "b";
31
+ })(),
32
+ 0,
33
+ ),
34
+ ),
35
+ [
36
+ [0, "a"],
37
+ [1, "b"],
38
+ ],
39
+ );
40
+ });
@@ -0,0 +1,24 @@
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
+ export async function* withIndex<T>(iter: AsyncIterable<T>, start = 0) {
19
+ let index = start;
20
+ for await (const item of iter) {
21
+ yield [index, item] as [number, T];
22
+ index++;
23
+ }
24
+ }