@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/iterable.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
|
export async function* withIndex<T>(iter: AsyncIterable<T>, start = 0) {
|
|
19
2
|
let index = start;
|
|
20
3
|
for await (const item of iter) {
|
package/src/jimp.ts
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
import * as jimp from "jimp";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Narrowed view of `jimp.JimpInstance` used throughout the project.
|
|
5
|
+
*
|
|
6
|
+
* Two constraints are tightened relative to the upstream type:
|
|
7
|
+
*
|
|
8
|
+
* 1. `bitmap.data` is asserted to be `Uint8Array<ArrayBuffer>` (never
|
|
9
|
+
* SAB-backed). jimp allocates pixels via `Buffer`, which is always
|
|
10
|
+
* backed by a real ArrayBuffer in practice. Pinning the generic
|
|
11
|
+
* parameter here lets `sliceBackingBuffer` (and `postMessage` transfer
|
|
12
|
+
* lists) infer ArrayBuffer instead of ArrayBufferLike.
|
|
13
|
+
*
|
|
14
|
+
* 2. `resize` and `composite` return `JimpInstance` (this narrowed type)
|
|
15
|
+
* rather than upstream `jimp.JimpInstance`, so chaining preserves the
|
|
16
|
+
* bitmap-backing constraint above.
|
|
17
|
+
*/
|
|
3
18
|
export type JimpInstance = Pick<
|
|
4
19
|
jimp.JimpInstance,
|
|
5
|
-
| "
|
|
6
|
-
| "height"
|
|
7
|
-
| "bitmap"
|
|
8
|
-
| "getPixelColor"
|
|
9
|
-
| "setPixelColor"
|
|
10
|
-
| "resize"
|
|
11
|
-
| "composite"
|
|
20
|
+
"width" | "height" | "getPixelColor" | "setPixelColor"
|
|
12
21
|
> & {
|
|
22
|
+
bitmap: {
|
|
23
|
+
data: Uint8Array<ArrayBuffer>;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
resize: (options: Parameters<jimp.JimpInstance["resize"]>[0]) => JimpInstance;
|
|
28
|
+
composite: (
|
|
29
|
+
...args: Parameters<jimp.JimpInstance["composite"]>
|
|
30
|
+
) => JimpInstance;
|
|
13
31
|
getBuffer: (mime: "image/png") => ReturnType<jimp.JimpInstance["getBuffer"]>;
|
|
14
32
|
getBase64: (mime: "image/png") => ReturnType<jimp.JimpInstance["getBase64"]>;
|
|
15
33
|
};
|
package/src/pdf.ts
CHANGED
|
@@ -1,74 +1,111 @@
|
|
|
1
|
+
import { gs } from "@u1f992/gs-wasm";
|
|
2
|
+
|
|
3
|
+
import { perf } from "./perf.ts";
|
|
4
|
+
|
|
1
5
|
/*
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
-
* (at your option) any later version.
|
|
6
|
+
* Pages are rendered with Ghostscript (gs-wasm). Each gs() invocation spins up
|
|
7
|
+
* its own worker and Ghostscript instance, so we render one page per call and
|
|
8
|
+
* let the caller drive concurrency (e.g. by rendering A/B/mask together and by
|
|
9
|
+
* running multiple page workers).
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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/>.
|
|
11
|
+
* Ghostscript (via the `web-worker` package) must be invoked from a context
|
|
12
|
+
* that can itself spawn a worker. In Node's `web-worker` this is only the main
|
|
13
|
+
* thread, so rendering happens on the main thread and the resulting PNG bytes
|
|
14
|
+
* are handed off to the diff workers for decoding. The PDF is placed in
|
|
15
|
+
* Ghostscript's in-memory FS as `input.pdf` and the page is read back as a PNG.
|
|
16
16
|
*/
|
|
17
|
+
const INPUT_VM_PATH = "input.pdf";
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Count the pages of a PDF using Ghostscript's `pdfpagecount`. Runs with
|
|
21
|
+
* `-dNODISPLAY` (no rendering) so it is cheap relative to a page render.
|
|
22
|
+
*/
|
|
23
|
+
export async function countPages(pdf: Uint8Array): Promise<number> {
|
|
24
|
+
const span = perf.span("pdf.countPages_ms");
|
|
25
|
+
const out: number[] = [];
|
|
26
|
+
const { exitCode } = await gs({
|
|
27
|
+
args: [
|
|
28
|
+
"-q",
|
|
29
|
+
"-dNODISPLAY",
|
|
30
|
+
"-dNOSAFER",
|
|
31
|
+
"-c",
|
|
32
|
+
`(${INPUT_VM_PATH}) (r) file runpdfbegin pdfpagecount = quit`,
|
|
33
|
+
],
|
|
34
|
+
inputFiles: { [INPUT_VM_PATH]: pdf },
|
|
35
|
+
onStdout: (charCode) => {
|
|
36
|
+
if (charCode !== null) out.push(charCode);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
span.stop();
|
|
40
|
+
if (exitCode !== 0) {
|
|
41
|
+
throw new Error(`gs countPages failed (exit ${exitCode})`);
|
|
26
42
|
}
|
|
43
|
+
const text = String.fromCharCode(...out).trim();
|
|
44
|
+
const n = Number.parseInt(text, 10);
|
|
45
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
46
|
+
throw new Error(`gs countPages: unexpected output ${JSON.stringify(text)}`);
|
|
47
|
+
}
|
|
48
|
+
return n;
|
|
27
49
|
}
|
|
28
50
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Render an inclusive range of (0-based) pages of a PDF to PNG bytes in a single
|
|
53
|
+
* Ghostscript invocation, returning a map keyed by 0-based page index. Batching
|
|
54
|
+
* several pages per call amortizes Ghostscript's startup and PDF parsing, which
|
|
55
|
+
* dominate a single-page render. `alpha` selects the device: `pngalpha` keeps
|
|
56
|
+
* the page background transparent (so the diff can tell "no content" from
|
|
57
|
+
* "content" via the alpha channel), while `png16m` renders opaque. Decoding to
|
|
58
|
+
* RGBA is left to the caller (the diff workers) so it can run off this thread.
|
|
59
|
+
*/
|
|
60
|
+
export async function renderPageRangePng(
|
|
61
|
+
pdf: Uint8Array,
|
|
62
|
+
firstIndex: number,
|
|
63
|
+
lastIndex: number,
|
|
64
|
+
dpi: number,
|
|
65
|
+
alpha: boolean,
|
|
66
|
+
): Promise<Map<number, Uint8Array<ArrayBuffer>>> {
|
|
67
|
+
const device = alpha ? "pngalpha" : "png16m";
|
|
68
|
+
const first = firstIndex + 1; // Ghostscript page numbers are 1-based.
|
|
69
|
+
const last = lastIndex + 1;
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
71
|
+
// `%d` in the output pattern is the 1-based index of the page *within this
|
|
72
|
+
// run* (it restarts at 1 regardless of -dFirstPage), so the k-th output maps
|
|
73
|
+
// back to absolute page (first + k - 1).
|
|
74
|
+
const pageCount = last - first + 1;
|
|
75
|
+
const names: string[] = [];
|
|
76
|
+
for (let k = 1; k <= pageCount; k++) names.push(`out-${k}.png`);
|
|
39
77
|
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
const sRender = perf.span("pdf.gsRender_ms");
|
|
79
|
+
const { exitCode, outputFiles } = await gs({
|
|
80
|
+
args: [
|
|
81
|
+
"-dNOPAUSE",
|
|
82
|
+
"-dBATCH",
|
|
83
|
+
"-dQUIET",
|
|
84
|
+
`-dFirstPage=${first}`,
|
|
85
|
+
`-dLastPage=${last}`,
|
|
86
|
+
`-sDEVICE=${device}`,
|
|
87
|
+
`-r${dpi}`,
|
|
88
|
+
"-dTextAlphaBits=4",
|
|
89
|
+
"-dGraphicsAlphaBits=4",
|
|
90
|
+
"-sOutputFile=out-%d.png",
|
|
91
|
+
INPUT_VM_PATH,
|
|
92
|
+
],
|
|
93
|
+
inputFiles: { [INPUT_VM_PATH]: pdf },
|
|
94
|
+
outputFilePaths: names,
|
|
95
|
+
});
|
|
96
|
+
sRender.stop();
|
|
97
|
+
if (exitCode !== 0) {
|
|
98
|
+
throw new Error(`gs render failed (pages ${first}-${last}, exit ${exitCode})`);
|
|
99
|
+
}
|
|
100
|
+
const result = new Map<number, Uint8Array<ArrayBuffer>>();
|
|
101
|
+
for (let k = 1; k <= pageCount; k++) {
|
|
102
|
+
const png = outputFiles[`out-${k}.png`];
|
|
103
|
+
if (!png) {
|
|
104
|
+
throw new Error(`gs render produced no output (page ${first + k - 1})`);
|
|
52
105
|
}
|
|
106
|
+
result.set(firstIndex + (k - 1), png);
|
|
53
107
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export async function pageToImage(
|
|
58
|
-
page: mupdf.Page,
|
|
59
|
-
dpi: number,
|
|
60
|
-
alpha: boolean,
|
|
61
|
-
) {
|
|
62
|
-
const zoom = dpi / 72;
|
|
63
|
-
const pixmap = page.toPixmap(
|
|
64
|
-
[zoom, 0, 0, zoom, 0, 0],
|
|
65
|
-
mupdf.ColorSpace.DeviceRGB,
|
|
66
|
-
alpha,
|
|
67
|
-
);
|
|
68
|
-
const width = pixmap.getWidth();
|
|
69
|
-
const height = pixmap.getHeight();
|
|
70
|
-
const data = pixmapToRGBA(pixmap);
|
|
71
|
-
pixmap.destroy();
|
|
72
|
-
page.destroy();
|
|
73
|
-
return jimp.Jimp.fromBitmap({ width, height, data }) as JimpInstance;
|
|
108
|
+
perf.incr("pdf.gsCalls");
|
|
109
|
+
perf.incr("pdf.pagesRendered", pageCount);
|
|
110
|
+
return result;
|
|
74
111
|
}
|
package/src/perf.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const _enabled = (() => {
|
|
2
|
+
try {
|
|
3
|
+
if (
|
|
4
|
+
typeof process !== "undefined" &&
|
|
5
|
+
process.env &&
|
|
6
|
+
process.env.PDFDIFF_PROFILE === "1"
|
|
7
|
+
) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
} catch {
|
|
11
|
+
// process not available (e.g. in some browser worker environments)
|
|
12
|
+
}
|
|
13
|
+
const g = globalThis as { __PDFDIFF_PROFILE__?: boolean };
|
|
14
|
+
return g.__PDFDIFF_PROFILE__ === true;
|
|
15
|
+
})();
|
|
16
|
+
|
|
17
|
+
export type Counters = Readonly<Record<string, number>>;
|
|
18
|
+
|
|
19
|
+
export type Span = { stop(): void };
|
|
20
|
+
|
|
21
|
+
type Perf = {
|
|
22
|
+
readonly enabled: boolean;
|
|
23
|
+
span(key: string): Span;
|
|
24
|
+
incr(key: string, delta?: number): void;
|
|
25
|
+
setMax(key: string, value: number): void;
|
|
26
|
+
merge(other: Counters): void;
|
|
27
|
+
dump(): Counters;
|
|
28
|
+
reset(): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const _counters: Record<string, number> = Object.create(null);
|
|
32
|
+
|
|
33
|
+
const _NOOP_SPAN: Span = Object.freeze({ stop() {} });
|
|
34
|
+
const _noop = () => {};
|
|
35
|
+
const _emptyDump = (): Counters => Object.freeze({});
|
|
36
|
+
|
|
37
|
+
const _realPerf: Perf = {
|
|
38
|
+
enabled: true,
|
|
39
|
+
span(key) {
|
|
40
|
+
const t0 = performance.now();
|
|
41
|
+
return {
|
|
42
|
+
stop() {
|
|
43
|
+
_counters[key] = (_counters[key] ?? 0) + (performance.now() - t0);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
incr(key, delta = 1) {
|
|
48
|
+
_counters[key] = (_counters[key] ?? 0) + delta;
|
|
49
|
+
},
|
|
50
|
+
setMax(key, value) {
|
|
51
|
+
const cur = _counters[key];
|
|
52
|
+
if (cur === undefined || value > cur) _counters[key] = value;
|
|
53
|
+
},
|
|
54
|
+
merge(other) {
|
|
55
|
+
for (const k of Object.keys(other)) {
|
|
56
|
+
_counters[k] = (_counters[k] ?? 0) + other[k]!;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
dump() {
|
|
60
|
+
return { ..._counters };
|
|
61
|
+
},
|
|
62
|
+
reset() {
|
|
63
|
+
for (const k of Object.keys(_counters)) delete _counters[k];
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const _noopPerf: Perf = {
|
|
68
|
+
enabled: false,
|
|
69
|
+
span: () => _NOOP_SPAN,
|
|
70
|
+
incr: _noop,
|
|
71
|
+
setMax: _noop,
|
|
72
|
+
merge: _noop,
|
|
73
|
+
dump: _emptyDump,
|
|
74
|
+
reset: _noop,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const perf: Perf = _enabled ? _realPerf : _noopPerf;
|
package/src/rgba-color.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
|
|
package/src/rgba-color.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
|
export type RGBAColor = [number, number, number, number];
|
|
19
2
|
|
|
20
3
|
export const parseHex = (hex: string) => {
|
package/src/style.css
CHANGED
|
@@ -11,9 +11,21 @@
|
|
|
11
11
|
.diff-table {
|
|
12
12
|
border: 1px solid black;
|
|
13
13
|
border-collapse: collapse;
|
|
14
|
+
width: 100%;
|
|
15
|
+
table-layout: fixed;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
.diff-table th,
|
|
17
19
|
.diff-table td {
|
|
18
20
|
border: 1px solid black;
|
|
19
21
|
}
|
|
22
|
+
|
|
23
|
+
.diff-table img {
|
|
24
|
+
display: block;
|
|
25
|
+
max-width: 100%;
|
|
26
|
+
height: auto;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
body.hide-no-diff .diff-details.no-diff {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice an ArrayBufferView into a standalone backing buffer of the same kind
|
|
3
|
+
* (ArrayBuffer in, ArrayBuffer out; SharedArrayBuffer in, SharedArrayBuffer
|
|
4
|
+
* out). The buffer kind is preserved through the generic parameter.
|
|
5
|
+
*/
|
|
6
|
+
export function sliceBackingBuffer<TArrayBuffer extends ArrayBufferLike>(src: {
|
|
7
|
+
buffer: TArrayBuffer;
|
|
8
|
+
byteOffset: number;
|
|
9
|
+
byteLength: number;
|
|
10
|
+
}): TArrayBuffer {
|
|
11
|
+
return src.buffer.slice(
|
|
12
|
+
src.byteOffset,
|
|
13
|
+
src.byteOffset + src.byteLength,
|
|
14
|
+
) as TArrayBuffer;
|
|
15
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -1,38 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 mupdf from "mupdf";
|
|
19
|
-
|
|
20
|
-
import { drawDifference, type Pallet } from "./diff.ts";
|
|
21
|
-
import {
|
|
22
|
-
composeLayers,
|
|
23
|
-
createEmptyImage,
|
|
24
|
-
type AlignStrategy,
|
|
25
|
-
} from "./image.ts";
|
|
1
|
+
import { decodePng } from "./decode.ts";
|
|
2
|
+
import { type Pallet } from "./diff.ts";
|
|
3
|
+
import { alignSize, createEmptyImage, type AlignStrategy } from "./image.ts";
|
|
26
4
|
import type { JimpInstance } from "./jimp.ts";
|
|
27
|
-
import {
|
|
5
|
+
import { perf, type Counters } from "./perf.ts";
|
|
6
|
+
import { type RGBAColor } from "./rgba-color.ts";
|
|
7
|
+
import { sliceBackingBuffer } from "./transferable.ts";
|
|
8
|
+
import createWasmModule, { type MainModule } from "./wasm/core.js";
|
|
28
9
|
|
|
29
10
|
export type InitMessage = {
|
|
30
11
|
type: "init";
|
|
31
|
-
aBytes: Uint8Array;
|
|
32
|
-
bBytes: Uint8Array;
|
|
33
|
-
maskBytes: Uint8Array | null;
|
|
34
|
-
dpi: number;
|
|
35
|
-
alpha: boolean;
|
|
36
12
|
pallet: Pallet;
|
|
37
13
|
align: AlignStrategy;
|
|
38
14
|
};
|
|
@@ -40,6 +16,11 @@ export type InitMessage = {
|
|
|
40
16
|
export type PageMessage = {
|
|
41
17
|
type: "page";
|
|
42
18
|
index: number;
|
|
19
|
+
// PNG bytes rendered on the main thread, or null when the source PDF has no
|
|
20
|
+
// such page (the diff then treats it as an empty/transparent page).
|
|
21
|
+
a: ArrayBuffer | null;
|
|
22
|
+
b: ArrayBuffer | null;
|
|
23
|
+
mask: ArrayBuffer | null;
|
|
43
24
|
};
|
|
44
25
|
|
|
45
26
|
export type LoadedMessage = {
|
|
@@ -56,9 +37,10 @@ export type PageResultMessage = {
|
|
|
56
37
|
a: { width: number; height: number; data: ArrayBuffer };
|
|
57
38
|
b: { width: number; height: number; data: ArrayBuffer };
|
|
58
39
|
diff: { width: number; height: number; data: ArrayBuffer };
|
|
59
|
-
addition:
|
|
60
|
-
deletion:
|
|
61
|
-
modification:
|
|
40
|
+
addition: ArrayBuffer;
|
|
41
|
+
deletion: ArrayBuffer;
|
|
42
|
+
modification: ArrayBuffer;
|
|
43
|
+
perf?: Counters | undefined;
|
|
62
44
|
};
|
|
63
45
|
|
|
64
46
|
export type ErrorMessage = {
|
|
@@ -66,74 +48,102 @@ export type ErrorMessage = {
|
|
|
66
48
|
message: string;
|
|
67
49
|
};
|
|
68
50
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
51
|
+
type WasmProcessResult = {
|
|
52
|
+
overlay: Uint8Array<ArrayBuffer>;
|
|
53
|
+
addition: Int32Array<ArrayBuffer>;
|
|
54
|
+
deletion: Int32Array<ArrayBuffer>;
|
|
55
|
+
modification: Int32Array<ArrayBuffer>;
|
|
56
|
+
};
|
|
57
|
+
|
|
72
58
|
let opts: {
|
|
73
|
-
dpi: number;
|
|
74
|
-
alpha: boolean;
|
|
75
59
|
pallet: Pallet;
|
|
76
60
|
align: AlignStrategy;
|
|
77
61
|
};
|
|
78
62
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
src instanceof Uint8Array || src instanceof Uint8ClampedArray
|
|
84
|
-
? src
|
|
85
|
-
: Uint8Array.from(src as ArrayLike<number>);
|
|
86
|
-
const out = new ArrayBuffer(view.byteLength);
|
|
87
|
-
new Uint8Array(out).set(view);
|
|
88
|
-
return out;
|
|
63
|
+
let wasm: MainModule | null = null;
|
|
64
|
+
async function getWasm(): Promise<MainModule> {
|
|
65
|
+
if (!wasm) wasm = await createWasmModule();
|
|
66
|
+
return wasm;
|
|
89
67
|
}
|
|
90
68
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
])) as [JimpInstance, JimpInstance, JimpInstance];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
69
|
+
function packColor([r, g, b, a]: RGBAColor): number {
|
|
70
|
+
return ((r << 24) | (g << 16) | (b << 8) | a) >>> 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function processPage(msg: PageMessage): Promise<PageResultMessage> {
|
|
74
|
+
const index = msg.index;
|
|
75
|
+
const sLoad = perf.span("worker.decodeAll_ms");
|
|
76
|
+
const [pageA, pageB, pageMaskOrNull] = (await Promise.all([
|
|
77
|
+
msg.a !== null ? decodePng(msg.a) : createEmptyImage(1, 1),
|
|
78
|
+
msg.b !== null ? decodePng(msg.b) : createEmptyImage(1, 1),
|
|
79
|
+
msg.mask !== null ? decodePng(msg.mask) : Promise.resolve(null),
|
|
80
|
+
])) as [JimpInstance, JimpInstance, JimpInstance | null];
|
|
81
|
+
sLoad.stop();
|
|
82
|
+
|
|
83
|
+
const sAlign = perf.span("worker.alignSize_ms");
|
|
84
|
+
let aAligned: JimpInstance;
|
|
85
|
+
let bAligned: JimpInstance;
|
|
86
|
+
let maskAligned: JimpInstance | null;
|
|
87
|
+
if (pageMaskOrNull !== null) {
|
|
88
|
+
[aAligned, bAligned, maskAligned] = alignSize(
|
|
89
|
+
[pageA, pageB, pageMaskOrNull],
|
|
90
|
+
opts.align,
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
[aAligned, bAligned] = alignSize([pageA, pageB], opts.align);
|
|
94
|
+
maskAligned = null;
|
|
95
|
+
}
|
|
96
|
+
sAlign.stop();
|
|
97
|
+
|
|
98
|
+
const width = aAligned.width;
|
|
99
|
+
const height = aAligned.height;
|
|
100
|
+
const aData = aAligned.bitmap.data;
|
|
101
|
+
const bData = bAligned.bitmap.data;
|
|
102
|
+
const maskData = maskAligned !== null ? maskAligned.bitmap.data : null;
|
|
103
|
+
|
|
104
|
+
const sProcess = perf.span("worker.processPage_ms");
|
|
105
|
+
const wasmModule = await getWasm();
|
|
106
|
+
const result = wasmModule.processPage(
|
|
107
|
+
aData,
|
|
108
|
+
bData,
|
|
109
|
+
maskData,
|
|
110
|
+
width,
|
|
111
|
+
height,
|
|
112
|
+
packColor(opts.pallet.addition),
|
|
113
|
+
packColor(opts.pallet.deletion),
|
|
114
|
+
packColor(opts.pallet.modification),
|
|
115
|
+
) as WasmProcessResult | number;
|
|
116
|
+
if (typeof result === "number") {
|
|
117
|
+
throw new Error(`wasm processPage failed: ${result}`);
|
|
118
|
+
}
|
|
119
|
+
sProcess.stop();
|
|
120
|
+
|
|
121
|
+
const sXfer = perf.span("worker.toTransferable_ms");
|
|
122
|
+
const aBuf = sliceBackingBuffer(aData);
|
|
123
|
+
const bBuf = sliceBackingBuffer(bData);
|
|
124
|
+
const dBuf = sliceBackingBuffer(result.overlay);
|
|
125
|
+
const addBuf = sliceBackingBuffer(result.addition);
|
|
126
|
+
const delBuf = sliceBackingBuffer(result.deletion);
|
|
127
|
+
const modBuf = sliceBackingBuffer(result.modification);
|
|
128
|
+
sXfer.stop();
|
|
129
|
+
perf.incr("worker.pages");
|
|
130
|
+
|
|
131
|
+
let pagePerf: Counters | undefined;
|
|
132
|
+
if (perf.enabled) {
|
|
133
|
+
pagePerf = perf.dump();
|
|
134
|
+
perf.reset();
|
|
135
|
+
}
|
|
115
136
|
|
|
116
137
|
return {
|
|
117
138
|
type: "pageResult",
|
|
118
139
|
index,
|
|
119
|
-
a: {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
height: pageB.height,
|
|
127
|
-
data: toTransferable(pageB.bitmap.data),
|
|
128
|
-
},
|
|
129
|
-
diff: {
|
|
130
|
-
width: diff.width,
|
|
131
|
-
height: diff.height,
|
|
132
|
-
data: toTransferable(diff.bitmap.data),
|
|
133
|
-
},
|
|
134
|
-
addition,
|
|
135
|
-
deletion,
|
|
136
|
-
modification,
|
|
140
|
+
a: { width, height, data: aBuf },
|
|
141
|
+
b: { width, height, data: bBuf },
|
|
142
|
+
diff: { width, height, data: dBuf },
|
|
143
|
+
addition: addBuf,
|
|
144
|
+
deletion: delBuf,
|
|
145
|
+
modification: modBuf,
|
|
146
|
+
perf: pagePerf,
|
|
137
147
|
};
|
|
138
148
|
}
|
|
139
149
|
|
|
@@ -143,26 +153,22 @@ self.addEventListener(
|
|
|
143
153
|
try {
|
|
144
154
|
const msg = e.data;
|
|
145
155
|
if (msg.type === "init") {
|
|
146
|
-
pdfA = mupdf.PDFDocument.openDocument(msg.aBytes, "application/pdf");
|
|
147
|
-
pdfB = mupdf.PDFDocument.openDocument(msg.bBytes, "application/pdf");
|
|
148
|
-
pdfMask = msg.maskBytes
|
|
149
|
-
? mupdf.PDFDocument.openDocument(msg.maskBytes, "application/pdf")
|
|
150
|
-
: new mupdf.PDFDocument();
|
|
151
156
|
opts = {
|
|
152
|
-
dpi: msg.dpi,
|
|
153
|
-
alpha: msg.alpha,
|
|
154
157
|
pallet: msg.pallet,
|
|
155
158
|
align: msg.align,
|
|
156
159
|
};
|
|
157
|
-
|
|
160
|
+
await getWasm();
|
|
158
161
|
const ready: ReadyMessage = { type: "ready" };
|
|
159
162
|
self.postMessage(ready);
|
|
160
163
|
} else if (msg.type === "page") {
|
|
161
|
-
const result = await processPage(msg
|
|
164
|
+
const result = await processPage(msg);
|
|
162
165
|
self.postMessage(result, [
|
|
163
166
|
result.a.data,
|
|
164
167
|
result.b.data,
|
|
165
168
|
result.diff.data,
|
|
169
|
+
result.addition,
|
|
170
|
+
result.deletion,
|
|
171
|
+
result.modification,
|
|
166
172
|
]);
|
|
167
173
|
}
|
|
168
174
|
} catch (err) {
|