compress-pdf-lib 1.0.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/LICENSE +21 -0
- package/README.md +202 -0
- package/package.json +51 -0
- package/src/compress.js +537 -0
- package/src/index.js +1 -0
- package/src/pdf-compressor.worker.js +132 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# compress-pdf-lib
|
|
2
|
+
|
|
3
|
+
Client-side PDF compression. Renders each page with `pdf.js`, encodes it to
|
|
4
|
+
JPEG with mozjpeg (WASM, via `@jsquash/jpeg`) across a parallel `Worker`
|
|
5
|
+
pool, and rebuilds the PDF with `pdf-lib`. Nothing leaves the browser, and
|
|
6
|
+
there's no bundled UI — you call one function and get a compressed file back.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
This package ships as **raw ESM source**, not a pre-bundled dist. It relies on
|
|
11
|
+
Vite-specific import syntax (`?url` asset imports, `new URL(..., import.meta.url)`
|
|
12
|
+
worker resolution), so it only works inside a **Vite-powered build**:
|
|
13
|
+
|
|
14
|
+
- ✅ React + Vite (`npm create vite@latest`)
|
|
15
|
+
- ✅ Astro (Astro's dev server and build are Vite under the hood)
|
|
16
|
+
- ✅ Any other Vite app (SvelteKit, Vue + Vite, plain Vite, etc.)
|
|
17
|
+
- ❌ Webpack / CRA / Next.js's default Webpack build (untested, likely needs
|
|
18
|
+
worker-loader / asset-url tweaks)
|
|
19
|
+
|
|
20
|
+
It also only runs in the browser — it uses `Worker`, `OffscreenCanvas`,
|
|
21
|
+
`createImageBitmap`, and `navigator.*`, so call it from client-side code
|
|
22
|
+
(a React event handler, a browser `<script>` in Astro, etc.), never during
|
|
23
|
+
SSR.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install compress-pdf-lib
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or, straight from GitHub without publishing to npm:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm install github:dgbkn/compress-pdf-lib
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
import { compressPDF } from "compress-pdf-lib";
|
|
41
|
+
|
|
42
|
+
const { file, stats } = await compressPDF(pdfFile, {
|
|
43
|
+
quality: 70, // JPEG quality, 0-100
|
|
44
|
+
resolution: 1600, // max px on a page's longest side
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
console.log(stats);
|
|
48
|
+
// {
|
|
49
|
+
// pages, originalBytes, compressedBytes, savedBytes,
|
|
50
|
+
// reduction, // percent
|
|
51
|
+
// elapsed, // ms
|
|
52
|
+
// workersUsed,
|
|
53
|
+
// perPage: [{ pageNumber, renderedWidth, renderedHeight, jpegBytes }, ...]
|
|
54
|
+
// }
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`compressPDF(input, options)` accepts a `File`, `Blob`, `ArrayBuffer`, or
|
|
58
|
+
typed array, and returns `{ file, stats }` where `file` is a `File` (or
|
|
59
|
+
`Blob` if `File` isn't available) you can upload, download, or inspect.
|
|
60
|
+
|
|
61
|
+
### React + Vite
|
|
62
|
+
|
|
63
|
+
```jsx
|
|
64
|
+
import { compressPDF } from "compress-pdf-lib";
|
|
65
|
+
|
|
66
|
+
function Uploader() {
|
|
67
|
+
async function handleChange(event) {
|
|
68
|
+
const original = event.target.files[0];
|
|
69
|
+
const { file, stats } = await compressPDF(original, { quality: 65 });
|
|
70
|
+
|
|
71
|
+
console.log(`${stats.reduction.toFixed(1)}% smaller`);
|
|
72
|
+
// upload `file`, or trigger a download, etc.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return <input type="file" accept="application/pdf" onChange={handleChange} />;
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Astro
|
|
80
|
+
|
|
81
|
+
Astro components render on the server by default, so run this inside a
|
|
82
|
+
client-side script or an interactive island (`client:load` etc.):
|
|
83
|
+
|
|
84
|
+
```astro
|
|
85
|
+
---
|
|
86
|
+
// src/pages/index.astro
|
|
87
|
+
---
|
|
88
|
+
<input type="file" id="pdf-input" accept="application/pdf" />
|
|
89
|
+
<pre id="stats"></pre>
|
|
90
|
+
|
|
91
|
+
<script>
|
|
92
|
+
import { compressPDF } from "compress-pdf-lib";
|
|
93
|
+
|
|
94
|
+
const input = document.getElementById("pdf-input");
|
|
95
|
+
const statsEl = document.getElementById("stats");
|
|
96
|
+
|
|
97
|
+
input.addEventListener("change", async (event) => {
|
|
98
|
+
const file = event.target.files?.[0];
|
|
99
|
+
if (!file) return;
|
|
100
|
+
|
|
101
|
+
const { file: compressed, stats } = await compressPDF(file, { quality: 70 });
|
|
102
|
+
statsEl.textContent = JSON.stringify(stats, null, 2);
|
|
103
|
+
});
|
|
104
|
+
</script>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
(A React/Vue/Svelte island with `client:load` works the same way — just call
|
|
108
|
+
`compressPDF` inside a browser event handler.)
|
|
109
|
+
|
|
110
|
+
### Intercepting a fetch upload
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
import { compressPDF } from "compress-pdf-lib";
|
|
114
|
+
|
|
115
|
+
const originalFetch = window.fetch;
|
|
116
|
+
|
|
117
|
+
window.fetch = async function (...args) {
|
|
118
|
+
const [resource, config] = args;
|
|
119
|
+
|
|
120
|
+
if (config?.body instanceof FormData) {
|
|
121
|
+
const entries = [...config.body.entries()];
|
|
122
|
+
const hasPdf = entries.some(
|
|
123
|
+
([, v]) => v instanceof File && v.type === "application/pdf"
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
if (hasPdf) {
|
|
127
|
+
const newFormData = new FormData();
|
|
128
|
+
|
|
129
|
+
for (const [key, value] of entries) {
|
|
130
|
+
if (value instanceof File && value.type === "application/pdf") {
|
|
131
|
+
const { file, stats } = await compressPDF(value, { quality: 70 });
|
|
132
|
+
console.log(`Compressed ${value.name}:`, stats);
|
|
133
|
+
newFormData.append(key, file, value.name);
|
|
134
|
+
} else {
|
|
135
|
+
newFormData.append(key, value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
config.body = newFormData;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return originalFetch.call(this, resource, config);
|
|
144
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## API
|
|
148
|
+
|
|
149
|
+
### `compressPDF(input, options?)`
|
|
150
|
+
|
|
151
|
+
| Option | Type | Default | Description |
|
|
152
|
+
|--------------|----------|---------|-----------------------------------------------|
|
|
153
|
+
| `quality` | number | `65` | JPEG quality, 0–100 |
|
|
154
|
+
| `resolution` | number | `1600` | Max px on a page's longest rendered side |
|
|
155
|
+
| `workers` | number | auto | Override the auto-detected worker count |
|
|
156
|
+
| `onProgress` | function | — | `(update) => void`, called with `{ stage, progress, ... }` |
|
|
157
|
+
|
|
158
|
+
Returns `Promise<{ file, stats }>`.
|
|
159
|
+
|
|
160
|
+
### `getClientPower()`
|
|
161
|
+
|
|
162
|
+
Returns `{ cores, memory, workers }` — the auto-detected hardware profile and
|
|
163
|
+
the worker count `compressPDF` would use by default.
|
|
164
|
+
|
|
165
|
+
### `CompressionPool`
|
|
166
|
+
|
|
167
|
+
The underlying worker-pool class, exported in case you want to manage the
|
|
168
|
+
pool's lifecycle yourself across multiple compressions instead of letting
|
|
169
|
+
`compressPDF` create/destroy one per call.
|
|
170
|
+
|
|
171
|
+
## Publishing this package
|
|
172
|
+
|
|
173
|
+
### Option A — npm registry
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cd compress-pdf-lib
|
|
177
|
+
npm login
|
|
178
|
+
npm publish
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
(`publishConfig.access: public` is already set in `package.json`, needed if
|
|
182
|
+
you ever scope the package name like `@you/compress-pdf-lib`.)
|
|
183
|
+
|
|
184
|
+
Bump `version` in `package.json` before each subsequent `npm publish`
|
|
185
|
+
(`npm version patch|minor|major` does this for you and tags git).
|
|
186
|
+
|
|
187
|
+
### Option B — GitHub only (no npm publish)
|
|
188
|
+
|
|
189
|
+
1. Push this folder as a repo, e.g. `github.com/YOUR_USERNAME/compress-pdf-lib`.
|
|
190
|
+
2. Consumers install with:
|
|
191
|
+
```bash
|
|
192
|
+
npm install github:YOUR_USERNAME/compress-pdf-lib
|
|
193
|
+
# or a specific tag/branch:
|
|
194
|
+
npm install github:YOUR_USERNAME/compress-pdf-lib#v1.0.0
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Either way, update the `repository`/`homepage`/`bugs` URLs in `package.json`
|
|
198
|
+
to your actual GitHub username first.
|
|
199
|
+
|
|
200
|
+
## License
|
|
201
|
+
|
|
202
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "compress-pdf-lib",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Client-side PDF compression: pdf.js render + mozjpeg (WASM) encoding via a parallel worker pool + pdf-lib rebuild. No server, no UI. Ships as raw ESM source for Vite-based apps (React + Vite, Astro).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"module": "src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./worker": "./src/pdf-compressor.worker.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"src/pdf-compressor.worker.js"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pdf",
|
|
23
|
+
"compression",
|
|
24
|
+
"compress-pdf",
|
|
25
|
+
"jpeg",
|
|
26
|
+
"mozjpeg",
|
|
27
|
+
"pdfjs",
|
|
28
|
+
"pdf-lib",
|
|
29
|
+
"vite",
|
|
30
|
+
"astro",
|
|
31
|
+
"react"
|
|
32
|
+
],
|
|
33
|
+
"author": "Dev Goyal",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"pdfjs-dist": "^6.3.289",
|
|
37
|
+
"pdf-lib": "^1.17.1",
|
|
38
|
+
"@jsquash/jpeg": "^1.4.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/dgbkn/compress-pdf-lib.git"
|
|
46
|
+
},
|
|
47
|
+
"homepage": "https://github.com/dgbkn/compress-pdf-lib#readme",
|
|
48
|
+
"bugs": {
|
|
49
|
+
"url": "https://github.com/dgbkn/compress-pdf-lib/issues"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/compress.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compress.js
|
|
3
|
+
*
|
|
4
|
+
* Full, UI-less PDF compression library: worker-pool + parallel page
|
|
5
|
+
* rendering + mozjpeg (WASM) encoding + pdf-lib rebuild.
|
|
6
|
+
*
|
|
7
|
+
* Pairs with pdf-compressor.worker.js (same folder).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { compressPDF } from 'compress-pdf-lib';
|
|
11
|
+
*
|
|
12
|
+
* const { file, stats } = await compressPDF(pdfFile, {
|
|
13
|
+
* quality: 70,
|
|
14
|
+
* resolution: 1600,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* console.log(stats);
|
|
18
|
+
* // {
|
|
19
|
+
* // pages, originalBytes, compressedBytes, savedBytes,
|
|
20
|
+
* // reduction, elapsed, workersUsed, perPage: [...]
|
|
21
|
+
* // }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as pdfjsLib from "pdfjs-dist";
|
|
25
|
+
import { PDFDocument } from "pdf-lib";
|
|
26
|
+
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
|
27
|
+
|
|
28
|
+
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
29
|
+
|
|
30
|
+
/* ============================================================
|
|
31
|
+
CLIENT POWER DETECTION
|
|
32
|
+
============================================================ */
|
|
33
|
+
|
|
34
|
+
export function getClientPower() {
|
|
35
|
+
const cores = navigator.hardwareConcurrency || 4;
|
|
36
|
+
const memory = navigator.deviceMemory || 4;
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
* Conservative worker count.
|
|
40
|
+
* We don't want one worker per core because
|
|
41
|
+
* WASM JPEG encoding is CPU intensive.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
let workers;
|
|
45
|
+
|
|
46
|
+
if (cores <= 2) {
|
|
47
|
+
workers = 1;
|
|
48
|
+
} else if (cores <= 4) {
|
|
49
|
+
workers = 2;
|
|
50
|
+
} else if (cores <= 8) {
|
|
51
|
+
workers = 3;
|
|
52
|
+
} else if (cores <= 12) {
|
|
53
|
+
workers = 4;
|
|
54
|
+
} else {
|
|
55
|
+
workers = 6;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* Memory protection */
|
|
59
|
+
|
|
60
|
+
if (memory <= 2) {
|
|
61
|
+
workers = Math.min(workers, 1);
|
|
62
|
+
} else if (memory <= 4) {
|
|
63
|
+
workers = Math.min(workers, 2);
|
|
64
|
+
} else if (memory <= 8) {
|
|
65
|
+
workers = Math.min(workers, 4);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Never create ridiculous amounts of workers */
|
|
69
|
+
|
|
70
|
+
workers = Math.max(1, Math.min(workers, Math.max(1, cores - 1), 6));
|
|
71
|
+
|
|
72
|
+
return { cores, memory, workers };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ============================================================
|
|
76
|
+
WORKER POOL
|
|
77
|
+
============================================================ */
|
|
78
|
+
|
|
79
|
+
export class CompressionPool {
|
|
80
|
+
constructor() {
|
|
81
|
+
this.workers = [];
|
|
82
|
+
this.idle = [];
|
|
83
|
+
this.jobs = new Map();
|
|
84
|
+
this.queue = [];
|
|
85
|
+
this.nextId = 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
init(count) {
|
|
89
|
+
this.destroy();
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < count; i++) {
|
|
92
|
+
const worker = new Worker(
|
|
93
|
+
new URL("./pdf-compressor.worker.js", import.meta.url),
|
|
94
|
+
{ type: "module" }
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const slot = { worker, busy: false };
|
|
98
|
+
|
|
99
|
+
worker.onmessage = (event) => {
|
|
100
|
+
const data = event.data;
|
|
101
|
+
|
|
102
|
+
if (data.type === "image-complete") {
|
|
103
|
+
const jobId = data.jobId;
|
|
104
|
+
const job = this.jobs.get(jobId);
|
|
105
|
+
|
|
106
|
+
if (!job) return;
|
|
107
|
+
|
|
108
|
+
this.jobs.delete(jobId);
|
|
109
|
+
slot.busy = false;
|
|
110
|
+
this.idle.push(slot);
|
|
111
|
+
|
|
112
|
+
job.resolve(data);
|
|
113
|
+
this.pump();
|
|
114
|
+
} else if (data.type === "error") {
|
|
115
|
+
const jobId = data.jobId;
|
|
116
|
+
const job = this.jobs.get(jobId);
|
|
117
|
+
|
|
118
|
+
if (!job) return;
|
|
119
|
+
|
|
120
|
+
this.jobs.delete(jobId);
|
|
121
|
+
slot.busy = false;
|
|
122
|
+
this.idle.push(slot);
|
|
123
|
+
|
|
124
|
+
job.reject(new Error(data.message || "Worker compression failed"));
|
|
125
|
+
this.pump();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
worker.onerror = (error) => {
|
|
130
|
+
console.error("Worker error:", error);
|
|
131
|
+
slot.busy = false;
|
|
132
|
+
|
|
133
|
+
for (const [id, job] of this.jobs) {
|
|
134
|
+
if (job.slot === slot) {
|
|
135
|
+
this.jobs.delete(id);
|
|
136
|
+
job.reject(new Error("Compression worker crashed"));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.pump();
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
this.workers.push(slot);
|
|
144
|
+
this.idle.push(slot);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
run(payload, transferables = []) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const id = this.nextId++;
|
|
151
|
+
|
|
152
|
+
this.queue.push({ id, payload, transferables, resolve, reject });
|
|
153
|
+
|
|
154
|
+
this.pump();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pump() {
|
|
159
|
+
while (this.queue.length > 0 && this.idle.length > 0) {
|
|
160
|
+
const slot = this.idle.shift();
|
|
161
|
+
if (!slot) return;
|
|
162
|
+
|
|
163
|
+
const job = this.queue.shift();
|
|
164
|
+
slot.busy = true;
|
|
165
|
+
job.slot = slot;
|
|
166
|
+
|
|
167
|
+
this.jobs.set(job.id, job);
|
|
168
|
+
|
|
169
|
+
slot.worker.postMessage({ ...job.payload, jobId: job.id }, job.transferables);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async drain() {
|
|
174
|
+
while (this.queue.length > 0 || this.jobs.size > 0) {
|
|
175
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
destroy() {
|
|
180
|
+
for (const slot of this.workers) {
|
|
181
|
+
try {
|
|
182
|
+
slot.worker.terminate();
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.workers = [];
|
|
187
|
+
this.idle = [];
|
|
188
|
+
this.jobs.clear();
|
|
189
|
+
this.queue = [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/* ============================================================
|
|
194
|
+
SAFE PDF PAGE CLEANUP
|
|
195
|
+
============================================================ */
|
|
196
|
+
|
|
197
|
+
function safePageCleanup(page) {
|
|
198
|
+
try {
|
|
199
|
+
if (page && typeof page.cleanup === "function") {
|
|
200
|
+
page.cleanup();
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.warn("Page cleanup skipped:", error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ============================================================
|
|
208
|
+
INPUT NORMALIZATION
|
|
209
|
+
============================================================ */
|
|
210
|
+
|
|
211
|
+
async function normalizeInput(input) {
|
|
212
|
+
let arrayBuffer;
|
|
213
|
+
let fileName = "document.pdf";
|
|
214
|
+
let fileType = "application/pdf";
|
|
215
|
+
|
|
216
|
+
if (typeof File !== "undefined" && input instanceof File) {
|
|
217
|
+
fileName = input.name || fileName;
|
|
218
|
+
fileType = input.type || fileType;
|
|
219
|
+
arrayBuffer = await input.arrayBuffer();
|
|
220
|
+
} else if (input instanceof Blob) {
|
|
221
|
+
fileType = input.type || fileType;
|
|
222
|
+
arrayBuffer = await input.arrayBuffer();
|
|
223
|
+
} else if (input instanceof ArrayBuffer) {
|
|
224
|
+
arrayBuffer = input;
|
|
225
|
+
} else if (ArrayBuffer.isView(input)) {
|
|
226
|
+
arrayBuffer = input.buffer.slice(
|
|
227
|
+
input.byteOffset,
|
|
228
|
+
input.byteOffset + input.byteLength
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
throw new Error("compressPDF: unsupported input type");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { arrayBuffer, fileName, fileType, originalBytes: arrayBuffer.byteLength };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ============================================================
|
|
238
|
+
RENDER + COMPRESS ONE PAGE
|
|
239
|
+
============================================================ */
|
|
240
|
+
|
|
241
|
+
async function processPage(pdf, pool, pageNumber, totalPages, { quality, resolution }) {
|
|
242
|
+
let page = null;
|
|
243
|
+
let canvas = null;
|
|
244
|
+
let bitmap = null;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
page = await pdf.getPage(pageNumber);
|
|
248
|
+
|
|
249
|
+
const baseViewport = page.getViewport({ scale: 1 });
|
|
250
|
+
const pdfWidth = baseViewport.width;
|
|
251
|
+
const pdfHeight = baseViewport.height;
|
|
252
|
+
|
|
253
|
+
const largestDimension = Math.max(pdfWidth, pdfHeight);
|
|
254
|
+
|
|
255
|
+
let scale = resolution / largestDimension;
|
|
256
|
+
scale = Math.max(scale, 0.25);
|
|
257
|
+
scale = Math.min(scale, 3);
|
|
258
|
+
|
|
259
|
+
const viewport = page.getViewport({ scale });
|
|
260
|
+
const width = Math.ceil(viewport.width);
|
|
261
|
+
const height = Math.ceil(viewport.height);
|
|
262
|
+
|
|
263
|
+
canvas = document.createElement("canvas");
|
|
264
|
+
canvas.width = width;
|
|
265
|
+
canvas.height = height;
|
|
266
|
+
|
|
267
|
+
const ctx = canvas.getContext("2d", {
|
|
268
|
+
alpha: false,
|
|
269
|
+
desynchronized: true,
|
|
270
|
+
willReadFrequently: false,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!ctx) {
|
|
274
|
+
throw new Error(`Canvas unavailable for page ${pageNumber}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
ctx.fillStyle = "#ffffff";
|
|
278
|
+
ctx.fillRect(0, 0, width, height);
|
|
279
|
+
|
|
280
|
+
await page.render({
|
|
281
|
+
canvasContext: ctx,
|
|
282
|
+
viewport,
|
|
283
|
+
background: "white",
|
|
284
|
+
}).promise;
|
|
285
|
+
|
|
286
|
+
bitmap = await createImageBitmap(canvas);
|
|
287
|
+
|
|
288
|
+
canvas.width = 1;
|
|
289
|
+
canvas.height = 1;
|
|
290
|
+
|
|
291
|
+
const compressed = await pool.run(
|
|
292
|
+
{
|
|
293
|
+
type: "compress-image",
|
|
294
|
+
bitmap,
|
|
295
|
+
pageNumber,
|
|
296
|
+
totalPages,
|
|
297
|
+
quality,
|
|
298
|
+
pdfWidth,
|
|
299
|
+
pdfHeight,
|
|
300
|
+
},
|
|
301
|
+
[bitmap]
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
bitmap = null;
|
|
305
|
+
|
|
306
|
+
return compressed;
|
|
307
|
+
} finally {
|
|
308
|
+
if (canvas) {
|
|
309
|
+
try {
|
|
310
|
+
canvas.width = 1;
|
|
311
|
+
canvas.height = 1;
|
|
312
|
+
} catch {}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (bitmap) {
|
|
316
|
+
try {
|
|
317
|
+
bitmap.close();
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
safePageCleanup(page);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ============================================================
|
|
326
|
+
PARALLEL PAGE PIPELINE
|
|
327
|
+
============================================================ */
|
|
328
|
+
|
|
329
|
+
async function processPages(pdf, pool, totalPages, { quality, resolution, workers, onProgress }) {
|
|
330
|
+
const results = new Array(totalPages);
|
|
331
|
+
|
|
332
|
+
const renderConcurrency = Math.max(1, Math.min(workers, 4));
|
|
333
|
+
|
|
334
|
+
let nextPage = 1;
|
|
335
|
+
let completed = 0;
|
|
336
|
+
|
|
337
|
+
async function runner() {
|
|
338
|
+
while (true) {
|
|
339
|
+
const pageNumber = nextPage++;
|
|
340
|
+
|
|
341
|
+
if (pageNumber > totalPages) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const result = await processPage(pdf, pool, pageNumber, totalPages, {
|
|
346
|
+
quality,
|
|
347
|
+
resolution,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
results[pageNumber - 1] = result;
|
|
351
|
+
completed++;
|
|
352
|
+
|
|
353
|
+
if (typeof onProgress === "function") {
|
|
354
|
+
onProgress({
|
|
355
|
+
stage: "compressing",
|
|
356
|
+
pageNumber,
|
|
357
|
+
totalPages,
|
|
358
|
+
completed,
|
|
359
|
+
progress: Math.round((completed / totalPages) * 90),
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const runners = Math.min(renderConcurrency, totalPages);
|
|
366
|
+
|
|
367
|
+
await Promise.all(Array.from({ length: runners }, () => runner()));
|
|
368
|
+
|
|
369
|
+
return results;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/* ============================================================
|
|
373
|
+
BUILD FINAL PDF
|
|
374
|
+
============================================================ */
|
|
375
|
+
|
|
376
|
+
async function buildPdf(compressedPages, { originalBytes, startedAt, workersUsed, onProgress }) {
|
|
377
|
+
if (typeof onProgress === "function") {
|
|
378
|
+
onProgress({ stage: "building", progress: 92 });
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const outputPdf = await PDFDocument.create();
|
|
382
|
+
|
|
383
|
+
const perPage = [];
|
|
384
|
+
|
|
385
|
+
for (let i = 0; i < compressedPages.length; i++) {
|
|
386
|
+
const item = compressedPages[i];
|
|
387
|
+
|
|
388
|
+
if (!item) {
|
|
389
|
+
throw new Error(`Missing compressed page ${i + 1}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let jpegBytes;
|
|
393
|
+
|
|
394
|
+
if (item.jpeg instanceof ArrayBuffer) {
|
|
395
|
+
jpegBytes = new Uint8Array(item.jpeg);
|
|
396
|
+
} else if (item.jpeg instanceof Uint8Array) {
|
|
397
|
+
jpegBytes = item.jpeg;
|
|
398
|
+
} else if (ArrayBuffer.isView(item.jpeg)) {
|
|
399
|
+
jpegBytes = new Uint8Array(item.jpeg.buffer, item.jpeg.byteOffset, item.jpeg.byteLength);
|
|
400
|
+
} else {
|
|
401
|
+
throw new Error(`Invalid JPEG for page ${i + 1}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const image = await outputPdf.embedJpg(jpegBytes);
|
|
405
|
+
|
|
406
|
+
const page = outputPdf.addPage([item.pdfWidth, item.pdfHeight]);
|
|
407
|
+
|
|
408
|
+
page.drawImage(image, {
|
|
409
|
+
x: 0,
|
|
410
|
+
y: 0,
|
|
411
|
+
width: item.pdfWidth,
|
|
412
|
+
height: item.pdfHeight,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
perPage.push({
|
|
416
|
+
pageNumber: i + 1,
|
|
417
|
+
renderedWidth: item.width,
|
|
418
|
+
renderedHeight: item.height,
|
|
419
|
+
jpegBytes: item.jpegBytes,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
item.jpeg = null;
|
|
423
|
+
|
|
424
|
+
if (typeof onProgress === "function") {
|
|
425
|
+
onProgress({
|
|
426
|
+
stage: "building",
|
|
427
|
+
progress: 92 + Math.round(((i + 1) / compressedPages.length) * 7),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (typeof onProgress === "function") {
|
|
433
|
+
onProgress({ stage: "finalizing", progress: 99 });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const pdfBytes = await outputPdf.save({
|
|
437
|
+
useObjectStreams: true,
|
|
438
|
+
addDefaultPage: false,
|
|
439
|
+
objectsPerTick: 50,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const blob = new Blob([pdfBytes], { type: "application/pdf" });
|
|
443
|
+
|
|
444
|
+
const compressedBytes = blob.size;
|
|
445
|
+
const savedBytes = Math.max(0, originalBytes - compressedBytes);
|
|
446
|
+
const reduction = originalBytes > 0 ? (savedBytes / originalBytes) * 100 : 0;
|
|
447
|
+
const elapsed = performance.now() - startedAt;
|
|
448
|
+
|
|
449
|
+
const stats = {
|
|
450
|
+
pages: compressedPages.length,
|
|
451
|
+
originalBytes,
|
|
452
|
+
compressedBytes,
|
|
453
|
+
savedBytes,
|
|
454
|
+
reduction,
|
|
455
|
+
elapsed,
|
|
456
|
+
workersUsed,
|
|
457
|
+
perPage,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
if (typeof onProgress === "function") {
|
|
461
|
+
onProgress({ stage: "complete", progress: 100 });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { blob, stats };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* ============================================================
|
|
468
|
+
PUBLIC API
|
|
469
|
+
============================================================ */
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Compress a PDF using the same render -> mozjpeg WASM -> pdf-lib rebuild
|
|
473
|
+
* pipeline as the original app, run across a parallel worker pool.
|
|
474
|
+
*
|
|
475
|
+
* @param {File|Blob|ArrayBuffer|ArrayBufferView} input
|
|
476
|
+
* @param {Object} [options]
|
|
477
|
+
* @param {number} [options.quality=65] - JPEG quality, 0-100
|
|
478
|
+
* @param {number} [options.resolution=1600] - max px on the longest page side
|
|
479
|
+
* @param {number} [options.workers] - override auto-detected worker count
|
|
480
|
+
* @param {(update: object) => void} [options.onProgress] - optional progress callback
|
|
481
|
+
* @returns {Promise<{file: File|Blob, stats: object}>}
|
|
482
|
+
*/
|
|
483
|
+
export async function compressPDF(input, options = {}) {
|
|
484
|
+
const { quality = 65, resolution = 1600, workers: workerOverride, onProgress } = options;
|
|
485
|
+
|
|
486
|
+
const startedAt = performance.now();
|
|
487
|
+
|
|
488
|
+
const { arrayBuffer, fileName, fileType, originalBytes } = await normalizeInput(input);
|
|
489
|
+
|
|
490
|
+
const power = getClientPower();
|
|
491
|
+
const workerCount = workerOverride || power.workers;
|
|
492
|
+
|
|
493
|
+
const pool = new CompressionPool();
|
|
494
|
+
pool.init(workerCount);
|
|
495
|
+
|
|
496
|
+
const loadingTask = pdfjsLib.getDocument({
|
|
497
|
+
data: new Uint8Array(arrayBuffer.slice(0)),
|
|
498
|
+
useSystemFonts: true,
|
|
499
|
+
isEvalSupported: true,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
const pdf = await loadingTask.promise;
|
|
504
|
+
const totalPages = pdf.numPages;
|
|
505
|
+
|
|
506
|
+
const compressedPages = await processPages(pdf, pool, totalPages, {
|
|
507
|
+
quality,
|
|
508
|
+
resolution,
|
|
509
|
+
workers: workerCount,
|
|
510
|
+
onProgress,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const { blob, stats } = await buildPdf(compressedPages, {
|
|
514
|
+
originalBytes,
|
|
515
|
+
startedAt,
|
|
516
|
+
workersUsed: workerCount,
|
|
517
|
+
onProgress,
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const file =
|
|
521
|
+
typeof File !== "undefined"
|
|
522
|
+
? new File([blob], fileName, { type: fileType })
|
|
523
|
+
: blob;
|
|
524
|
+
|
|
525
|
+
return { file, stats };
|
|
526
|
+
} finally {
|
|
527
|
+
try {
|
|
528
|
+
if (loadingTask && typeof loadingTask.destroy === "function") {
|
|
529
|
+
await loadingTask.destroy();
|
|
530
|
+
}
|
|
531
|
+
} catch (cleanupError) {
|
|
532
|
+
console.warn("compressPDF: loading task cleanup skipped:", cleanupError);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
pool.destroy();
|
|
536
|
+
}
|
|
537
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { compressPDF, getClientPower, CompressionPool } from "./compress.js";
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pdf-compressor.worker.js
|
|
3
|
+
*
|
|
4
|
+
* Same worker used by CompressionPool in compressPDFLIB.js.
|
|
5
|
+
* Takes a transferred ImageBitmap, draws it to an OffscreenCanvas,
|
|
6
|
+
* encodes it with mozjpeg (WASM) via @jsquash/jpeg, and transfers
|
|
7
|
+
* the resulting JPEG bytes back to the main thread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { encode as encodeJpeg } from "@jsquash/jpeg";
|
|
11
|
+
|
|
12
|
+
self.onmessage = async (event) => {
|
|
13
|
+
const data = event.data;
|
|
14
|
+
|
|
15
|
+
if (data?.type !== "compress-image") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
jobId,
|
|
21
|
+
bitmap,
|
|
22
|
+
pageNumber,
|
|
23
|
+
totalPages,
|
|
24
|
+
quality,
|
|
25
|
+
pdfWidth,
|
|
26
|
+
pdfHeight,
|
|
27
|
+
} = data;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (!bitmap) {
|
|
31
|
+
throw new Error("ImageBitmap missing");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const width = bitmap.width;
|
|
35
|
+
const height = bitmap.height;
|
|
36
|
+
|
|
37
|
+
/* ================================================
|
|
38
|
+
OFFSCREEN CANVAS
|
|
39
|
+
================================================ */
|
|
40
|
+
|
|
41
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
42
|
+
|
|
43
|
+
const ctx = canvas.getContext("2d", {
|
|
44
|
+
alpha: false,
|
|
45
|
+
desynchronized: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (!ctx) {
|
|
49
|
+
bitmap.close();
|
|
50
|
+
throw new Error("OffscreenCanvas unavailable");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ctx.fillStyle = "#ffffff";
|
|
54
|
+
ctx.fillRect(0, 0, width, height);
|
|
55
|
+
|
|
56
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
57
|
+
|
|
58
|
+
bitmap.close();
|
|
59
|
+
|
|
60
|
+
/* ================================================
|
|
61
|
+
GET PIXELS
|
|
62
|
+
================================================ */
|
|
63
|
+
|
|
64
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
65
|
+
|
|
66
|
+
/* ================================================
|
|
67
|
+
MOZJPEG WASM
|
|
68
|
+
================================================ */
|
|
69
|
+
|
|
70
|
+
const encoded = await encodeJpeg(imageData, {
|
|
71
|
+
quality: Number(quality),
|
|
72
|
+
progressive: true,
|
|
73
|
+
optimize_coding: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* ================================================
|
|
77
|
+
NORMALIZE ARRAYBUFFER
|
|
78
|
+
================================================ */
|
|
79
|
+
|
|
80
|
+
let jpegBuffer;
|
|
81
|
+
|
|
82
|
+
if (encoded instanceof ArrayBuffer) {
|
|
83
|
+
jpegBuffer = encoded;
|
|
84
|
+
} else if (ArrayBuffer.isView(encoded)) {
|
|
85
|
+
jpegBuffer = encoded.buffer.slice(
|
|
86
|
+
encoded.byteOffset,
|
|
87
|
+
encoded.byteOffset + encoded.byteLength
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
throw new Error("Invalid JPEG encoder output");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (jpegBuffer.byteLength === 0) {
|
|
94
|
+
throw new Error("Empty JPEG");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ================================================
|
|
98
|
+
TRANSFER
|
|
99
|
+
================================================ */
|
|
100
|
+
|
|
101
|
+
self.postMessage(
|
|
102
|
+
{
|
|
103
|
+
type: "image-complete",
|
|
104
|
+
jobId,
|
|
105
|
+
pageNumber,
|
|
106
|
+
totalPages,
|
|
107
|
+
jpeg: jpegBuffer,
|
|
108
|
+
jpegBytes: jpegBuffer.byteLength,
|
|
109
|
+
width,
|
|
110
|
+
height,
|
|
111
|
+
pdfWidth,
|
|
112
|
+
pdfHeight,
|
|
113
|
+
},
|
|
114
|
+
[jpegBuffer]
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
canvas.width = 1;
|
|
118
|
+
canvas.height = 1;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
try {
|
|
121
|
+
bitmap?.close();
|
|
122
|
+
} catch {}
|
|
123
|
+
|
|
124
|
+
self.postMessage({
|
|
125
|
+
type: "error",
|
|
126
|
+
jobId,
|
|
127
|
+
pageNumber,
|
|
128
|
+
totalPages,
|
|
129
|
+
message: error?.message || String(error),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
};
|