@u1f992/pdfdiff 0.2.1 → 0.3.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/.clang-format +3 -0
- package/.github/workflows/gh-pages.yml +6 -1
- package/LICENSE +68 -81
- package/dist/browser.js +1405 -3099
- package/dist/browser.js.map +1 -1
- package/dist/cli-png-worker.d.ts +13 -0
- package/dist/cli-png-worker.d.ts.map +1 -0
- package/dist/cli-png-worker.js +287 -0
- package/dist/cli-png-worker.js.map +1 -0
- package/dist/cli.js +401 -3110
- package/dist/cli.js.map +1 -1
- package/dist/core.wasm +0 -0
- package/dist/decode.d.ts +9 -0
- package/dist/decode.d.ts.map +1 -0
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/gs-wasm/gs.js +5821 -0
- package/dist/gs-wasm/gs.wasm +0 -0
- package/dist/gs-wasm/index.js +120 -0
- package/dist/gs-wasm/index.js.map +1 -0
- package/dist/gs-wasm/worker.js +764 -0
- package/dist/gs-wasm/worker.js.map +1 -0
- package/dist/image.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +6 -1
- package/dist/index.js +310 -3094
- package/dist/index.js.map +1 -1
- package/dist/iterable.d.ts.map +1 -1
- package/dist/jimp.d.ts +23 -1
- package/dist/jimp.d.ts.map +1 -1
- package/dist/pdf.d.ts +15 -4
- package/dist/pdf.d.ts.map +1 -1
- package/dist/perf.d.ts +16 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/rgba-color.d.ts.map +1 -1
- package/dist/squoosh_png_bg.wasm +0 -0
- package/dist/style.css +12 -0
- package/dist/transferable.d.ts +11 -0
- package/dist/transferable.d.ts.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/worker.d.ts +8 -8
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +144 -3210
- package/dist/worker.js.map +1 -1
- package/package.json +11 -4
- package/rollup.config.js +83 -5
- package/scripts/build-wasm.sh +32 -0
- package/src/browser.ts +122 -9
- package/src/cli-png-worker.ts +42 -0
- package/src/cli.ts +113 -34
- package/src/decode.ts +15 -0
- package/src/diff.ts +99 -51
- package/src/image.ts +4 -18
- package/src/index.html +6 -1
- package/src/index.test.ts +10 -18
- package/src/index.ts +176 -76
- package/src/iterable.test.ts +0 -17
- package/src/iterable.ts +0 -17
- package/src/jimp.ts +25 -7
- package/src/pdf.ts +99 -62
- package/src/perf.ts +77 -0
- package/src/rgba-color.test.ts +0 -17
- package/src/rgba-color.ts +0 -17
- package/src/style.css +12 -0
- package/src/transferable.ts +15 -0
- package/src/worker.ts +106 -100
- package/wasm/Makefile +34 -0
- package/wasm/bindings.cpp +76 -0
- package/wasm/core.c +176 -0
- package/wasm/core.h +69 -0
- package/dist/mupdf-wasm.wasm +0 -0
package/src/diff.ts
CHANGED
|
@@ -1,22 +1,6 @@
|
|
|
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
1
|
import { type JimpInstance } from "./jimp.ts";
|
|
19
2
|
import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
|
|
3
|
+
import { perf } from "./perf.ts";
|
|
20
4
|
import { type RGBAColor } from "./rgba-color.ts";
|
|
21
5
|
|
|
22
6
|
export type Pallet = {
|
|
@@ -28,58 +12,122 @@ export type Pallet = {
|
|
|
28
12
|
export function drawDifference(
|
|
29
13
|
a: JimpInstance,
|
|
30
14
|
b: JimpInstance,
|
|
31
|
-
mask: JimpInstance,
|
|
15
|
+
mask: JimpInstance | null,
|
|
32
16
|
pallet: Readonly<Pallet>,
|
|
33
17
|
align: AlignStrategy,
|
|
34
18
|
) {
|
|
35
|
-
const
|
|
19
|
+
const sAlign = perf.span("diff.align_ms");
|
|
20
|
+
let aNew: JimpInstance;
|
|
21
|
+
let bNew: JimpInstance;
|
|
22
|
+
let maskNew: JimpInstance | null;
|
|
23
|
+
if (mask !== null) {
|
|
24
|
+
[aNew, bNew, maskNew] = alignSize([a, b, mask], align);
|
|
25
|
+
} else {
|
|
26
|
+
[aNew, bNew] = alignSize([a, b], align);
|
|
27
|
+
maskNew = null;
|
|
28
|
+
}
|
|
29
|
+
sAlign.stop();
|
|
30
|
+
|
|
36
31
|
const width = aNew.width;
|
|
37
32
|
const height = aNew.height;
|
|
38
33
|
const aData = aNew.bitmap.data;
|
|
39
34
|
const bData = bNew.bitmap.data;
|
|
40
|
-
const mData = maskNew.bitmap.data;
|
|
35
|
+
const mData = maskNew !== null ? maskNew.bitmap.data : null;
|
|
41
36
|
|
|
37
|
+
const sCreate = perf.span("diff.createEmpty_ms");
|
|
42
38
|
const diffImage = createEmptyImage(width, height);
|
|
43
39
|
const dData = diffImage.bitmap.data;
|
|
40
|
+
sCreate.stop();
|
|
44
41
|
|
|
45
42
|
const addition: [number, number][] = [];
|
|
46
43
|
const deletion: [number, number][] = [];
|
|
47
44
|
const modification: [number, number][] = [];
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
aData[idx
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
const sScan = perf.span("diff.scan_ms");
|
|
47
|
+
let diffPixels = 0;
|
|
48
|
+
|
|
49
|
+
if (mData !== null) {
|
|
50
|
+
for (let x = 0; x < width; x++) {
|
|
51
|
+
for (let y = 0; y < height; y++) {
|
|
52
|
+
const idx = (y * width + x) * 4;
|
|
53
|
+
if (mData[idx + 3]! !== 0) continue;
|
|
54
|
+
const aAlpha = aData[idx + 3]!;
|
|
55
|
+
const bAlpha = bData[idx + 3]!;
|
|
56
|
+
if (
|
|
57
|
+
aAlpha === bAlpha &&
|
|
58
|
+
aData[idx] === bData[idx] &&
|
|
59
|
+
aData[idx + 1] === bData[idx + 1] &&
|
|
60
|
+
aData[idx + 2] === bData[idx + 2]
|
|
61
|
+
) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (aAlpha === 0 && bAlpha === 0) continue;
|
|
65
|
+
let target: [number, number][];
|
|
66
|
+
let color: Readonly<RGBAColor>;
|
|
67
|
+
if (aAlpha === 0) {
|
|
68
|
+
target = addition;
|
|
69
|
+
color = pallet.addition;
|
|
70
|
+
} else if (bAlpha === 0) {
|
|
71
|
+
target = deletion;
|
|
72
|
+
color = pallet.deletion;
|
|
73
|
+
} else {
|
|
74
|
+
target = modification;
|
|
75
|
+
color = pallet.modification;
|
|
76
|
+
}
|
|
77
|
+
target.push([x, y]);
|
|
78
|
+
diffPixels++;
|
|
79
|
+
dData[idx] = color[0];
|
|
80
|
+
dData[idx + 1] = color[1];
|
|
81
|
+
dData[idx + 2] = color[2];
|
|
82
|
+
dData[idx + 3] = color[3];
|
|
62
83
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
for (let x = 0; x < width; x++) {
|
|
87
|
+
for (let y = 0; y < height; y++) {
|
|
88
|
+
const idx = (y * width + x) * 4;
|
|
89
|
+
const aAlpha = aData[idx + 3]!;
|
|
90
|
+
const bAlpha = bData[idx + 3]!;
|
|
91
|
+
if (
|
|
92
|
+
aAlpha === bAlpha &&
|
|
93
|
+
aData[idx] === bData[idx] &&
|
|
94
|
+
aData[idx + 1] === bData[idx + 1] &&
|
|
95
|
+
aData[idx + 2] === bData[idx + 2]
|
|
96
|
+
) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (aAlpha === 0 && bAlpha === 0) continue;
|
|
100
|
+
let target: [number, number][];
|
|
101
|
+
let color: Readonly<RGBAColor>;
|
|
102
|
+
if (aAlpha === 0) {
|
|
103
|
+
target = addition;
|
|
104
|
+
color = pallet.addition;
|
|
105
|
+
} else if (bAlpha === 0) {
|
|
106
|
+
target = deletion;
|
|
107
|
+
color = pallet.deletion;
|
|
108
|
+
} else {
|
|
109
|
+
target = modification;
|
|
110
|
+
color = pallet.modification;
|
|
111
|
+
}
|
|
112
|
+
target.push([x, y]);
|
|
113
|
+
diffPixels++;
|
|
114
|
+
dData[idx] = color[0];
|
|
115
|
+
dData[idx + 1] = color[1];
|
|
116
|
+
dData[idx + 2] = color[2];
|
|
117
|
+
dData[idx + 3] = color[3];
|
|
75
118
|
}
|
|
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
119
|
}
|
|
82
120
|
}
|
|
121
|
+
sScan.stop();
|
|
122
|
+
perf.incr("diff.diffPixels", diffPixels);
|
|
123
|
+
perf.incr("diff.totalPixels", width * height);
|
|
124
|
+
perf.incr("diff.pages");
|
|
83
125
|
|
|
84
|
-
return {
|
|
126
|
+
return {
|
|
127
|
+
diff: diffImage,
|
|
128
|
+
addition,
|
|
129
|
+
deletion,
|
|
130
|
+
modification,
|
|
131
|
+
hasDiff: diffPixels > 0,
|
|
132
|
+
};
|
|
85
133
|
}
|
package/src/image.ts
CHANGED
|
@@ -1,22 +1,6 @@
|
|
|
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
1
|
import * as jimp from "jimp";
|
|
19
2
|
import { type JimpInstance } from "./jimp.ts";
|
|
3
|
+
import { perf } from "./perf.ts";
|
|
20
4
|
|
|
21
5
|
export function createEmptyImage(width: number, height: number) {
|
|
22
6
|
return new jimp.Jimp({
|
|
@@ -68,7 +52,7 @@ function alignImage(
|
|
|
68
52
|
targetWidth: number,
|
|
69
53
|
targetHeight: number,
|
|
70
54
|
align: AlignStrategy,
|
|
71
|
-
) {
|
|
55
|
+
): JimpInstance {
|
|
72
56
|
if (align === "resize") {
|
|
73
57
|
return img.resize({ w: targetWidth, h: targetHeight });
|
|
74
58
|
} else {
|
|
@@ -117,6 +101,7 @@ export function composeLayers(
|
|
|
117
101
|
canvasHeight: number,
|
|
118
102
|
layers: [JimpInstance, number][],
|
|
119
103
|
) {
|
|
104
|
+
const _span = perf.span("image.compose_ms");
|
|
120
105
|
const canvas = createEmptyImage(canvasWidth, canvasHeight);
|
|
121
106
|
const dData = canvas.bitmap.data;
|
|
122
107
|
for (const [image, opacity] of layers) {
|
|
@@ -146,5 +131,6 @@ export function composeLayers(
|
|
|
146
131
|
}
|
|
147
132
|
}
|
|
148
133
|
}
|
|
134
|
+
_span.stop();
|
|
149
135
|
return canvas;
|
|
150
136
|
}
|
package/src/index.html
CHANGED
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
</div>
|
|
50
50
|
<div>
|
|
51
51
|
<label for="workers">Workers:</label>
|
|
52
|
-
<input type="number" id="workers"
|
|
52
|
+
<input type="number" id="workers" placeholder="auto" min="1" />
|
|
53
53
|
</div>
|
|
54
54
|
<div>
|
|
55
55
|
<label for="addition-color">Addition:</label>
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
<input type="color" id="modification-color" value="#ffc105" />
|
|
65
65
|
</div>
|
|
66
66
|
<button type="submit">Submit</button>
|
|
67
|
+
<label>
|
|
68
|
+
<input type="checkbox" id="hide-no-diff" />
|
|
69
|
+
Hide pages with no diff
|
|
70
|
+
</label>
|
|
71
|
+
<button type="button" id="download-zip" disabled>Download zip</button>
|
|
67
72
|
</form>
|
|
68
73
|
</div>
|
|
69
74
|
<div class="error" id="error-message"></div>
|
package/src/index.test.ts
CHANGED
|
@@ -1,26 +1,10 @@
|
|
|
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
1
|
import assert from "node:assert/strict";
|
|
19
2
|
import fs from "node:fs";
|
|
20
3
|
import test from "node:test";
|
|
21
4
|
|
|
22
5
|
import {
|
|
23
6
|
defaultOptions,
|
|
7
|
+
defaultWorkers,
|
|
24
8
|
formatHex,
|
|
25
9
|
isValidAlignStrategy,
|
|
26
10
|
parseHex,
|
|
@@ -50,10 +34,18 @@ test("defaultOptions", () => {
|
|
|
50
34
|
deletion: [0xff, 0x57, 0x24, 0xff],
|
|
51
35
|
modification: [0xff, 0xc1, 0x05, 0xff],
|
|
52
36
|
},
|
|
53
|
-
workers:
|
|
37
|
+
workers: defaultWorkers,
|
|
54
38
|
});
|
|
55
39
|
});
|
|
56
40
|
|
|
41
|
+
test("defaultWorkers scales with cores, capped at 4", () => {
|
|
42
|
+
assert.equal(
|
|
43
|
+
defaultWorkers,
|
|
44
|
+
Math.max(1, Math.min(globalThis.navigator?.hardwareConcurrency ?? 1, 4)),
|
|
45
|
+
);
|
|
46
|
+
assert.ok(defaultWorkers >= 1 && defaultWorkers <= 4);
|
|
47
|
+
});
|
|
48
|
+
|
|
57
49
|
test("isValidAlignStrategy", async (ctx) => {
|
|
58
50
|
for (const s of [
|
|
59
51
|
"resize",
|
package/src/index.ts
CHANGED
|
@@ -1,28 +1,13 @@
|
|
|
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
1
|
import * as jimp from "jimp";
|
|
19
|
-
import * as mupdf from "mupdf";
|
|
20
2
|
import Worker from "web-worker";
|
|
21
3
|
|
|
22
4
|
import { type Pallet } from "./diff.ts";
|
|
23
5
|
import { isValidAlignStrategy, type AlignStrategy } from "./image.ts";
|
|
24
6
|
import { withIndex } from "./iterable.ts";
|
|
7
|
+
import { countPages, renderPageRangePng } from "./pdf.ts";
|
|
8
|
+
import { perf } from "./perf.ts";
|
|
25
9
|
import { parseHex, formatHex } from "./rgba-color.ts";
|
|
10
|
+
import { sliceBackingBuffer } from "./transferable.ts";
|
|
26
11
|
import { VERSION } from "./version.ts";
|
|
27
12
|
import type { JimpInstance } from "./jimp.ts";
|
|
28
13
|
import type {
|
|
@@ -34,7 +19,7 @@ import type {
|
|
|
34
19
|
ReadyMessage,
|
|
35
20
|
} from "./worker.ts";
|
|
36
21
|
|
|
37
|
-
export { withIndex, isValidAlignStrategy, parseHex, formatHex };
|
|
22
|
+
export { withIndex, isValidAlignStrategy, parseHex, formatHex, perf };
|
|
38
23
|
|
|
39
24
|
type Options = {
|
|
40
25
|
dpi: number;
|
|
@@ -54,6 +39,15 @@ type Result = {
|
|
|
54
39
|
modification: [number, number][];
|
|
55
40
|
};
|
|
56
41
|
|
|
42
|
+
// Default parallelism scales with the machine: rendering and diffing run across
|
|
43
|
+
// several workers, so the out-of-the-box run uses the CPU rather than a single
|
|
44
|
+
// core. Capped at 4 to keep the default memory footprint and oversubscription
|
|
45
|
+
// modest; raise --workers explicitly for large jobs on big machines.
|
|
46
|
+
export const defaultWorkers = Math.max(
|
|
47
|
+
1,
|
|
48
|
+
Math.min(globalThis.navigator?.hardwareConcurrency ?? 1, 4),
|
|
49
|
+
);
|
|
50
|
+
|
|
57
51
|
export const defaultOptions: Options = {
|
|
58
52
|
dpi: 150,
|
|
59
53
|
alpha: true,
|
|
@@ -64,25 +58,9 @@ export const defaultOptions: Options = {
|
|
|
64
58
|
deletion: [0xff, 0x57, 0x24, 0xff],
|
|
65
59
|
modification: [0xff, 0xc1, 0x05, 0xff],
|
|
66
60
|
},
|
|
67
|
-
workers:
|
|
61
|
+
workers: defaultWorkers,
|
|
68
62
|
};
|
|
69
63
|
|
|
70
|
-
function asSharedBytes(bytes: Uint8Array): Uint8Array {
|
|
71
|
-
const isNode =
|
|
72
|
-
typeof globalThis.process !== "undefined" &&
|
|
73
|
-
!!globalThis.process.versions?.node;
|
|
74
|
-
const coiOk =
|
|
75
|
-
(globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated ===
|
|
76
|
-
true;
|
|
77
|
-
if (typeof SharedArrayBuffer !== "undefined" && (isNode || coiOk)) {
|
|
78
|
-
const sab = new SharedArrayBuffer(bytes.byteLength);
|
|
79
|
-
const view = new Uint8Array(sab);
|
|
80
|
-
view.set(bytes);
|
|
81
|
-
return view;
|
|
82
|
-
}
|
|
83
|
-
return new Uint8Array(bytes);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
64
|
type WorkerResponse =
|
|
87
65
|
| LoadedMessage
|
|
88
66
|
| ReadyMessage
|
|
@@ -135,12 +113,29 @@ class WorkerHandle {
|
|
|
135
113
|
});
|
|
136
114
|
}
|
|
137
115
|
|
|
138
|
-
|
|
116
|
+
processDiff(
|
|
117
|
+
index: number,
|
|
118
|
+
a: Uint8Array<ArrayBuffer> | null,
|
|
119
|
+
b: Uint8Array<ArrayBuffer> | null,
|
|
120
|
+
mask: Uint8Array<ArrayBuffer> | null,
|
|
121
|
+
): Promise<PageResultMessage> {
|
|
139
122
|
return new Promise<PageResultMessage>((resolve, reject) => {
|
|
140
123
|
this.pendingResolve = resolve as (data: WorkerResponse) => void;
|
|
141
124
|
this.pendingReject = reject;
|
|
142
|
-
const
|
|
143
|
-
|
|
125
|
+
const aBuf = a !== null ? sliceBackingBuffer(a) : null;
|
|
126
|
+
const bBuf = b !== null ? sliceBackingBuffer(b) : null;
|
|
127
|
+
const maskBuf = mask !== null ? sliceBackingBuffer(mask) : null;
|
|
128
|
+
const msg: PageMessage = {
|
|
129
|
+
type: "page",
|
|
130
|
+
index,
|
|
131
|
+
a: aBuf,
|
|
132
|
+
b: bBuf,
|
|
133
|
+
mask: maskBuf,
|
|
134
|
+
};
|
|
135
|
+
const transfer = [aBuf, bBuf, maskBuf].filter(
|
|
136
|
+
(buf): buf is ArrayBuffer => buf !== null,
|
|
137
|
+
);
|
|
138
|
+
this.worker.postMessage(msg, transfer);
|
|
144
139
|
});
|
|
145
140
|
}
|
|
146
141
|
|
|
@@ -154,8 +149,18 @@ function workerUrl(): URL {
|
|
|
154
149
|
return new URL(`${file}?v=${encodeURIComponent(VERSION)}`, import.meta.url);
|
|
155
150
|
}
|
|
156
151
|
|
|
152
|
+
function unpackCoords(buf: ArrayBuffer): [number, number][] {
|
|
153
|
+
const arr = new Int32Array(buf);
|
|
154
|
+
const out: [number, number][] = new Array(arr.length >>> 1);
|
|
155
|
+
for (let i = 0, j = 0; j < out.length; i += 2, j++) {
|
|
156
|
+
out[j] = [arr[i]!, arr[i + 1]!];
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
157
161
|
function pageResultToResult(msg: PageResultMessage): Result {
|
|
158
|
-
|
|
162
|
+
const sP = perf.span("main.pageResultToResult_ms");
|
|
163
|
+
const r = {
|
|
159
164
|
a: jimp.Jimp.fromBitmap({
|
|
160
165
|
width: msg.a.width,
|
|
161
166
|
height: msg.a.height,
|
|
@@ -171,10 +176,14 @@ function pageResultToResult(msg: PageResultMessage): Result {
|
|
|
171
176
|
height: msg.diff.height,
|
|
172
177
|
data: new Uint8Array(msg.diff.data),
|
|
173
178
|
}) as JimpInstance,
|
|
174
|
-
addition: msg.addition,
|
|
175
|
-
deletion: msg.deletion,
|
|
176
|
-
modification: msg.modification,
|
|
179
|
+
addition: unpackCoords(msg.addition),
|
|
180
|
+
deletion: unpackCoords(msg.deletion),
|
|
181
|
+
modification: unpackCoords(msg.modification),
|
|
177
182
|
};
|
|
183
|
+
sP.stop();
|
|
184
|
+
perf.incr("main.resultsReceived");
|
|
185
|
+
if (msg.perf) perf.merge(msg.perf);
|
|
186
|
+
return r;
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
export async function* visualizeDifferences(
|
|
@@ -196,62 +205,144 @@ export async function* visualizeDifferences(
|
|
|
196
205
|
workers: options?.workers ?? defaultOptions.workers,
|
|
197
206
|
};
|
|
198
207
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
const [aPages, bPages, maskPages] = await Promise.all([
|
|
209
|
+
countPages(a),
|
|
210
|
+
countPages(b),
|
|
202
211
|
typeof merged.mask !== "undefined"
|
|
203
|
-
?
|
|
204
|
-
:
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
probeB.countPages(),
|
|
208
|
-
probeMask.countPages(),
|
|
209
|
-
);
|
|
210
|
-
probe.destroy();
|
|
211
|
-
probeB.destroy();
|
|
212
|
-
probeMask.destroy();
|
|
212
|
+
? countPages(merged.mask)
|
|
213
|
+
: Promise.resolve(0),
|
|
214
|
+
]);
|
|
215
|
+
const maxPages = Math.max(aPages, bPages, maskPages);
|
|
213
216
|
|
|
214
217
|
if (maxPages === 0) return;
|
|
215
218
|
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
219
|
-
typeof merged.mask !== "undefined" ? asSharedBytes(merged.mask) : null;
|
|
219
|
+
const mask = merged.mask;
|
|
220
|
+
const hasMask = typeof mask !== "undefined" && maskPages > 0;
|
|
221
|
+
const numDocs = hasMask ? 3 : 2;
|
|
220
222
|
|
|
221
223
|
const initMsg: InitMessage = {
|
|
222
224
|
type: "init",
|
|
223
|
-
aBytes,
|
|
224
|
-
bBytes,
|
|
225
|
-
maskBytes,
|
|
226
|
-
dpi: merged.dpi,
|
|
227
|
-
alpha: merged.alpha,
|
|
228
225
|
pallet: merged.pallet,
|
|
229
226
|
align: merged.align,
|
|
230
227
|
};
|
|
231
228
|
|
|
232
229
|
const N = Math.max(1, Math.min(merged.workers, maxPages));
|
|
233
230
|
const url = workerUrl();
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const buffered = new Map<number, Result>();
|
|
238
|
-
let nextToAssign = 0;
|
|
239
|
-
|
|
240
|
-
const workers: WorkerHandle[] = [worker0];
|
|
241
|
-
for (let i = 1; i < N; i++) {
|
|
231
|
+
const workers: WorkerHandle[] = [];
|
|
232
|
+
for (let i = 0; i < N; i++) {
|
|
242
233
|
const w = new WorkerHandle(url);
|
|
243
234
|
await w.init(initMsg);
|
|
244
235
|
workers.push(w);
|
|
245
236
|
}
|
|
246
237
|
|
|
238
|
+
let aborted: unknown = null;
|
|
239
|
+
|
|
240
|
+
// One PNG slot per page per document, fulfilled as render chunks complete.
|
|
241
|
+
// Pages past a document's page count resolve to null (an empty/transparent
|
|
242
|
+
// page). The defensive catch keeps a chunk failure from surfacing as an
|
|
243
|
+
// unhandled rejection before a diff lane awaits the slot.
|
|
244
|
+
type Slot = {
|
|
245
|
+
p: Promise<Uint8Array<ArrayBuffer> | null>;
|
|
246
|
+
resolve: (v: Uint8Array<ArrayBuffer> | null) => void;
|
|
247
|
+
reject: (e: unknown) => void;
|
|
248
|
+
};
|
|
249
|
+
const makeSlots = (count: number): Slot[] =>
|
|
250
|
+
Array.from({ length: maxPages }, (_, i) => {
|
|
251
|
+
if (i >= count) {
|
|
252
|
+
return { p: Promise.resolve(null), resolve: () => {}, reject: () => {} };
|
|
253
|
+
}
|
|
254
|
+
let resolve!: (v: Uint8Array<ArrayBuffer> | null) => void;
|
|
255
|
+
let reject!: (e: unknown) => void;
|
|
256
|
+
const p = new Promise<Uint8Array<ArrayBuffer> | null>((res, rej) => {
|
|
257
|
+
resolve = res;
|
|
258
|
+
reject = rej;
|
|
259
|
+
});
|
|
260
|
+
p.catch(() => {});
|
|
261
|
+
return { p, resolve, reject };
|
|
262
|
+
});
|
|
263
|
+
const slots = {
|
|
264
|
+
a: makeSlots(aPages),
|
|
265
|
+
b: makeSlots(bPages),
|
|
266
|
+
mask: makeSlots(hasMask ? maskPages : 0),
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Render chunk tasks: batch several pages per gs() call to amortize startup,
|
|
270
|
+
// interleaving A/B/mask by page range so the early pages of every document
|
|
271
|
+
// become available together (which keeps the diff stage fed).
|
|
272
|
+
// Render concurrency. Aim for ~2x as many chunks as render slots so pages
|
|
273
|
+
// arrive in waves and the diff/decode stage overlaps later renders instead of
|
|
274
|
+
// waiting for one big batch. A floor keeps each chunk large enough to amortize
|
|
275
|
+
// Ghostscript's per-call startup: when there are many slots relative to pages,
|
|
276
|
+
// the slots are already saturated, so batching beats finer streaming.
|
|
277
|
+
const MIN_CHUNK = 4;
|
|
278
|
+
const R = Math.max(merged.workers, numDocs);
|
|
279
|
+
const totalRenderPages = aPages + bPages + (hasMask ? maskPages : 0);
|
|
280
|
+
const chunkSize = Math.max(
|
|
281
|
+
1,
|
|
282
|
+
Math.min(maxPages, Math.max(MIN_CHUNK, Math.ceil(totalRenderPages / (2 * R)))),
|
|
283
|
+
);
|
|
284
|
+
type Task = { bytes: Uint8Array; start: number; end: number; slots: Slot[] };
|
|
285
|
+
const tasks: Task[] = [];
|
|
286
|
+
const pushChunk = (
|
|
287
|
+
bytes: Uint8Array | undefined,
|
|
288
|
+
count: number,
|
|
289
|
+
target: Slot[],
|
|
290
|
+
start: number,
|
|
291
|
+
) => {
|
|
292
|
+
if (bytes === undefined || start >= count) return;
|
|
293
|
+
tasks.push({
|
|
294
|
+
bytes,
|
|
295
|
+
start,
|
|
296
|
+
end: Math.min(start + chunkSize, count) - 1,
|
|
297
|
+
slots: target,
|
|
298
|
+
});
|
|
299
|
+
};
|
|
300
|
+
for (let start = 0; start < maxPages; start += chunkSize) {
|
|
301
|
+
pushChunk(a, aPages, slots.a, start);
|
|
302
|
+
pushChunk(b, bPages, slots.b, start);
|
|
303
|
+
if (hasMask) pushChunk(mask, maskPages, slots.mask, start);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let taskIdx = 0;
|
|
307
|
+
const renderLoops = Array.from(
|
|
308
|
+
{ length: Math.min(R, tasks.length) },
|
|
309
|
+
async () => {
|
|
310
|
+
while (taskIdx < tasks.length && aborted === null) {
|
|
311
|
+
const t = tasks[taskIdx++]!;
|
|
312
|
+
try {
|
|
313
|
+
const pngs = await renderPageRangePng(
|
|
314
|
+
t.bytes,
|
|
315
|
+
t.start,
|
|
316
|
+
t.end,
|
|
317
|
+
merged.dpi,
|
|
318
|
+
merged.alpha,
|
|
319
|
+
);
|
|
320
|
+
for (let i = t.start; i <= t.end; i++) {
|
|
321
|
+
t.slots[i]!.resolve(pngs.get(i) ?? null);
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
aborted = e;
|
|
325
|
+
for (let i = t.start; i <= t.end; i++) t.slots[i]!.reject(e);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const buffered = new Map<number, Result>();
|
|
332
|
+
let nextToAssign = 0;
|
|
247
333
|
const resolvers = new Map<number, (r: Result) => void>();
|
|
248
334
|
let workerError: unknown = null;
|
|
249
335
|
|
|
250
|
-
const
|
|
336
|
+
const diffLoops = workers.map(async (w) => {
|
|
251
337
|
while (nextToAssign < maxPages && workerError === null) {
|
|
252
338
|
const idx = nextToAssign++;
|
|
253
339
|
try {
|
|
254
|
-
const
|
|
340
|
+
const [aPng, bPng, maskPng] = await Promise.all([
|
|
341
|
+
slots.a[idx]!.p,
|
|
342
|
+
slots.b[idx]!.p,
|
|
343
|
+
slots.mask[idx]!.p,
|
|
344
|
+
]);
|
|
345
|
+
const msg = await w.processDiff(idx, aPng, bPng, maskPng);
|
|
255
346
|
const result = pageResultToResult(msg);
|
|
256
347
|
const resolve = resolvers.get(idx);
|
|
257
348
|
if (resolve) {
|
|
@@ -259,9 +350,11 @@ export async function* visualizeDifferences(
|
|
|
259
350
|
resolve(result);
|
|
260
351
|
} else {
|
|
261
352
|
buffered.set(idx, result);
|
|
353
|
+
perf.setMax("main.bufferedPeak", buffered.size);
|
|
262
354
|
}
|
|
263
355
|
} catch (e) {
|
|
264
356
|
workerError = e;
|
|
357
|
+
aborted = e;
|
|
265
358
|
for (const [, resolve] of resolvers) resolve(null as never);
|
|
266
359
|
resolvers.clear();
|
|
267
360
|
return;
|
|
@@ -278,13 +371,20 @@ export async function* visualizeDifferences(
|
|
|
278
371
|
buffered.delete(i);
|
|
279
372
|
r = buf;
|
|
280
373
|
} else {
|
|
374
|
+
const sWait = perf.span("main.yieldWaitMain_ms");
|
|
281
375
|
r = await new Promise<Result>((resolve) => resolvers.set(i, resolve));
|
|
376
|
+
sWait.stop();
|
|
282
377
|
if (workerError !== null) throw workerError;
|
|
283
378
|
}
|
|
379
|
+
const sYield = perf.span("main.consumerTime_ms");
|
|
284
380
|
yield r;
|
|
381
|
+
sYield.stop();
|
|
285
382
|
}
|
|
286
|
-
await Promise.all(
|
|
383
|
+
await Promise.all(diffLoops);
|
|
384
|
+
await Promise.all(renderLoops);
|
|
287
385
|
} finally {
|
|
386
|
+
aborted = aborted ?? new Error("aborted");
|
|
387
|
+
await Promise.allSettled(renderLoops);
|
|
288
388
|
for (const w of workers) w.terminate();
|
|
289
389
|
}
|
|
290
390
|
}
|
package/src/iterable.test.ts
CHANGED
|
@@ -1,20 +1,3 @@
|
|
|
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
1
|
import assert from "assert";
|
|
19
2
|
import test from "node:test";
|
|
20
3
|
|