@u1f992/pdfdiff 0.0.1 → 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/.github/workflows/gh-pages.yml +60 -0
- package/.github/workflows/publish.yml +34 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/settings.json +20 -0
- package/LICENSE +674 -0
- package/README.md +24 -45
- package/dist/browser.d.ts +2 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +3690 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +39856 -0
- package/dist/cli.js.map +1 -0
- package/dist/coi-serviceworker.min.js +2 -0
- package/dist/diff.d.ts +15 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/image.d.ts +13 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.html +72 -0
- package/dist/index.js +3554 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/iterable.d.ts +2 -0
- package/dist/iterable.d.ts.map +1 -0
- package/dist/iterable.test.d.ts +2 -0
- package/dist/iterable.test.d.ts.map +1 -0
- package/dist/jimp.d.ts +6 -0
- package/dist/jimp.d.ts.map +1 -0
- package/dist/mupdf-wasm.wasm +0 -0
- package/dist/pdf.d.ts +5 -0
- package/dist/pdf.d.ts.map +1 -0
- package/dist/rgba-color.d.ts +4 -0
- package/dist/rgba-color.d.ts.map +1 -0
- package/dist/rgba-color.test.d.ts +2 -0
- package/dist/rgba-color.test.d.ts.map +1 -0
- package/dist/style.css +19 -0
- package/dist/worker.d.ts +42 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3515 -0
- package/dist/worker.js.map +1 -0
- package/package.json +45 -7
- package/prettier.config.js +3 -0
- package/prototyping/README.md +1 -0
- package/prototyping/flat-map-concurrency.js +218 -0
- package/prototyping/worker.js +10 -0
- package/rollup.config.js +108 -0
- package/src/browser.ts +196 -0
- package/src/cli.ts +195 -0
- package/src/diff.ts +85 -0
- package/src/image.ts +150 -0
- package/src/index.html +72 -0
- package/src/index.test.ts +97 -0
- package/src/index.ts +275 -0
- package/src/iterable.test.ts +40 -0
- package/src/iterable.ts +24 -0
- package/src/jimp.ts +15 -0
- package/src/pdf.ts +74 -0
- package/src/rgba-color.test.ts +43 -0
- package/src/rgba-color.ts +63 -0
- package/src/style.css +19 -0
- package/src/worker.ts +159 -0
- package/test/a.pdf +0 -0
- package/test/b.pdf +0 -0
- package/test/base.xcf +0 -0
- package/test/expected.png +0 -0
- package/test/mask.pdf +0 -0
- package/tsconfig.json +50 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
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.ts";
|
|
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
|
+
workers: workers_,
|
|
44
|
+
version,
|
|
45
|
+
help,
|
|
46
|
+
},
|
|
47
|
+
} = util.parseArgs({
|
|
48
|
+
allowPositionals: true,
|
|
49
|
+
options: {
|
|
50
|
+
dpi: { type: "string" },
|
|
51
|
+
alpha: { type: "boolean" },
|
|
52
|
+
mask: { type: "string" },
|
|
53
|
+
align: { type: "string" },
|
|
54
|
+
"addition-color": { type: "string" },
|
|
55
|
+
"deletion-color": { type: "string" },
|
|
56
|
+
"modification-color": { type: "string" },
|
|
57
|
+
workers: { type: "string" },
|
|
58
|
+
version: { type: "boolean", short: "v" },
|
|
59
|
+
help: { type: "boolean", short: "h" },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (help) {
|
|
64
|
+
console.log(`USAGE:
|
|
65
|
+
pdfdiff <A> <B> <OUTDIR> [OPTIONS]
|
|
66
|
+
|
|
67
|
+
OPTIONS:
|
|
68
|
+
--dpi <DPI> default: ${defaultOptions.dpi}
|
|
69
|
+
--alpha default: ${defaultOptions.alpha}
|
|
70
|
+
--mask <PATH> default: ${defaultOptions.mask}
|
|
71
|
+
--align <resize | top-left | top-center | top-right
|
|
72
|
+
| middle-left | middle-center | middle-right
|
|
73
|
+
| bottom-left | bottom-center | bottom-right> default: ${defaultOptions.align}
|
|
74
|
+
--addition-color <#HEX> default: ${formatHex(defaultOptions.pallet.addition)}
|
|
75
|
+
--deletion-color <#HEX> default: ${formatHex(defaultOptions.pallet.deletion)}
|
|
76
|
+
--modification-color <#HEX> default: ${formatHex(defaultOptions.pallet.modification)}
|
|
77
|
+
--workers <N> default: ${defaultOptions.workers}
|
|
78
|
+
-v, --version
|
|
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.
|
|
88
|
+
`);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
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
|
+
}
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (positionals.length !== 3) {
|
|
106
|
+
throw new Error("Expected 3 positional arguments: <A> <B> <OUTDIR>");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const pdfA = fs.readFileSync(path.resolve(positionals[0]!));
|
|
110
|
+
const pdfB = fs.readFileSync(path.resolve(positionals[1]!));
|
|
111
|
+
const outDir = path.resolve(positionals[2]!);
|
|
112
|
+
|
|
113
|
+
const dpi =
|
|
114
|
+
typeof dpi_ !== "undefined" ? parseInt(dpi_, 10) : defaultOptions.dpi;
|
|
115
|
+
if (Number.isNaN(dpi)) {
|
|
116
|
+
throw new Error("Invalid DPI value");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const alpha = alpha_ ?? defaultOptions.alpha;
|
|
120
|
+
|
|
121
|
+
const pdfMask =
|
|
122
|
+
typeof mask_ !== "undefined"
|
|
123
|
+
? fs.readFileSync(path.resolve(mask_))
|
|
124
|
+
: undefined;
|
|
125
|
+
|
|
126
|
+
const align = align_ ?? defaultOptions.align;
|
|
127
|
+
if (!isValidAlignStrategy(align)) {
|
|
128
|
+
throw new Error(`Invalid alignment strategy`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const additionColor =
|
|
132
|
+
typeof additionColorHex !== "undefined"
|
|
133
|
+
? parseHex(additionColorHex)
|
|
134
|
+
: defaultOptions.pallet.addition;
|
|
135
|
+
const deletionColor =
|
|
136
|
+
typeof deletionColorHex !== "undefined"
|
|
137
|
+
? parseHex(deletionColorHex)
|
|
138
|
+
: defaultOptions.pallet.deletion;
|
|
139
|
+
const modificationColor =
|
|
140
|
+
typeof modificationColorHex !== "undefined"
|
|
141
|
+
? parseHex(modificationColorHex)
|
|
142
|
+
: defaultOptions.pallet.modification;
|
|
143
|
+
if (
|
|
144
|
+
additionColor === null ||
|
|
145
|
+
deletionColor === null ||
|
|
146
|
+
modificationColor === null
|
|
147
|
+
) {
|
|
148
|
+
throw new Error("Invalid color format");
|
|
149
|
+
}
|
|
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
|
+
|
|
159
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
160
|
+
for await (const [
|
|
161
|
+
i,
|
|
162
|
+
{ a, b, diff, addition, deletion, modification },
|
|
163
|
+
] of withIndex(
|
|
164
|
+
visualizeDifferences(pdfA, pdfB, {
|
|
165
|
+
dpi,
|
|
166
|
+
alpha,
|
|
167
|
+
mask: pdfMask,
|
|
168
|
+
align,
|
|
169
|
+
pallet: {
|
|
170
|
+
addition: additionColor,
|
|
171
|
+
deletion: deletionColor,
|
|
172
|
+
modification: modificationColor,
|
|
173
|
+
},
|
|
174
|
+
workers,
|
|
175
|
+
}),
|
|
176
|
+
1,
|
|
177
|
+
)) {
|
|
178
|
+
console.log(
|
|
179
|
+
`Page ${i}, Addition: ${addition.length}, Deletion: ${deletion.length}, Modification: ${modification.length}`,
|
|
180
|
+
);
|
|
181
|
+
const dir = path.join(outDir, i.toString(10));
|
|
182
|
+
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")),
|
|
194
|
+
);
|
|
195
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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 { type JimpInstance } from "./jimp.ts";
|
|
19
|
+
import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
|
|
20
|
+
import { type RGBAColor } from "./rgba-color.ts";
|
|
21
|
+
|
|
22
|
+
export type Pallet = {
|
|
23
|
+
addition: RGBAColor;
|
|
24
|
+
deletion: RGBAColor;
|
|
25
|
+
modification: RGBAColor;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function drawDifference(
|
|
29
|
+
a: JimpInstance,
|
|
30
|
+
b: JimpInstance,
|
|
31
|
+
mask: JimpInstance,
|
|
32
|
+
pallet: Readonly<Pallet>,
|
|
33
|
+
align: AlignStrategy,
|
|
34
|
+
) {
|
|
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;
|
|
41
|
+
|
|
42
|
+
const diffImage = createEmptyImage(width, height);
|
|
43
|
+
const dData = diffImage.bitmap.data;
|
|
44
|
+
|
|
45
|
+
const addition: [number, number][] = [];
|
|
46
|
+
const deletion: [number, number][] = [];
|
|
47
|
+
const modification: [number, number][] = [];
|
|
48
|
+
|
|
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;
|
|
62
|
+
}
|
|
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
|
+
}
|
|
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
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { diff: diffImage, addition, deletion, modification };
|
|
85
|
+
}
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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.ts";
|
|
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
|
+
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;
|
|
150
|
+
}
|
package/src/index.html
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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 src="./coi-serviceworker.min.js"></script>
|
|
9
|
+
<script type="module" src="./browser.js"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<h1>pdfdiff</h1>
|
|
13
|
+
<div class="form-container">
|
|
14
|
+
<form id="pdf-diff-form">
|
|
15
|
+
<div>
|
|
16
|
+
<label for="pdf-a">A:</label>
|
|
17
|
+
<input type="file" id="pdf-a" accept="application/pdf" required />
|
|
18
|
+
</div>
|
|
19
|
+
<div>
|
|
20
|
+
<label for="pdf-b">B:</label>
|
|
21
|
+
<input type="file" id="pdf-b" accept="application/pdf" required />
|
|
22
|
+
</div>
|
|
23
|
+
<div>
|
|
24
|
+
<label for="pdf-mask">Mask:</label>
|
|
25
|
+
<input type="file" id="pdf-mask" accept="application/pdf" />
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<label for="dpi">DPI:</label>
|
|
29
|
+
<input type="number" id="dpi" value="150" />
|
|
30
|
+
</div>
|
|
31
|
+
<div>
|
|
32
|
+
<label for="alpha">Alpha:</label>
|
|
33
|
+
<input type="checkbox" id="alpha" checked />
|
|
34
|
+
</div>
|
|
35
|
+
<div>
|
|
36
|
+
<label for="align">Align:</label>
|
|
37
|
+
<select id="align">
|
|
38
|
+
<option value="resize">resize</option>
|
|
39
|
+
<option value="top-left">top-left</option>
|
|
40
|
+
<option value="top-center">top-center</option>
|
|
41
|
+
<option value="top-right">top-right</option>
|
|
42
|
+
<option value="middle-left">middle-left</option>
|
|
43
|
+
<option value="middle-center">middle-center</option>
|
|
44
|
+
<option value="middle-right">middle-right</option>
|
|
45
|
+
<option value="bottom-left">bottom-left</option>
|
|
46
|
+
<option value="bottom-center">bottom-center</option>
|
|
47
|
+
<option value="bottom-right">bottom-right</option>
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
50
|
+
<div>
|
|
51
|
+
<label for="workers">Workers:</label>
|
|
52
|
+
<input type="number" id="workers" value="1" min="1" />
|
|
53
|
+
</div>
|
|
54
|
+
<div>
|
|
55
|
+
<label for="addition-color">Addition:</label>
|
|
56
|
+
<input type="color" id="addition-color" value="#4cae4f" />
|
|
57
|
+
</div>
|
|
58
|
+
<div>
|
|
59
|
+
<label for="deletion-color">Deletion:</label>
|
|
60
|
+
<input type="color" id="deletion-color" value="#ff5724" />
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<label for="modification-color">Modification:</label>
|
|
64
|
+
<input type="color" id="modification-color" value="#ffc105" />
|
|
65
|
+
</div>
|
|
66
|
+
<button type="submit">Submit</button>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="error" id="error-message"></div>
|
|
70
|
+
<div class="results" id="results"></div>
|
|
71
|
+
</body>
|
|
72
|
+
</html>
|
|
@@ -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
|
+
});
|