compress-pdf-lib 1.0.3 → 1.0.5
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/README.md +195 -124
- package/package.json +4 -5
- package/src/compress.js +178 -56
- package/src/pdf-compressor.worker.js +26 -43
package/README.md
CHANGED
|
@@ -1,26 +1,46 @@
|
|
|
1
1
|
# compress-pdf-lib
|
|
2
2
|
|
|
3
|
-
Client-side PDF compression
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Client-side PDF compression for **Vite-powered apps** (React + Vite, Astro, SvelteKit, plain Vite, etc.).
|
|
4
|
+
|
|
5
|
+
Extracts every embedded raster image from the PDF, recompresses it individually using the
|
|
6
|
+
browser's hardware-accelerated JPEG encoder (`OffscreenCanvas.convertToBlob` → libjpeg-turbo),
|
|
7
|
+
and writes the result back into the exact same PDF object slot with `pdf-lib`.
|
|
8
|
+
Text, fonts, and vector drawing are **never touched** — text-only PDFs are safe and won't bloat.
|
|
9
|
+
|
|
10
|
+
**Zero WASM · zero server · nothing leaves the browser · no bundled UI.**
|
|
11
|
+
|
|
12
|
+
---
|
|
7
13
|
|
|
8
14
|
## Requirements
|
|
9
15
|
|
|
10
16
|
This package ships as **raw ESM source**, not a pre-bundled dist. It relies on
|
|
11
|
-
Vite-specific import syntax (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
Vite-specific import syntax (`new URL(..., import.meta.url)` worker resolution),
|
|
18
|
+
so it only works inside a **Vite-powered build**:
|
|
19
|
+
|
|
20
|
+
| Environment | Works? |
|
|
21
|
+
|---|---|
|
|
22
|
+
| React + Vite (`npm create vite@latest`) | ✅ |
|
|
23
|
+
| Astro | ✅ |
|
|
24
|
+
| SvelteKit, Vue + Vite, plain Vite | ✅ |
|
|
25
|
+
| Webpack / CRA / Next.js default Webpack | ❌ (needs worker-loader tweaks) |
|
|
26
|
+
|
|
27
|
+
It also only runs **in the browser** — it uses `Worker`, `OffscreenCanvas`, `createImageBitmap`,
|
|
28
|
+
and `navigator.*`, so always call it from client-side code, never during SSR.
|
|
29
|
+
|
|
30
|
+
> **Vite config note:** If Vite complains that `pdf-lib`'s CJS sub-modules can't be resolved,
|
|
31
|
+
> add this alias to your `vite.config.js`:
|
|
32
|
+
> ```js
|
|
33
|
+
> resolve: {
|
|
34
|
+
> alias: {
|
|
35
|
+
> "pdf-lib": new URL(
|
|
36
|
+
> "./node_modules/pdf-lib/dist/pdf-lib.esm.js",
|
|
37
|
+
> import.meta.url
|
|
38
|
+
> ).pathname,
|
|
39
|
+
> },
|
|
40
|
+
> }
|
|
41
|
+
> ```
|
|
19
42
|
|
|
20
|
-
|
|
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.
|
|
43
|
+
---
|
|
24
44
|
|
|
25
45
|
## Install
|
|
26
46
|
|
|
@@ -28,35 +48,134 @@ SSR.
|
|
|
28
48
|
npm install compress-pdf-lib
|
|
29
49
|
```
|
|
30
50
|
|
|
31
|
-
Or
|
|
51
|
+
Or directly from GitHub (no npm publish needed):
|
|
32
52
|
|
|
33
53
|
```bash
|
|
34
54
|
npm install github:dgbkn/compress-pdf-lib
|
|
55
|
+
# pin to a tag:
|
|
56
|
+
npm install github:dgbkn/compress-pdf-lib#v1.0.4
|
|
35
57
|
```
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Quick Start
|
|
38
62
|
|
|
39
63
|
```js
|
|
40
64
|
import { compressPDF } from "compress-pdf-lib";
|
|
41
65
|
|
|
42
66
|
const { file, stats } = await compressPDF(pdfFile, {
|
|
43
|
-
quality:
|
|
44
|
-
|
|
67
|
+
quality: 80, // JPEG quality 0–100 (default 75)
|
|
68
|
+
scale: 1, // image resize factor (default 1 = keep native size)
|
|
45
69
|
});
|
|
46
70
|
|
|
47
|
-
console.log(stats);
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
console.log(`Saved ${stats.reduction.toFixed(1)}% (${stats.savedBytes} bytes)`);
|
|
72
|
+
// download or upload `file` — it's a File/Blob
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
### `compressPDF(input, options?)` — ⭐ recommended
|
|
80
|
+
|
|
81
|
+
Extracts each embedded raster image from the PDF, recompresses it, and rebuilds the PDF in place.
|
|
82
|
+
Text and vectors are untouched.
|
|
83
|
+
|
|
84
|
+
**input** — `File | Blob | ArrayBuffer | TypedArray`
|
|
85
|
+
|
|
86
|
+
| Option | Type | Default | Description |
|
|
87
|
+
|---|---|---|---|
|
|
88
|
+
| `quality` | `number` | `75` | JPEG quality, 0–100. Higher = better quality, larger file. |
|
|
89
|
+
| `scale` | `number` | `1` | Resize factor applied to each image's **own** pixel dimensions. `1` = keep native size, just recompress. `0.5` = halve width & height. Clamped to 0.05–1. |
|
|
90
|
+
| `minImageBytes` | `number` | `2048` | Skip images already smaller than this many bytes — not worth re-encoding overhead. |
|
|
91
|
+
| `onlyIfSmaller` | `boolean` | `true` | If the recompressed JPEG would be **larger** than the original stream, keep the original. |
|
|
92
|
+
| `workers` | `number` | auto | Override the auto-detected worker count. Pass `0` or omit for auto. |
|
|
93
|
+
| `onProgress` | `function` | — | `(update) => void`. Called with `{ stage, progress, imageIndex, totalImages, completed }` as compression proceeds. `progress` is 0–100. |
|
|
94
|
+
|
|
95
|
+
**Returns** `Promise<{ file: File|Blob, stats }>`
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
stats = {
|
|
99
|
+
pages, // number of pages in the PDF
|
|
100
|
+
imagesFound, // total image XObjects discovered
|
|
101
|
+
imagesCompressed, // images successfully recompressed
|
|
102
|
+
imagesSkipped, // images skipped (too small, unsupported codec, already smaller, etc.)
|
|
103
|
+
originalBytes, // input file size in bytes
|
|
104
|
+
compressedBytes, // output file size in bytes
|
|
105
|
+
savedBytes, // originalBytes - compressedBytes (≥ 0)
|
|
106
|
+
reduction, // (savedBytes / originalBytes) * 100 — percent
|
|
107
|
+
elapsed, // wall-clock ms
|
|
108
|
+
workersUsed, // number of Worker threads used (0 = direct / no worker pool)
|
|
109
|
+
perImage: [ // one entry per image XObject
|
|
110
|
+
{
|
|
111
|
+
ref, // PDF object reference string
|
|
112
|
+
skipped, // null if compressed; reason string if skipped
|
|
113
|
+
width, height, // original pixel dimensions
|
|
114
|
+
newWidth, newHeight,
|
|
115
|
+
originalBytes,
|
|
116
|
+
compressedBytes,
|
|
117
|
+
format, // "jpeg" | "flate+smask"
|
|
118
|
+
},
|
|
119
|
+
// ...
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
### `rasterizePDF(input, options?)` — legacy
|
|
127
|
+
|
|
128
|
+
Renders **every page** to a full-page bitmap and rebuilds the PDF from those images.
|
|
129
|
+
This **discards text and vector content** (replaces them with pictures of them).
|
|
130
|
+
Prefer `compressPDF` unless the document is already purely scanned images.
|
|
131
|
+
|
|
132
|
+
| Option | Type | Default | Description |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| `quality` | `number` | `65` | JPEG quality, 0–100 |
|
|
135
|
+
| `resolution` | `number \| "original"` | `1600` | Max px on a page's longest side. `"original"` keeps full render scale. |
|
|
136
|
+
| `scale` | `number` | — | Direct render multiplier (overrides `resolution` when set). Clamped 0.25–3. |
|
|
137
|
+
| `workers` | `number` | auto | Worker count override |
|
|
138
|
+
| `engine` | `string` | `"native"` | Encoder engine hint (passed to worker) |
|
|
139
|
+
| `onProgress` | `function` | — | Same shape as `compressPDF` |
|
|
140
|
+
|
|
141
|
+
**Returns** `Promise<{ file: File|Blob, stats }>`
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
### `getClientPower()`
|
|
146
|
+
|
|
147
|
+
Returns `{ cores, memory, workers }` — the auto-detected hardware profile and
|
|
148
|
+
recommended worker count that `compressPDF` would use by default.
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
import { getClientPower } from "compress-pdf-lib";
|
|
152
|
+
|
|
153
|
+
const { cores, memory, workers } = getClientPower();
|
|
154
|
+
console.log(`Using ${workers} workers on ${cores}-core / ${memory}GB RAM machine`);
|
|
55
155
|
```
|
|
56
156
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### `CompressionPool`
|
|
160
|
+
|
|
161
|
+
The underlying worker-pool class. Exported for advanced use cases where you want
|
|
162
|
+
to manage pool lifecycle across multiple compressions (e.g. keep workers alive
|
|
163
|
+
between calls instead of creating/destroying per-call).
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
import { CompressionPool, getClientPower } from "compress-pdf-lib";
|
|
167
|
+
|
|
168
|
+
const pool = new CompressionPool();
|
|
169
|
+
pool.init(getClientPower().workers);
|
|
170
|
+
|
|
171
|
+
// ... compress multiple PDFs reusing the same pool ...
|
|
172
|
+
|
|
173
|
+
pool.destroy();
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Examples
|
|
60
179
|
|
|
61
180
|
### React + Vite
|
|
62
181
|
|
|
@@ -64,12 +183,17 @@ typed array, and returns `{ file, stats }` where `file` is a `File` (or
|
|
|
64
183
|
import { compressPDF } from "compress-pdf-lib";
|
|
65
184
|
|
|
66
185
|
function Uploader() {
|
|
67
|
-
async function handleChange(
|
|
68
|
-
const original =
|
|
69
|
-
const { file, stats } = await compressPDF(original, { quality: 65 });
|
|
186
|
+
async function handleChange(e) {
|
|
187
|
+
const original = e.target.files[0];
|
|
70
188
|
|
|
71
|
-
|
|
72
|
-
|
|
189
|
+
const { file, stats } = await compressPDF(original, {
|
|
190
|
+
quality: 80,
|
|
191
|
+
onProgress: ({ progress, stage }) =>
|
|
192
|
+
console.log(stage, `${progress}%`),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
console.log(`${stats.reduction.toFixed(1)}% smaller in ${(stats.elapsed/1000).toFixed(2)}s`);
|
|
196
|
+
// upload `file`, trigger a download, etc.
|
|
73
197
|
}
|
|
74
198
|
|
|
75
199
|
return <input type="file" accept="application/pdf" onChange={handleChange} />;
|
|
@@ -78,124 +202,71 @@ function Uploader() {
|
|
|
78
202
|
|
|
79
203
|
### Astro
|
|
80
204
|
|
|
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
205
|
```astro
|
|
85
206
|
---
|
|
86
207
|
// src/pages/index.astro
|
|
87
208
|
---
|
|
88
209
|
<input type="file" id="pdf-input" accept="application/pdf" />
|
|
89
|
-
<pre id="
|
|
210
|
+
<pre id="out"></pre>
|
|
90
211
|
|
|
91
212
|
<script>
|
|
92
213
|
import { compressPDF } from "compress-pdf-lib";
|
|
93
214
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
input.addEventListener("change", async (event) => {
|
|
98
|
-
const file = event.target.files?.[0];
|
|
215
|
+
document.getElementById("pdf-input").addEventListener("change", async (e) => {
|
|
216
|
+
const file = e.target.files?.[0];
|
|
99
217
|
if (!file) return;
|
|
100
218
|
|
|
101
|
-
const { file: compressed, stats } = await compressPDF(file, { quality:
|
|
102
|
-
|
|
219
|
+
const { file: compressed, stats } = await compressPDF(file, { quality: 75 });
|
|
220
|
+
document.getElementById("out").textContent = JSON.stringify(stats, null, 2);
|
|
103
221
|
});
|
|
104
222
|
</script>
|
|
105
223
|
```
|
|
106
224
|
|
|
107
|
-
|
|
108
|
-
`compressPDF` inside a browser event handler.)
|
|
225
|
+
### Intercept fetch uploads
|
|
109
226
|
|
|
110
|
-
|
|
227
|
+
Auto-compress any PDF before it leaves the browser:
|
|
111
228
|
|
|
112
229
|
```js
|
|
113
230
|
import { compressPDF } from "compress-pdf-lib";
|
|
114
231
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const [resource, config] = args;
|
|
119
|
-
|
|
232
|
+
const _fetch = window.fetch;
|
|
233
|
+
window.fetch = async function(...args) {
|
|
234
|
+
const [url, config] = args;
|
|
120
235
|
if (config?.body instanceof FormData) {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
}
|
|
236
|
+
const next = new FormData();
|
|
237
|
+
for (const [key, val] of config.body.entries()) {
|
|
238
|
+
if (val instanceof File && val.type === "application/pdf") {
|
|
239
|
+
const { file } = await compressPDF(val, { quality: 75 });
|
|
240
|
+
next.append(key, file, val.name);
|
|
241
|
+
} else {
|
|
242
|
+
next.append(key, val);
|
|
137
243
|
}
|
|
138
|
-
|
|
139
|
-
config.body = newFormData;
|
|
140
244
|
}
|
|
245
|
+
config.body = next;
|
|
141
246
|
}
|
|
142
|
-
|
|
143
|
-
return originalFetch.call(this, resource, config);
|
|
247
|
+
return _fetch.call(this, url, config);
|
|
144
248
|
};
|
|
145
249
|
```
|
|
146
250
|
|
|
147
|
-
|
|
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, ... }` |
|
|
251
|
+
### With progress bar
|
|
157
252
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
### Option A — npm registry
|
|
174
|
-
|
|
175
|
-
```bash
|
|
176
|
-
cd compress-pdf-lib
|
|
177
|
-
npm login
|
|
178
|
-
npm publish
|
|
253
|
+
```js
|
|
254
|
+
const { file, stats } = await compressPDF(pdfFile, {
|
|
255
|
+
quality: 80,
|
|
256
|
+
scale: 0.85,
|
|
257
|
+
onlyIfSmaller: true,
|
|
258
|
+
|
|
259
|
+
onProgress({ stage, progress, imageIndex, totalImages }) {
|
|
260
|
+
progressBar.style.width = `${progress}%`;
|
|
261
|
+
statusEl.textContent =
|
|
262
|
+
stage === "compressing"
|
|
263
|
+
? `Compressing image ${imageIndex} / ${totalImages}…`
|
|
264
|
+
: stage;
|
|
265
|
+
},
|
|
266
|
+
});
|
|
179
267
|
```
|
|
180
268
|
|
|
181
|
-
|
|
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.
|
|
269
|
+
---
|
|
199
270
|
|
|
200
271
|
## License
|
|
201
272
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "compress-pdf-lib",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Client-side PDF compression:
|
|
3
|
+
"version": "1.0.5",
|
|
4
|
+
"description": "Client-side PDF compression: extract & recompress embedded images with hardware-accelerated native Canvas / libjpeg-turbo + pdf-lib rebuild. No server, no UI. Ships as raw ESM source for Vite-based apps (React + Vite, Astro).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"compression",
|
|
24
24
|
"compress-pdf",
|
|
25
25
|
"jpeg",
|
|
26
|
-
"
|
|
26
|
+
"canvas",
|
|
27
27
|
"pdfjs",
|
|
28
28
|
"pdf-lib",
|
|
29
29
|
"vite",
|
|
@@ -34,8 +34,7 @@
|
|
|
34
34
|
"license": "MIT",
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"pdfjs-dist": "^6.3.289",
|
|
37
|
-
"pdf-lib": "^1.17.1"
|
|
38
|
-
"@jsquash/jpeg": "^1.4.0"
|
|
37
|
+
"pdf-lib": "^1.17.1"
|
|
39
38
|
},
|
|
40
39
|
"publishConfig": {
|
|
41
40
|
"access": "public"
|
package/src/compress.js
CHANGED
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
* // }
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
-
import * as pdfjsLib from "pdfjs-dist";
|
|
42
41
|
import {
|
|
43
42
|
PDFDocument,
|
|
44
43
|
PDFName,
|
|
@@ -48,9 +47,6 @@ import {
|
|
|
48
47
|
PDFNumber,
|
|
49
48
|
decodePDFRawStream,
|
|
50
49
|
} from "pdf-lib";
|
|
51
|
-
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
|
52
|
-
|
|
53
|
-
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
|
54
50
|
|
|
55
51
|
/* ============================================================
|
|
56
52
|
CLIENT POWER DETECTION
|
|
@@ -268,6 +264,47 @@ function jpegResultToBytes(item) {
|
|
|
268
264
|
throw new Error("Invalid JPEG encoder output");
|
|
269
265
|
}
|
|
270
266
|
|
|
267
|
+
/*
|
|
268
|
+
* ---- Direct/Worker JPEG Encoder ------------------------------------------
|
|
269
|
+
*/
|
|
270
|
+
|
|
271
|
+
async function encodeBitmapToJpeg(bitmap, targetWidth, targetHeight, quality, engine = "native", pool = null) {
|
|
272
|
+
if (pool) {
|
|
273
|
+
const compressed = await pool.run(
|
|
274
|
+
{
|
|
275
|
+
type: "compress-image",
|
|
276
|
+
bitmap,
|
|
277
|
+
targetWidth,
|
|
278
|
+
targetHeight,
|
|
279
|
+
quality,
|
|
280
|
+
engine,
|
|
281
|
+
},
|
|
282
|
+
[bitmap]
|
|
283
|
+
);
|
|
284
|
+
return jpegResultToBytes(compressed);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ULTRA-FAST DIRECT NATIVE PATH:
|
|
288
|
+
// Zero worker overhead, zero postMessage serialization, runs in ~15ms via browser C++ engine
|
|
289
|
+
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
|
|
290
|
+
const ctx = canvas.getContext("2d", { alpha: false, desynchronized: true });
|
|
291
|
+
if (!ctx) {
|
|
292
|
+
bitmap.close();
|
|
293
|
+
throw new Error("OffscreenCanvas unavailable");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
ctx.fillStyle = "#ffffff";
|
|
297
|
+
ctx.fillRect(0, 0, targetWidth, targetHeight);
|
|
298
|
+
ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight);
|
|
299
|
+
bitmap.close();
|
|
300
|
+
|
|
301
|
+
const q = Math.max(0.01, Math.min(Number(quality) / 100, 1.0));
|
|
302
|
+
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: q });
|
|
303
|
+
canvas.width = 1;
|
|
304
|
+
canvas.height = 1;
|
|
305
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
306
|
+
}
|
|
307
|
+
|
|
271
308
|
/* ============================================================================
|
|
272
309
|
STRATEGY 1 (RECOMMENDED): EXTRACT EMBEDDED IMAGES, COMPRESS, REPLACE IN PLACE
|
|
273
310
|
============================================================================ */
|
|
@@ -276,29 +313,43 @@ function jpegResultToBytes(item) {
|
|
|
276
313
|
* ---- PDF filter name plumbing --------------------------------------------
|
|
277
314
|
*/
|
|
278
315
|
|
|
279
|
-
|
|
280
|
-
|
|
316
|
+
const FILTER_ALIASES = {
|
|
317
|
+
DCT: "DCTDecode",
|
|
318
|
+
Fl: "FlateDecode",
|
|
319
|
+
LZW: "LZWDecode",
|
|
320
|
+
A85: "ASCII85Decode",
|
|
321
|
+
AHx: "ASCIIHexDecode",
|
|
322
|
+
RL: "RunLengthDecode",
|
|
323
|
+
CCF: "CCITTFaxDecode",
|
|
324
|
+
JBIG2: "JBIG2Decode",
|
|
325
|
+
JPX: "JPXDecode",
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
function normalizeFilterName(name) {
|
|
329
|
+
if (!name) return "";
|
|
330
|
+
const clean = name.replace(/^\//, "");
|
|
331
|
+
return FILTER_ALIASES[clean] || clean;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function filterNamesOf(context, dict) {
|
|
335
|
+
const filter = context.lookup(dict.get(PDFName.of("Filter")));
|
|
281
336
|
|
|
282
337
|
if (!filter) return [];
|
|
283
338
|
|
|
284
339
|
if (filter instanceof PDFName) {
|
|
285
|
-
return [filter.asString()
|
|
340
|
+
return [normalizeFilterName(filter.asString())];
|
|
286
341
|
}
|
|
287
342
|
|
|
288
343
|
if (filter instanceof PDFArray) {
|
|
289
|
-
return filter.asArray().map((f) =>
|
|
344
|
+
return filter.asArray().map((f) => {
|
|
345
|
+
const resolved = context.lookup(f);
|
|
346
|
+
return resolved instanceof PDFName ? normalizeFilterName(resolved.asString()) : "";
|
|
347
|
+
}).filter(Boolean);
|
|
290
348
|
}
|
|
291
349
|
|
|
292
350
|
return [];
|
|
293
351
|
}
|
|
294
352
|
|
|
295
|
-
const IMAGE_CODEC_FILTERS = new Set([
|
|
296
|
-
"DCTDecode",
|
|
297
|
-
"JPXDecode",
|
|
298
|
-
"CCITTFaxDecode",
|
|
299
|
-
"JBIG2Decode",
|
|
300
|
-
]);
|
|
301
|
-
|
|
302
353
|
/*
|
|
303
354
|
* ---- ColorSpace resolution --------------------------------------------
|
|
304
355
|
* Returns { kind, components, palette?, paletteComponents? }
|
|
@@ -518,11 +569,12 @@ async function decodeImageXObject(context, ref) {
|
|
|
518
569
|
// values — recompressing would break it, so skip these entirely.
|
|
519
570
|
if (dict.get(PDFName.of("Mask"))) return null;
|
|
520
571
|
|
|
521
|
-
const filterNames = filterNamesOf(dict);
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
572
|
+
const filterNames = filterNamesOf(context, dict);
|
|
573
|
+
|
|
574
|
+
// Skip explicitly unhandled compression formats natively
|
|
575
|
+
const unsupported = filterNames.find(f => f === "JPXDecode" || f === "CCITTFaxDecode" || f === "JBIG2Decode");
|
|
576
|
+
if (unsupported) {
|
|
577
|
+
return { unsupported: true, reason: unsupported };
|
|
526
578
|
}
|
|
527
579
|
|
|
528
580
|
const width = dict.get(PDFName.of("Width"))?.asNumber?.();
|
|
@@ -535,10 +587,64 @@ async function decodeImageXObject(context, ref) {
|
|
|
535
587
|
let bitmap;
|
|
536
588
|
let hasAlpha = false;
|
|
537
589
|
|
|
538
|
-
if (
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
590
|
+
if (filterNames.includes("DCTDecode")) {
|
|
591
|
+
// pdf-lib's decodePDFRawStream throws on DCTDecode because it natively lacks a
|
|
592
|
+
// mechanism to decode JPEGs to raw samples.
|
|
593
|
+
// If DCTDecode is the sole filter (most common), stream.contents is already the raw JPEG bytes.
|
|
594
|
+
// If there is an outer filter (e.g. ASCII85Decode or FlateDecode applied on top of DCTDecode),
|
|
595
|
+
// decode only the outer filters and extract the JPEG bytes without passing DCTDecode to pdf-lib.
|
|
596
|
+
let jpegBytes;
|
|
597
|
+
const originalFilterVal = dict.get(PDFName.of("Filter"));
|
|
598
|
+
const filterObj = context.lookup(originalFilterVal);
|
|
599
|
+
|
|
600
|
+
if (filterNames.length > 1 && filterObj instanceof PDFArray) {
|
|
601
|
+
const remainingFilters = [];
|
|
602
|
+
const remainingDecodeParms = [];
|
|
603
|
+
const originalDecodeParms = context.lookup(dict.get(PDFName.of("DecodeParms")));
|
|
604
|
+
|
|
605
|
+
const filterArray = filterObj.asArray();
|
|
606
|
+
for (let i = 0; i < filterArray.length; i++) {
|
|
607
|
+
const f = filterArray[i];
|
|
608
|
+
const resolved = context.lookup(f);
|
|
609
|
+
const name = resolved instanceof PDFName ? normalizeFilterName(resolved.asString()) : "";
|
|
610
|
+
if (name !== "DCTDecode") {
|
|
611
|
+
remainingFilters.push(f);
|
|
612
|
+
if (originalDecodeParms instanceof PDFArray) {
|
|
613
|
+
remainingDecodeParms.push(originalDecodeParms.get(i));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (remainingFilters.length === 0) {
|
|
619
|
+
jpegBytes = stream.contents;
|
|
620
|
+
} else {
|
|
621
|
+
// Construct a safe surrogate stream without mutating the original dictionary
|
|
622
|
+
const surrogateDict = {
|
|
623
|
+
lookup(key) {
|
|
624
|
+
if (key === PDFName.of("Filter")) {
|
|
625
|
+
return remainingFilters.length === 1
|
|
626
|
+
? remainingFilters[0]
|
|
627
|
+
: context.obj(remainingFilters);
|
|
628
|
+
}
|
|
629
|
+
if (key === PDFName.of("DecodeParms")) {
|
|
630
|
+
if (originalDecodeParms instanceof PDFArray) {
|
|
631
|
+
return remainingDecodeParms.length === 1
|
|
632
|
+
? remainingDecodeParms[0]
|
|
633
|
+
: context.obj(remainingDecodeParms);
|
|
634
|
+
}
|
|
635
|
+
return originalDecodeParms;
|
|
636
|
+
}
|
|
637
|
+
return dict.lookup(key);
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
jpegBytes = decodePDFRawStream({ dict: surrogateDict, contents: stream.contents }).decode();
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
// Direct raw DCTDecode stream (most common)
|
|
645
|
+
jpegBytes = stream.contents;
|
|
646
|
+
}
|
|
647
|
+
|
|
542
648
|
const blob = new Blob([jpegBytes], { type: "image/jpeg" });
|
|
543
649
|
bitmap = await createImageBitmap(blob);
|
|
544
650
|
} else {
|
|
@@ -582,6 +688,7 @@ async function decodeImageXObject(context, ref) {
|
|
|
582
688
|
}
|
|
583
689
|
|
|
584
690
|
bitmap.close();
|
|
691
|
+
smaskDecoded.bitmap.close();
|
|
585
692
|
bitmap = await createImageBitmap(base);
|
|
586
693
|
}
|
|
587
694
|
} catch (smaskError) {
|
|
@@ -729,6 +836,8 @@ export async function compressPDF(input, options = {}) {
|
|
|
729
836
|
minImageBytes = 2048,
|
|
730
837
|
onlyIfSmaller = true,
|
|
731
838
|
workers: workerOverride,
|
|
839
|
+
useWorker = false,
|
|
840
|
+
engine = "native",
|
|
732
841
|
onProgress,
|
|
733
842
|
} = options;
|
|
734
843
|
|
|
@@ -746,11 +855,17 @@ export async function compressPDF(input, options = {}) {
|
|
|
746
855
|
const context = pdfDoc.context;
|
|
747
856
|
const imageRefs = findImageRefs(pdfDoc);
|
|
748
857
|
|
|
749
|
-
const
|
|
750
|
-
|
|
858
|
+
const shouldSpawnWorkers = useWorker || (typeof workerOverride === "number" && workerOverride > 0);
|
|
859
|
+
let pool = null;
|
|
860
|
+
let workerCount = 0;
|
|
751
861
|
|
|
752
|
-
|
|
753
|
-
|
|
862
|
+
if (shouldSpawnWorkers) {
|
|
863
|
+
const power = getClientPower();
|
|
864
|
+
const maxNeededWorkers = Math.max(1, Math.min(imageRefs.length, 4));
|
|
865
|
+
workerCount = Math.min(workerOverride || power.workers, maxNeededWorkers);
|
|
866
|
+
pool = new CompressionPool();
|
|
867
|
+
pool.init(workerCount);
|
|
868
|
+
}
|
|
754
869
|
|
|
755
870
|
const perImage = [];
|
|
756
871
|
let imagesCompressed = 0;
|
|
@@ -798,17 +913,19 @@ export async function compressPDF(input, options = {}) {
|
|
|
798
913
|
entry.newHeight = newHeight;
|
|
799
914
|
entry.originalBytes = origImgBytes;
|
|
800
915
|
|
|
801
|
-
let resizedBitmap = bitmap;
|
|
802
|
-
|
|
803
|
-
if (newWidth !== width || newHeight !== height) {
|
|
804
|
-
const resizeCanvas = new OffscreenCanvas(newWidth, newHeight);
|
|
805
|
-
const resizeCtx = resizeCanvas.getContext("2d");
|
|
806
|
-
resizeCtx.drawImage(bitmap, 0, 0, newWidth, newHeight);
|
|
807
|
-
bitmap.close();
|
|
808
|
-
resizedBitmap = await createImageBitmap(resizeCanvas);
|
|
809
|
-
}
|
|
810
|
-
|
|
811
916
|
if (hasAlpha) {
|
|
917
|
+
let resizedBitmap = bitmap;
|
|
918
|
+
|
|
919
|
+
if (newWidth !== width || newHeight !== height) {
|
|
920
|
+
const resizeCanvas = new OffscreenCanvas(newWidth, newHeight);
|
|
921
|
+
const resizeCtx = resizeCanvas.getContext("2d");
|
|
922
|
+
resizeCtx.drawImage(bitmap, 0, 0, newWidth, newHeight);
|
|
923
|
+
bitmap.close();
|
|
924
|
+
resizedBitmap = await createImageBitmap(resizeCanvas);
|
|
925
|
+
resizeCanvas.width = 1;
|
|
926
|
+
resizeCanvas.height = 1;
|
|
927
|
+
}
|
|
928
|
+
|
|
812
929
|
const newBytes = await replaceImageWithFlateRGBA(
|
|
813
930
|
context,
|
|
814
931
|
ref,
|
|
@@ -823,19 +940,16 @@ export async function compressPDF(input, options = {}) {
|
|
|
823
940
|
entry.compressedBytes = newBytes;
|
|
824
941
|
imagesCompressed++;
|
|
825
942
|
} else {
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
[resizedBitmap]
|
|
943
|
+
// Direct single-pass compression: zero-copy native encode without intermediate canvas
|
|
944
|
+
const jpegBytes = await encodeBitmapToJpeg(
|
|
945
|
+
bitmap,
|
|
946
|
+
newWidth,
|
|
947
|
+
newHeight,
|
|
948
|
+
quality,
|
|
949
|
+
engine,
|
|
950
|
+
pool
|
|
835
951
|
);
|
|
836
952
|
|
|
837
|
-
const jpegBytes = jpegResultToBytes(compressed);
|
|
838
|
-
|
|
839
953
|
if (onlyIfSmaller && jpegBytes.length >= origImgBytes) {
|
|
840
954
|
entry.skipped = "recompressed-not-smaller";
|
|
841
955
|
entry.compressedBytes = origImgBytes;
|
|
@@ -872,7 +986,9 @@ export async function compressPDF(input, options = {}) {
|
|
|
872
986
|
}
|
|
873
987
|
}
|
|
874
988
|
|
|
875
|
-
const concurrency =
|
|
989
|
+
const concurrency = shouldSpawnWorkers
|
|
990
|
+
? Math.max(1, Math.min(workerCount, 4))
|
|
991
|
+
: Math.min(Math.max(imageRefs.length, 1), 4);
|
|
876
992
|
const runnerCount = Math.min(concurrency, Math.max(imageRefs.length, 1));
|
|
877
993
|
|
|
878
994
|
await Promise.all(Array.from({ length: runnerCount }, () => runner()));
|
|
@@ -902,7 +1018,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
902
1018
|
savedBytes,
|
|
903
1019
|
reduction,
|
|
904
1020
|
elapsed,
|
|
905
|
-
workersUsed: workerCount,
|
|
1021
|
+
workersUsed: shouldSpawnWorkers ? workerCount : 0,
|
|
906
1022
|
perImage,
|
|
907
1023
|
};
|
|
908
1024
|
|
|
@@ -917,7 +1033,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
917
1033
|
|
|
918
1034
|
return { file, stats };
|
|
919
1035
|
} finally {
|
|
920
|
-
pool
|
|
1036
|
+
pool?.destroy();
|
|
921
1037
|
}
|
|
922
1038
|
}
|
|
923
1039
|
|
|
@@ -927,7 +1043,7 @@ export async function compressPDF(input, options = {}) {
|
|
|
927
1043
|
there's nothing for compressPDF's per-image extraction to find separately.
|
|
928
1044
|
============================================================================ */
|
|
929
1045
|
|
|
930
|
-
async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale }) {
|
|
1046
|
+
async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality, resolution, scale: fixedScale, engine = "native" }) {
|
|
931
1047
|
let page = null;
|
|
932
1048
|
let canvas = null;
|
|
933
1049
|
let bitmap = null;
|
|
@@ -989,9 +1105,12 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
|
|
|
989
1105
|
{
|
|
990
1106
|
type: "compress-image",
|
|
991
1107
|
bitmap,
|
|
1108
|
+
targetWidth: width,
|
|
1109
|
+
targetHeight: height,
|
|
1110
|
+
quality,
|
|
1111
|
+
engine,
|
|
992
1112
|
pageNumber,
|
|
993
1113
|
totalPages,
|
|
994
|
-
quality,
|
|
995
1114
|
pdfWidth,
|
|
996
1115
|
pdfHeight,
|
|
997
1116
|
},
|
|
@@ -1019,7 +1138,7 @@ async function rasterizeProcessPage(pdf, pool, pageNumber, totalPages, { quality
|
|
|
1019
1138
|
}
|
|
1020
1139
|
}
|
|
1021
1140
|
|
|
1022
|
-
async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, onProgress }) {
|
|
1141
|
+
async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolution, scale, workers, engine = "native", onProgress }) {
|
|
1023
1142
|
const results = new Array(totalPages);
|
|
1024
1143
|
|
|
1025
1144
|
const renderConcurrency = Math.max(1, Math.min(workers, 4));
|
|
@@ -1039,6 +1158,7 @@ async function rasterizeProcessPages(pdf, pool, totalPages, { quality, resolutio
|
|
|
1039
1158
|
quality,
|
|
1040
1159
|
resolution,
|
|
1041
1160
|
scale,
|
|
1161
|
+
engine,
|
|
1042
1162
|
});
|
|
1043
1163
|
|
|
1044
1164
|
results[pageNumber - 1] = result;
|
|
@@ -1162,6 +1282,7 @@ export async function rasterizePDF(input, options = {}) {
|
|
|
1162
1282
|
resolution = 1600,
|
|
1163
1283
|
scale,
|
|
1164
1284
|
workers: workerOverride,
|
|
1285
|
+
engine = "native",
|
|
1165
1286
|
onProgress,
|
|
1166
1287
|
} = options;
|
|
1167
1288
|
|
|
@@ -1190,6 +1311,7 @@ export async function rasterizePDF(input, options = {}) {
|
|
|
1190
1311
|
resolution,
|
|
1191
1312
|
scale,
|
|
1192
1313
|
workers: workerCount,
|
|
1314
|
+
engine,
|
|
1193
1315
|
onProgress,
|
|
1194
1316
|
});
|
|
1195
1317
|
|
|
@@ -1217,4 +1339,4 @@ export async function rasterizePDF(input, options = {}) {
|
|
|
1217
1339
|
|
|
1218
1340
|
pool.destroy();
|
|
1219
1341
|
}
|
|
1220
|
-
}
|
|
1342
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pdf-compressor.worker.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Takes a transferred ImageBitmap, draws it to an OffscreenCanvas
|
|
6
|
-
*
|
|
4
|
+
* Worker used by CompressionPool in compress.js.
|
|
5
|
+
* Takes a transferred ImageBitmap, draws it to an OffscreenCanvas (handling
|
|
6
|
+
* any scaling in a single native GPU/canvas draw call), encodes it directly
|
|
7
|
+
* to JPEG using browser-native OffscreenCanvas.convertToBlob(), and transfers
|
|
7
8
|
* the resulting JPEG bytes back to the main thread.
|
|
9
|
+
*
|
|
10
|
+
* 100% native C++ libjpeg-turbo with SIMD hardware acceleration.
|
|
11
|
+
* Zero WASM, zero external dependencies, zero JS heap memory overhead.
|
|
8
12
|
*/
|
|
9
13
|
|
|
10
|
-
import { encode as encodeJpeg } from "@jsquash/jpeg";
|
|
11
|
-
|
|
12
14
|
self.onmessage = async (event) => {
|
|
13
15
|
const data = event.data;
|
|
14
16
|
|
|
@@ -19,9 +21,11 @@ self.onmessage = async (event) => {
|
|
|
19
21
|
const {
|
|
20
22
|
jobId,
|
|
21
23
|
bitmap,
|
|
24
|
+
targetWidth,
|
|
25
|
+
targetHeight,
|
|
22
26
|
pageNumber,
|
|
23
27
|
totalPages,
|
|
24
|
-
quality,
|
|
28
|
+
quality = 75,
|
|
25
29
|
pdfWidth,
|
|
26
30
|
pdfHeight,
|
|
27
31
|
} = data;
|
|
@@ -31,15 +35,14 @@ self.onmessage = async (event) => {
|
|
|
31
35
|
throw new Error("ImageBitmap missing");
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
const width = bitmap.width;
|
|
35
|
-
const height = bitmap.height;
|
|
38
|
+
const width = targetWidth || bitmap.width;
|
|
39
|
+
const height = targetHeight || bitmap.height;
|
|
36
40
|
|
|
37
41
|
/* ================================================
|
|
38
|
-
OFFSCREEN CANVAS
|
|
42
|
+
OFFSCREEN CANVAS (SINGLE-PASS DRAW & RESIZE)
|
|
39
43
|
================================================ */
|
|
40
44
|
|
|
41
45
|
const canvas = new OffscreenCanvas(width, height);
|
|
42
|
-
|
|
43
46
|
const ctx = canvas.getContext("2d", {
|
|
44
47
|
alpha: false,
|
|
45
48
|
desynchronized: true,
|
|
@@ -53,49 +56,32 @@ self.onmessage = async (event) => {
|
|
|
53
56
|
ctx.fillStyle = "#ffffff";
|
|
54
57
|
ctx.fillRect(0, 0, width, height);
|
|
55
58
|
|
|
59
|
+
// Draw and resize in a single hardware-accelerated operation
|
|
56
60
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
57
|
-
|
|
58
61
|
bitmap.close();
|
|
59
62
|
|
|
60
63
|
/* ================================================
|
|
61
|
-
|
|
62
|
-
================================================ */
|
|
63
|
-
|
|
64
|
-
const imageData = ctx.getImageData(0, 0, width, height);
|
|
65
|
-
|
|
66
|
-
/* ================================================
|
|
67
|
-
MOZJPEG WASM
|
|
64
|
+
DIRECT NATIVE JPEG ENCODING
|
|
68
65
|
================================================ */
|
|
69
66
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
const q = Math.max(0.01, Math.min(Number(quality) / 100, 1.0));
|
|
68
|
+
const blob = await canvas.convertToBlob({
|
|
69
|
+
type: "image/jpeg",
|
|
70
|
+
quality: q,
|
|
74
71
|
});
|
|
75
72
|
|
|
76
|
-
|
|
77
|
-
NORMALIZE ARRAYBUFFER
|
|
78
|
-
================================================ */
|
|
73
|
+
const jpegBuffer = await blob.arrayBuffer();
|
|
79
74
|
|
|
80
|
-
|
|
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");
|
|
75
|
+
if (!jpegBuffer || jpegBuffer.byteLength === 0) {
|
|
76
|
+
throw new Error("Empty JPEG output");
|
|
91
77
|
}
|
|
92
78
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
79
|
+
// Immediately free canvas memory
|
|
80
|
+
canvas.width = 1;
|
|
81
|
+
canvas.height = 1;
|
|
96
82
|
|
|
97
83
|
/* ================================================
|
|
98
|
-
TRANSFER
|
|
84
|
+
TRANSFER BACK
|
|
99
85
|
================================================ */
|
|
100
86
|
|
|
101
87
|
self.postMessage(
|
|
@@ -113,9 +99,6 @@ self.onmessage = async (event) => {
|
|
|
113
99
|
},
|
|
114
100
|
[jpegBuffer]
|
|
115
101
|
);
|
|
116
|
-
|
|
117
|
-
canvas.width = 1;
|
|
118
|
-
canvas.height = 1;
|
|
119
102
|
} catch (error) {
|
|
120
103
|
try {
|
|
121
104
|
bitmap?.close();
|