@keeratita/heic-converter 0.4.2 โ†’ 0.5.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @keeratita/heic-converter
2
2
 
3
- A modern, lightweight TypeScript library to convert `.heic` and `.heif` images to standard web formats (JPEG, PNG, WebP, SVG) client-side in the browser or on the backend in Node.js.
3
+ A modern, lightweight TypeScript library to convert `.heic` and `.heif` images to standard web formats (JPEG, PNG, WebP, AVIF, SVG) client-side in the browser or on the backend in Node.js.
4
4
 
5
5
  Designed specifically for environments with strict **Content Security Policy (CSP)** rules, it is built with WebAssembly compiled **without** dynamic code execution (`eval()` or `new Function()`).
6
6
 
@@ -10,13 +10,16 @@ Designed specifically for environments with strict **Content Security Policy (CS
10
10
 
11
11
  - ๐Ÿ”’ **CSP Compliant**: Emscripten glue code is compiled with `-s DYNAMIC_EXECUTION=0`. Safe to run without `'unsafe-eval'`.
12
12
  - ๐Ÿงฉ **Dependency Injection Architecture**: Swap the decoder module easily by implementing a simple `IHeicDecoder` interface.
13
- - โšก **Optimized Performance**: A fresh decoder instance is created and released per conversion โ€” memory is reclaimed promptly and concurrent conversions never share mutable WASM state.
13
+ - โšก **Optimized Performance**: A fresh decoder instance is created and released per conversion โ€” memory is reclaimed promptly and concurrent conversions never share mutable WASM state. `convertMany({ reuseDecoders: true })` amortizes WASM init across batch items.
14
14
  - ๐ŸŒ **Isomorphic / Universal**: Runs in Node.js (decoding) and browser (decoding & canvas-based encoding).
15
15
  - ๐Ÿ“ฆ **No Bloat**: Zero external production dependencies. Small footprint.
16
- - ๐ŸŽจ **Format Support**: Convert to `jpeg` (with quality configuration), `png`, `webp`, and `svg` (embedded lossless vector).
17
- - ๐Ÿ“ **Resize Support**: Downscale with `maxWidth`/`maxHeight` or apply a uniform `scale` factor during conversion.
18
- - ๐Ÿ“š **Batch Conversion**: Convert many images at once with bounded concurrency via `convertMany`.
19
- - ๐Ÿงต **Web Worker Helper**: Offload conversions to a Web Worker with `convertHeicInWorker` to keep the UI thread responsive.
16
+ - ๐ŸŽจ **Format Support**: Convert to `jpeg` (with quality configuration), `png`, `webp`, `avif`, and `svg` (the encoded raster wrapped in an SVG document). AVIF degrades gracefully with a clear `format_unsupported` error where the canvas cannot encode it.
17
+ - ๐Ÿ“ **Resize & Crop**: Downscale with `maxWidth`/`maxHeight` or apply a uniform `scale` factor; cut any region with `crop` (in display pixels, composed with orientation).
18
+ - ๐Ÿงญ **EXIF-Orientation Aware**: Images stored rotated with an EXIF orientation flag come out upright, matching what viewers display. Opt out with `applyOrientation: false`.
19
+ - ๐Ÿ—‚ **Metadata Preservation**: `preserveExif: true` keeps the source EXIF block in JPEG (APP1) and PNG (`eXIf`) output; raw-decode consumers get the block via `DecodedImage.exif`.
20
+ - ๐Ÿ“š **Batch Conversion**: Convert many images at once with bounded concurrency via `convertMany` โ€” with per-item results (`continueOnError`) and pooled decoders (`reuseDecoders`) for large batches.
21
+ - ๐Ÿงต **Web Worker Helpers**: Offload single conversions (`convertHeicInWorker`) or whole batches (`convertManyInWorker`) to Web Workers to keep the UI thread responsive.
22
+ - ๐ŸŽ› **Output Shapes & Cancellation**: Return Blobs, base64 data URLs, or ArrayBuffers (`output`), and cancel in-flight work with an `AbortSignal` (`signal`) โ€” worker conversions terminate immediately.
20
23
 
21
24
  ---
22
25
 
@@ -48,15 +51,17 @@ In the browser, you can pass a `File` or `Blob` and get a converted `Blob` back:
48
51
  import { convertHeic } from '@keeratita/heic-converter';
49
52
 
50
53
  // Convert input File/Blob to JPEG
51
- const heicBlob = /* your file input */;
52
- const jpegBlob = await convertHeic(heicBlob, {
54
+ const input = document.querySelector<HTMLInputElement>('#fileInput')!.files!.item(0)!;
55
+ const jpegBlob = await convertHeic(input, {
53
56
  to: 'jpeg',
54
- quality: 0.9
57
+ quality: 0.9,
55
58
  });
56
59
 
57
60
  // Create preview URL
58
- const imageUrl = URL.createObjectURL(jpegBlob);
59
- document.querySelector('img').src = imageUrl;
61
+ const preview = document.querySelector('img');
62
+ if (preview) {
63
+ preview.src = URL.createObjectURL(jpegBlob);
64
+ }
60
65
  ```
61
66
 
62
67
  ### 2. Browser: WebP Output
@@ -66,6 +71,8 @@ WebP offers excellent compression with quality configuration:
66
71
  ```typescript
67
72
  import { convertHeic } from '@keeratita/heic-converter';
68
73
 
74
+ declare const heicBlob: Blob; // your HEIC file/blob
75
+
69
76
  const webpBlob = await convertHeic(heicBlob, {
70
77
  to: 'webp',
71
78
  quality: 0.8,
@@ -79,6 +86,8 @@ SVG wraps the raster image as an embedded lossless PNG inside an SVG container:
79
86
  ```typescript
80
87
  import { convertHeic } from '@keeratita/heic-converter';
81
88
 
89
+ declare const heicBlob: Blob; // your HEIC file/blob
90
+
82
91
  const svgBlob = await convertHeic(heicBlob, {
83
92
  to: 'svg',
84
93
  });
@@ -93,6 +102,8 @@ If your bundler places files in a custom assets folder or CDN, you can configure
93
102
  ```typescript
94
103
  import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
95
104
 
105
+ declare const heicBlob: Blob; // your HEIC file/blob
106
+
96
107
  // Create decoder with custom asset paths
97
108
  const decoder = new LibheifDecoder({
98
109
  locateFile: (path, prefix) => `https://cdn.example.com/assets/${path}`,
@@ -110,6 +121,8 @@ Alternatively, if you prefer to load the WASM binary manually as an `ArrayBuffer
110
121
  ```typescript
111
122
  import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
112
123
 
124
+ declare const heicBlob: Blob; // your HEIC file/blob
125
+
113
126
  const wasmResponse = await fetch('/assets/heic-decoder.wasm');
114
127
  const wasmBinary = await wasmResponse.arrayBuffer();
115
128
 
@@ -121,11 +134,47 @@ const jpegBlob = await convertHeic(heicBlob, {
121
134
  });
122
135
  ```
123
136
 
137
+ #### Files you must serve
138
+
139
+ `dist/` is no longer a single file. The JS is code-split, so a hand-hosted deploy (copying files into an assets folder or a CDN, with no bundler rewriting specifiers) needs **all** of these, under the chunk names the build produced:
140
+
141
+ | File | Needed for |
142
+ | --- | --- |
143
+ | `index.mjs` / `index.js` | the entry (ESM / CJS) |
144
+ | `chunk-*.mjs` / `chunk-*.js` | the eagerly imported shared code |
145
+ | `heic-decoder-*.mjs` / `heic-decoder-*.js` | the Emscripten glue, fetched on first decode |
146
+ | `heic-decoder.wasm` (+ `.gz` / `.br`) | the decoder binary |
147
+ | `worker-*.mjs` / `worker-*.js` | `convertHeicInWorker` / `convertManyInWorker` |
148
+ | `exif-*.mjs` / `exif-*.js` | `preserveExif: true` |
149
+
150
+ - Chunk names are **content-hashed and hard-referenced by the entry**, so a partial upload โ€” or a `dist/` mixed across releases โ€” 404s instead of degrading quietly. Deploy the whole directory per release.
151
+ - Serve chunks as `text/javascript`. A wrong MIME type surfaces as `worker_load_failed` from the worker APIs, and as `preserveExif` silently dropping metadata (it warns once).
152
+ - `heic-decoder.wasm` and the glue chunk come from **one** `emcc` invocation and are ABI-coupled. If you host the binary separately (`locateFile`, an artifact mirror, an immutable `/assets/heic-decoder.wasm`), re-upload *and* cache-bust it together with the glue, or instantiation fails with `decoder_init_failed`.
153
+
154
+ A bundler handles all of this: the dynamic imports are literal specifiers, so your bundler rewrites and emits the chunks for you.
155
+
156
+ #### Using a bundler (Vite, webpack, Rollup, esbuild)
157
+
158
+ The main entry is a small lazy loader: the Emscripten glue ships as a dynamic-import chunk and the `.wasm` binary is **not** inlined โ€” you must make sure `dist/heic-decoder.wasm` is served and resolvable at runtime. Bundlers that rewrite asset URLs usually handle this automatically because the glue resolves the binary relative to its own chunk. If yours doesn't, copy the binary into your assets (the `@keeratita/heic-converter/wasm` subpath export resolves to the file itself, handy as a copy source) and point the decoder at it:
159
+
160
+ ```typescript
161
+ import { convertHeic, LibheifDecoder } from '@keeratita/heic-converter';
162
+
163
+ declare const heicBlob: Blob;
164
+
165
+ // Serve a copy of @keeratita/heic-converter/dist/heic-decoder.wasm there.
166
+ const blob = await convertHeic(heicBlob, {
167
+ decoder: new LibheifDecoder({ locateFile: () => '/assets/heic-decoder.wasm' }),
168
+ });
169
+ ```
170
+
171
+ Fetching a `.gz`/`.br` variant URL directly will not work โ€” see the compression note below.
172
+
124
173
  #### Reducing the download size
125
174
 
126
- The WASM binary (~1.2 MB) dominates the payload. Everything else is small and lazy: the Emscripten glue (~65 KB) is a separate chunk fetched only on the first decode, and the main entry is ~9 KB.
175
+ The WASM binary (~794 KB, ~288 KB gzipped) dominates the payload; everything else is small and lazy. The Emscripten glue is a separate chunk fetched only on the first decode, and the Web Worker implementation and the opt-in EXIF injectors are separate chunks fetched only when you first call `convertHeicInWorker`/`convertManyInWorker` or pass `preserveExif: true`. What every consumer pays for up front is a few kilobytes of gzipped JavaScript โ€” `test/unit/bundle.test.ts` fails the build if one of those boundaries moves or the eager budget grows, so the numbers live in the tests rather than here.
127
176
 
128
- `npm run build` also emits pre-compressed copies โ€” `dist/heic-decoder.wasm.gz` (~397 KB) and `dist/heic-decoder.wasm.br` (~294 KB). Most static hosts and CDNs (GitHub Pages, Netlify, Vercel, Cloudflare) already compress `application/wasm` automatically when the browser sends `Accept-Encoding`; verify with:
177
+ `npm run build` also emits pre-compressed copies โ€” `dist/heic-decoder.wasm.gz` (~288 KB) and `dist/heic-decoder.wasm.br` (~230 KB) โ€” and verifies each one decompresses back to the binary, so a corrupt sidecar cannot reach a CDN. Most static hosts and CDNs (GitHub Pages, Netlify, Vercel, Cloudflare) already compress `application/wasm` automatically when the browser sends `Accept-Encoding`; verify with:
129
178
 
130
179
  ```bash
131
180
  curl -sI -H 'Accept-Encoding: br' https://your-site/heic-decoder.wasm | grep -i content-encoding
@@ -133,7 +182,7 @@ curl -sI -H 'Accept-Encoding: br' https://your-site/heic-decoder.wasm | grep -i
133
182
 
134
183
  If your server does not compress automatically, serve the pre-compressed files with the matching `Content-Encoding` header (nginx: `gzip_static on;` / `brotli_static on;`). Do **not** point `locateFile` at a `.br`/`.gz` URL โ€” the browser only decompresses responses tagged with `Content-Encoding`.
135
184
 
136
- For long-lived caching, serve the WASM with `Cache-Control: public, max-age=31536000, immutable` and rename it per release (e.g. `heic-decoder-1.2.3.wasm` via `locateFile`) so clients pick up upgrades. GitHub Pages caps asset cache lifetime at 600s; put a CDN in front if that matters.
185
+ For long-lived caching, serve the WASM with `Cache-Control: public, max-age=31536000, immutable` and rename it per release (e.g. `heic-decoder-1.2.3.wasm` via `locateFile`) so clients pick up upgrades โ€” and treat the binary and its glue chunk as one unit when you do, per the note above. GitHub Pages caps asset cache lifetime at 600s; put a CDN in front if that matters.
137
186
 
138
187
  ### 5. Node.js: Decoding Raw Pixel Data
139
188
 
@@ -149,19 +198,34 @@ import sharp from 'sharp'; // external node image library
149
198
  async function convertNode() {
150
199
  const heicData = new Uint8Array(fs.readFileSync('input.heic'));
151
200
 
152
- const decoder = new LibheifDecoder();
201
+ // Passing wasmBinary explicitly avoids relying on default path resolution
202
+ // inside the package; fs.readFileSync returns a Buffer, which the option
203
+ // accepts (it takes ArrayBuffer or any ArrayBufferView).
204
+ const decoder = new LibheifDecoder({
205
+ wasmBinary: fs.readFileSync(
206
+ 'node_modules/@keeratita/heic-converter/dist/heic-decoder.wasm',
207
+ ),
208
+ });
153
209
  await decoder.initialize();
154
210
 
155
- // Decodes to { width, height, data: Uint8ClampedArray (RGBA) }.
211
+ // Decodes to { width, height, data: Uint8ClampedArray (RGBA), orientation?, exif? }.
156
212
  // data is an independent copy โ€” safe to use after decoder.free().
157
- const { width, height, data } = await decoder.decode(heicData);
213
+ // orientation 2-8 means the stored pixels need a rotation/flip for display
214
+ // (convertHeic applies it automatically; raw-decode consumers must do it
215
+ // themselves โ€” rotate the buffer, or set the orientation tag in `exif`
216
+ // before handing it to sharp so viewers apply it).
217
+ // exif is the source EXIF block ("Exif\0\0" + TIFF) when the file carries
218
+ // one โ€” pass it to sharp.withMetadata({ exif }) to keep camera metadata.
219
+ const { width, height, data, exif } = await decoder.decode(heicData);
158
220
 
159
221
  // Process raw pixels using sharp
160
- await sharp(Buffer.from(data), {
222
+ let pipeline = sharp(Buffer.from(data), {
161
223
  raw: { width, height, channels: 4 },
162
- })
163
- .toFormat('jpeg')
164
- .toFile('output.jpg');
224
+ });
225
+ if (exif) {
226
+ pipeline = pipeline.withMetadata({ exif: Buffer.from(exif) });
227
+ }
228
+ await pipeline.toFormat('jpeg').toFile('output.jpg');
165
229
 
166
230
  // Clean up WASM memory
167
231
  decoder.free();
@@ -175,7 +239,8 @@ For large images, you can pass an `onProgress` callback to track the conversion
175
239
  ```typescript
176
240
  import { convertHeic } from '@keeratita/heic-converter';
177
241
 
178
- const heicBlob = /* your file */;
242
+ declare const heicBlob: Blob; // your HEIC file/blob
243
+
179
244
  const jpegBlob = await convertHeic(heicBlob, {
180
245
  to: 'jpeg',
181
246
  onProgress: (percent) => {
@@ -185,6 +250,9 @@ const jpegBlob = await convertHeic(heicBlob, {
185
250
  });
186
251
  ```
187
252
 
253
+ > [!NOTE]
254
+ > The callback contract is enforced by the library: `percent` is always a finite number clamped to `0`โ€“`100` (first call `0`, last call `100`). If your callback throws, the conversion rejects with a `progress_callback_failed` error naming your message โ€” the failure is attributed to the callback instead of crashing the WASM module.
255
+
188
256
  > [!TIP]
189
257
  > Since the WebAssembly module runs on the main browser thread, the UI thread will be occupied during conversion. For maximum responsiveness when converting large images, it is highly recommended to run this library inside a standard JS **Web Worker** and communicate progress back to the main thread.
190
258
 
@@ -195,6 +263,8 @@ Downscale to fit within maximum dimensions (aspect ratio is preserved, images sm
195
263
  ```typescript
196
264
  import { convertHeic } from '@keeratita/heic-converter';
197
265
 
266
+ declare const heicBlob: Blob; // your HEIC file/blob
267
+
198
268
  const thumbnailBlob = await convertHeic(heicBlob, {
199
269
  to: 'jpeg',
200
270
  maxWidth: 800,
@@ -211,13 +281,97 @@ const halfSizeBlob = await convertHeic(heicBlob, {
211
281
  });
212
282
  ```
213
283
 
214
- ### 8. Batch Conversion
284
+ > [!NOTE]
285
+ > Target dimensions are validated before any decoding work: `scale`, `maxWidth`, and `maxHeight` must be positive finite numbers (otherwise an `invalid_resize` error is thrown), and the resulting target size must not exceed **16384 px** on either side โ€” the canvas/WASM ceiling shared with the decoder's own security limits.
286
+
287
+ ### 8. Orientation (EXIF) Handling
288
+
289
+ Some HEIC files are stored rotated, with the intended display rotation recorded in the EXIF orientation tag (274). Browsers, Photos, and most image viewers apply that rotation when displaying โ€” so `convertHeic` uprights the output for you during rendering:
290
+
291
+ ```typescript
292
+ import { convertHeic } from '@keeratita/heic-converter';
293
+
294
+ // Upright according to the source's EXIF orientation (default: applyOrientation: true)
295
+ const jpeg = await convertHeic(heicFile);
296
+
297
+ // Keep the exact stored-pixel geometry instead (the pre-0.5 behavior):
298
+ const unrotated = await convertHeic(heicFile, { applyOrientation: false });
299
+ ```
300
+
301
+ How it works:
302
+
303
+ - HEIF's native display-transform boxes (`irot`/`imir`) are applied by the decoder itself during `decode()` โ€” `convertHeic` never stacks a second rotation on top, so Apple-style files are never double-rotated.
304
+ - When a file carries only the EXIF tag (the class written by tools that just rewrite metadata), `convertHeic` applies the rotation/flip via a canvas transform.
305
+ - `LibheifDecoder.decode()` returns the raw **stored** pixels and reports a pending rotation via the optional `orientation` field (EXIF semantics `1`โ€“`8`; absent or `1` means nothing is pending). If you encode the pixels yourself (e.g. Node.js + `sharp`), apply the rotation yourself.
306
+ - A non-boolean `applyOrientation` value is rejected up front with an `invalid_input` error.
307
+
308
+ ### 9. Cropping
309
+
310
+ Cut a rectangle out of the image during conversion. Crop coordinates are in **post-orientation display pixels** โ€” the geometry the image is shown in (after any EXIF/irot correction), which is what "top-left corner" means to a user. The crop is applied before `scale`/`maxWidth`/`maxHeight`, which then downscale the cropped region:
311
+
312
+ ```typescript
313
+ import { convertHeic } from '@keeratita/heic-converter';
314
+
315
+ declare const heicBlob: Blob; // your HEIC file/blob
316
+
317
+ const thumbnail = await convertHeic(heicBlob, {
318
+ to: 'jpeg',
319
+ crop: { x: 200, y: 100, width: 640, height: 480 }, // x/y default to 0
320
+ maxWidth: 320, // downscale the crop, not the whole image
321
+ });
322
+ ```
323
+
324
+ Cropping semantics:
325
+
326
+ - `width`/`height` are required positive integers; `x`/`y` are optional non-negative integers. Malformed values reject with `invalid_crop` **before** any decoding; a rectangle that exceeds the image rejects with `invalid_crop` naming the actual display size.
327
+ - Crop, orientation, and resize compose into a single canvas transform โ€” no intermediate copies.
328
+ - With `applyOrientation: false`, crop coordinates address the **stored** pixels instead (geometry as decoded).
329
+
330
+ ### 10. Output Shapes (Blob, data URL, ArrayBuffer)
331
+
332
+ By default conversions return a `Blob`. Choose another representation with `output` โ€” the return type follows the option:
333
+
334
+ ```typescript
335
+ const dataUrl = await convertHeic(heicBlob, { to: 'png', output: 'dataUrl' });
336
+ // string: "data:image/png;base64,โ€ฆ" โ€” ready for <img src>
337
+
338
+ const bytes = await convertHeic(heicBlob, { to: 'webp', output: 'arrayBuffer' });
339
+ // ArrayBuffer โ€” for uploads (fetch body), WebCodecs, custom pipelines
340
+
341
+ const blob = await convertHeic(heicBlob, { to: 'jpeg' }); // default Blob
342
+ ```
343
+
344
+ `output` applies to `convertMany` and `convertManyInWorker` too (in the worker case the chosen representation travels back through the worker protocol โ€” `arrayBuffer` results arrive as a structured-clone copy, never a base64 round-trip; a custom worker script can additionally transfer the buffer with `postMessage(msg, [msg.blob])`). Invalid values reject with `invalid_input`.
345
+
346
+ ### 11. Canceling a Conversion (AbortSignal)
347
+
348
+ Pass an `AbortSignal` to cancel pending work. Cancellation is checked at stage boundaries (after input read, before/after decode, before encode), so a queued or in-flight conversion stops promptly and rejects with the `aborted` code:
349
+
350
+ ```typescript
351
+ import { convertHeic, convertHeicInWorker } from '@keeratita/heic-converter';
352
+
353
+ const controller = new AbortController();
354
+ setTimeout(() => controller.abort(), 2000); // give up after 2s
355
+
356
+ await convertHeic(heicBlob, { signal: controller.signal }); // rejects with code 'aborted'
357
+ await convertMany(files, { signal: controller.signal }); // aborts remaining items
358
+ await convertHeicInWorker(heicBlob, { workerUrl, signal: controller.signal }); // terminates the worker
359
+ ```
360
+
361
+ > [!NOTE]
362
+ > - Worker-based conversions abort **immediately**: the worker is terminated, since it runs off the main thread.
363
+ > - The synchronous WASM `decode()` on the main thread cannot be preempted mid-call โ€” cancellation takes effect at the next boundary after it finishes.
364
+ > - In `convertMany`, an aborted batch rejects with `aborted` (cancellation is the outcome you asked for, and it wins over item-failure aggregation). With `continueOnError`, already-started items record `aborted` errors per item.
365
+
366
+ ### 12. Batch Conversion
215
367
 
216
368
  Convert many images at once with bounded concurrency (default `4`). Results are returned in input order; if any conversion fails, the promise rejects as soon as the failure is known with an error that identifies the failing item:
217
369
 
218
370
  ```typescript
219
371
  import { convertMany } from '@keeratita/heic-converter';
220
372
 
373
+ declare const heicFiles: File[]; // your HEIC files/blobs
374
+
221
375
  const blobs = await convertMany(heicFiles, {
222
376
  to: 'png',
223
377
  concurrency: 3,
@@ -227,7 +381,40 @@ const blobs = await convertMany(heicFiles, {
227
381
  });
228
382
  ```
229
383
 
230
- ### 9. Web Worker Conversion
384
+ Batch semantics worth knowing:
385
+
386
+ - The promise rejects with the **first failure in time** (not the lowest index). All items are still allowed to finish; decoders of in-flight items are released when they settle.
387
+ - The rejection is a `batch_item_failed` error carrying structured fields: `itemIndex` (0-based index of the first failing item), `itemTotal`, `failedCount` (total items that failed), and `cause` (the underlying error). The message names the failing item and, when several items failed, summarizes the count and the other errors.
388
+ - `onProgress` only fires for items that succeed; a failing item never reports 100%.
389
+ - `concurrency` must be a positive integer; the default is `4`.
390
+
391
+ **Keeping going after failures** โ€” with `continueOnError: true` the batch never rejects for item failures. Every item runs, and you get a result entry per item in input order:
392
+
393
+ ```typescript
394
+ const results = await convertMany(files, { to: 'png', continueOnError: true });
395
+ // Array<{ index: number; ok: true; result: Blob } | { index: number; ok: false; error: Error }>
396
+
397
+ const failures = results.filter((r) => !r.ok);
398
+ if (failures.length > 0) {
399
+ console.warn(`${failures.length} file(s) failed`, failures.map((f) => f.index));
400
+ }
401
+ ```
402
+
403
+ Up-front validation mistakes (bad format, bad `concurrency`, โ€ฆ) still reject normally โ€” a typo is a caller bug, not an item failure.
404
+
405
+ **Pooling decoders for big batches** โ€” by default each item gets its own decoder instance (created, WASM-initialized, freed per item). For batches much larger than the concurrency, `reuseDecoders: true` hands each batch runner one pooled instance that is reused across items โ€” the WASM module load is amortized while the exclusive-use invariant holds (an instance never decodes two things at once):
406
+
407
+ ```typescript
408
+ const blobs = await convertMany(thousandFiles, {
409
+ to: 'webp',
410
+ concurrency: 4,
411
+ reuseDecoders: true, // one LibheifDecoder per runner instead of one per item
412
+ });
413
+ ```
414
+
415
+ Ignored when you inject your own `decoder` (yours is used for all items, per the injection contract).
416
+
417
+ ### 13. Web Worker Conversion
231
418
 
232
419
  Run the conversion inside a Web Worker so the main thread stays responsive. Create a worker script that uses this library:
233
420
 
@@ -242,7 +429,11 @@ self.onmessage = async (event) => {
242
429
  ...options,
243
430
  onProgress: (percent) => self.postMessage({ type: 'progress', percent }),
244
431
  });
245
- self.postMessage({ type: 'result', ok: true, blob });
432
+ // With output: 'arrayBuffer' the payload is zero-copy via a transfer list.
433
+ self.postMessage(
434
+ { type: 'result', ok: true, blob },
435
+ blob instanceof ArrayBuffer ? [blob] : []
436
+ );
246
437
  } catch (error) {
247
438
  self.postMessage({ type: 'result', ok: false, error: error?.stack ?? error?.message ?? String(error) });
248
439
  }
@@ -254,6 +445,8 @@ Then convert from the main thread:
254
445
  ```typescript
255
446
  import { convertHeicInWorker } from '@keeratita/heic-converter';
256
447
 
448
+ declare const heicBlob: Blob; // your HEIC file/blob
449
+
257
450
  const jpegBlob = await convertHeicInWorker(heicBlob, {
258
451
  workerUrl: new URL('./converter.worker.js', import.meta.url),
259
452
  workerType: 'module',
@@ -266,11 +459,28 @@ const jpegBlob = await convertHeicInWorker(heicBlob, {
266
459
  > [!NOTE]
267
460
  > - Use `workerType: 'module'` when the worker script uses ES module imports (as in the example above). The default is `'classic'`, which requires the script to be pre-bundled (e.g. by Vite or webpack) โ€” static ES imports are not supported in classic workers.
268
461
  > - `workerUrl` should be a compile-time constant; the script runs with the page's privileges.
269
- > - Only `progress` and `result` messages are understood; any other message type is ignored.
270
- > - `decoder`, `onProgress`, and `workerUrl` are not structured-cloneable, so they are not sent to the worker. Progress is forwarded through `{ type: 'progress', percent }` messages instead.
271
- > - `timeoutMs` (default `60000`) bounds how long the promise waits for a result; set `0` to disable.
462
+ > - Only `progress` and `result` messages are understood; any other message type is ignored โ€” but ignored messages are counted and surfaced in timeout diagnostics (see below).
463
+ > - `decoder` cannot cross the worker boundary: passing it rejects immediately with an `invalid_input` error (it is also absent from `WorkerConvertOptions` at compile time). `onProgress` and `workerUrl` are stripped from the posted message; progress is forwarded through `{ type: 'progress', percent }` messages instead.
464
+ > - Concurrent calls are bounded per worker script: calls beyond `maxConcurrentWorkers` queue and start as slots free. Must be a positive integer (`invalid_concurrency` otherwise). Default: `navigator.hardwareConcurrency` clamped to 1โ€“8 (4 when unavailable).
465
+ > - `timeoutMs` (default `60000`) bounds how long the promise waits for a result; must be a finite number โ‰ฅ 0, `0` disables. The deadline starts when the call is made, so time spent queued behind `maxConcurrentWorkers` counts toward it โ€” raise `timeoutMs` or `maxConcurrentWorkers` for very large batches. A timeout rejects with a `worker_timeout` error whose message includes diagnostics โ€” how many progress messages arrived, the last percent, and any unrecognized message type (the usual sign the worker script doesn't implement the protocol).
272
466
  > - This helper is **browser-only**: it rejects in Node.js, where there is no global `Worker`.
273
467
 
468
+ **Batch inside workers** โ€” `convertManyInWorker` mirrors `convertMany` (input order, `batch_item_failed` aggregation or `continueOnError` per-item results) while each item runs in a worker; real concurrency is bounded by `maxConcurrentWorkers`:
469
+
470
+ ```typescript
471
+ import { convertManyInWorker } from '@keeratita/heic-converter';
472
+
473
+ const blobs = await convertManyInWorker(files, {
474
+ workerUrl: new URL('./converter.worker.js', import.meta.url),
475
+ workerType: 'module',
476
+ to: 'webp',
477
+ maxConcurrentWorkers: 3,
478
+ onProgress: (index, percent) => console.log(`Image ${index}: ${Math.round(percent)}%`),
479
+ });
480
+ ```
481
+
482
+ Every option of `convertHeicInWorker` applies (`output`, `signal`, `crop`, `preserveExif`, `timeoutMs`, โ€ฆ); `onProgress` receives the item `index` in addition to the percent, and a mid-batch abort terminates the running workers.
483
+
274
484
  ---
275
485
 
276
486
  ## ๐Ÿ”’ Content Security Policy (CSP)
@@ -284,6 +494,14 @@ Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval
284
494
  > [!NOTE]
285
495
  > `'wasm-unsafe-eval'` is a CSP Level 3 directive that allows compiling and executing WebAssembly modules without opening the security risks of general JavaScript `'unsafe-eval'`.
286
496
 
497
+ Two additions to that header are easy to miss:
498
+
499
+ - **Serving the WASM from another origin** (`locateFile` pointing at a CDN) needs that origin in `connect-src` โ€” the glue downloads the binary with `fetch()`/`XMLHttpRequest`, so `connect-src 'self'` blocks it and you get `decoder_init_failed` blaming the binary itself:
500
+ ```http
501
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https://cdn.example.com; img-src 'self' blob: data:;
502
+ ```
503
+ - **The worker helpers** are real `Worker` scripts, so they follow `worker-src` (falling back to `script-src`). With `workerUrl` on your own origin, `worker-src 'self'` is enough; if your bundler inlines the worker as a blob URL, use `worker-src 'self' blob:`. A CSP-blocked worker surfaces as the generic `worker_failed`.
504
+
287
505
  ---
288
506
 
289
507
  ## ๐Ÿ“– API Reference
@@ -294,36 +512,55 @@ Converts a HEIC image file to a standard web format.
294
512
 
295
513
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
296
514
  - **`options`**: (optional) `ConvertOptions`
297
- - `to`: `'jpeg' | 'jpg' | 'png' | 'webp' | 'svg'` (Default: `'jpeg'`)
298
- - `quality`: `number` (0.0 to 1.0, applicable to JPEG and WebP. Default: `0.92`)
515
+ - `to`: `'jpeg' | 'jpg' | 'png' | 'webp' | 'avif' | 'svg'` (Default: `'jpeg'`; `avif` rejects with `format_unsupported` where the canvas cannot encode it)
516
+ - `quality`: `number` (0.0 to 1.0, applicable to JPEG, WebP, and AVIF. Default: `0.92`)
299
517
  - `decoder`: `IHeicDecoder` (Inject custom decoder instance)
300
518
  - `onProgress`: `(percent: number) => void` (Optional callback, receives progress percentage from `0` to `100` during decoding)
301
519
  - `maxWidth`: `number` (Downscale to fit within this width, preserving aspect ratio. Never upscales)
302
520
  - `maxHeight`: `number` (Downscale to fit within this height, preserving aspect ratio. Never upscales)
303
521
  - `scale`: `number` (Uniform scale factor, e.g. `0.5` halves the image. Takes precedence over `maxWidth`/`maxHeight`)
304
- - **Returns**: `Promise<Blob>`
522
+ - `applyOrientation`: `boolean` (Rotate/flip the output to match the source's EXIF orientation. `irot`/`imir` transforms are already applied by the decoder and never stacked. Default: `true`)
523
+ - `crop`: `{ x?, y?, width, height }` (Cut a rectangle in post-orientation display pixels before resize. Default: none)
524
+ - `preserveExif`: `boolean` (Re-inject the source EXIF block into JPEG (APP1) / PNG (`eXIf`) output. Metadata may contain GPS โ€” opt-in. Default: `false`. Fail-safe: unparsable encoder output, a malformed block, or an oversized payload returns the image without metadata rather than failing โ€” as does a missing `dist/exif-*.mjs` chunk, which logs a one-time warning so a deployment problem is distinguishable from a file that simply had no Exif item)
525
+ - `output`: `'blob' | 'dataUrl' | 'arrayBuffer'` (Result representation. Default: `'blob'`; see [Usage section 10](#10-output-shapes-blob-data-url-arraybuffer))
526
+ - `signal`: `AbortSignal` (Cancel pending work; rejects with `aborted`. Default: none)
527
+ - **Returns**: `Promise<Blob>` โ€” or `Promise<string>` / `Promise<ArrayBuffer>` with the typed `output` overloads
305
528
 
306
529
  ### `convertMany(inputs, options?)`
307
530
 
308
- Converts multiple HEIC images with bounded concurrency. Results are returned in input order; rejects as soon as a conversion fails, with an error identifying the failing item index.
531
+ Converts multiple HEIC images with bounded concurrency. Results are returned in input order; rejects with the first failure in time โ€” a `batch_item_failed` error whose `message` names the failing item and whose fields describe the batch (`itemIndex` 0-based, `itemTotal`, `failedCount`, `cause`). With `continueOnError` the promise fulfills with per-item results instead.
309
532
 
310
533
  - **`inputs`**: `Array<Blob | File | ArrayBuffer | Uint8Array>`
311
534
  - **`options`**: (optional) `ConvertManyOptions` โ€” same as `ConvertOptions`, except `onProgress` uses the batch signature below; plus:
312
535
  - `concurrency`: `number` (Maximum concurrent conversions. Default: `4`)
313
- - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback)
314
- - `decoder`: `IHeicDecoder` (Optional. When provided, the same instance is shared by all concurrent conversions and must be safe for concurrent `decode()` calls)
315
- - **Returns**: `Promise<Blob[]>`
536
+ - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback; fires only for successful items)
537
+ - `decoder`: `IHeicDecoder` (Optional. When provided, the same instance is shared by all concurrent conversions and must be safe for concurrent `decode()` calls; the library never frees an injected decoder; `reuseDecoders` is ignored)
538
+ - `continueOnError`: `boolean` (Return per-item `{ index, ok, result | error }` entries instead of rejecting on the first failure. Default: `false`)
539
+ - `reuseDecoders`: `boolean` (Hand each batch runner one pooled `LibheifDecoder` reused across items โ€” amortizes WASM init for large batches while keeping exclusive use. Default: `false`)
540
+ - **Returns**: `Promise<Blob[]>` โ€” or `Promise<string[]>` / `Promise<ArrayBuffer[]>` with the typed `output` overloads; `Promise<ConvertItemResult[]>` with `continueOnError` (also typed by `output`)
316
541
 
317
542
  ### `convertHeicInWorker(input, options)`
318
543
 
319
- Converts a HEIC image inside a Web Worker. The worker script must implement the message protocol shown in [Usage section 9](#9-web-worker-conversion). Browser-only; rejects in Node.js.
544
+ Converts a HEIC image inside a Web Worker. The worker script must implement the message protocol shown in [Usage section 13](#13-web-worker-conversion). Browser-only; rejects in Node.js.
320
545
 
321
546
  - **`input`**: `Blob | File | ArrayBuffer | Uint8Array`
322
- - **`options`**: `WorkerConvertOptions` โ€” same as `ConvertOptions`, plus:
547
+ - **`options`**: `WorkerConvertOptions` โ€” same as `ConvertOptions` but without `decoder` (cannot be structured-cloned; passing one rejects with `invalid_input`), plus:
323
548
  - `workerUrl`: `string | URL` (URL of the worker script; should be a compile-time constant)
324
549
  - `workerType`: `'classic' | 'module'` (Worker script type. Default: `'classic'`; use `'module'` for scripts with ES imports)
325
- - `timeoutMs`: `number` (Maximum wait for the result in milliseconds. Default: `60000`; `0` disables)
326
- - **Returns**: `Promise<Blob>`
550
+ - `timeoutMs`: `number` (Maximum wait for the result in milliseconds, including time spent queued. Finite, โ‰ฅ 0; `0` disables. Default: `60000`)
551
+ - `maxConcurrentWorkers`: `number` (Positive integer. Concurrent workers per `workerUrl` + type; extra calls queue. Default: `navigator.hardwareConcurrency` clamped to 1โ€“8)
552
+ - **Returns**: `Promise<Blob>` โ€” or `Promise<string>` / `Promise<ArrayBuffer>` with the typed `output` overloads. Aborting via `signal` terminates the worker immediately.
553
+
554
+ ### `convertManyInWorker(inputs, options)`
555
+
556
+ Converts multiple HEIC images, each inside a Web Worker, with `convertMany` semantics (input order, `batch_item_failed` aggregation, `continueOnError` per-item results). Browser-only; rejects in Node.js.
557
+
558
+ - **`inputs`**: `Array<Blob | File | ArrayBuffer | Uint8Array>`
559
+ - **`options`**: `WorkerBatchOptions` โ€” same as `convertHeicInWorker`'s `WorkerConvertOptions` (including `output`, `crop`, `preserveExif`, `signal`), except `onProgress` uses the batch signature below; plus:
560
+ - `onProgress`: `(index: number, percent: number) => void` (Per-item progress callback)
561
+ - `maxConcurrentWorkers`: `number` (Positive integer. Real concurrency โ€” each in-flight item occupies one semaphore slot. Default: `navigator.hardwareConcurrency` clamped to 1โ€“8)
562
+ - `continueOnError`: `boolean` (Per-item `{ index, ok, result | error }` entries instead of rejecting on the first failure. Default: `false`)
563
+ - **Returns**: `Promise<Blob[]>` โ€” typed overloads mirror `convertMany` (`output` / `continueOnError` combinations)
327
564
 
328
565
  ### `LibheifDecoder(options?)`
329
566
 
@@ -331,11 +568,57 @@ The default WASM-based implementation of `IHeicDecoder`.
331
568
 
332
569
  - **`options`**: (optional) `LibheifDecoderOptions`
333
570
  - `locateFile`: `(path: string, prefix: string) => string`
334
- - `wasmBinary`: `ArrayBuffer`
571
+ - `wasmBinary`: `ArrayBuffer | ArrayBufferView` (e.g. a Node.js `Buffer` from `fs.readFileSync`)
572
+ - `moduleOverrides`: `Record<string, unknown>` (Advanced: merged into the Emscripten module arguments, e.g. `instantiateWasm`)
335
573
  - **Methods**:
336
574
  - `initialize(): Promise<void>`: Loads and initializes the WASM wrapper. Safe to call concurrently; the module loads at most once per instance.
337
- - `decode(data: Uint8Array, onProgress?: (percent: number) => void): Promise<DecodedImage>`: Decodes the HEIC bytes to raw RGBA, with optional progress callback. Returns an independent copy of the pixel data โ€” safe to use after `free()`.
338
- - `free(): void`: Releases allocated WebAssembly heap memory. Idempotent.
575
+ - `decode(data: Uint8Array, onProgress?: (percent: number) => void): Promise<DecodedImage>`: Decodes the HEIC bytes to raw RGBA, with optional progress callback (normalized to finite `0`โ€“`100`). Returns JS-owned pixel data โ€” safe to use after `free()`. The result carries an optional `orientation` field (`2`โ€“`8`, EXIF semantics) when the stored pixels need a rotation for display that the decoder did not itself apply (EXIF-tag-only files); absent or `1` means the pixels are upright. Safe to call after `free()`: the instance transparently re-initializes.
576
+ - `free(): void`: Releases the WASM module and decoder instance. Idempotent, and safe to call while `initialize()` is in flight (the in-flight load is discarded).
577
+
578
+ ### Error handling
579
+
580
+ All errors thrown by this library are `HeicConverterError` instances (`extends Error`) with a machine-readable `code`, and โ€” where relevant โ€” a `cause` and batch fields:
581
+
582
+ | `code` | Thrown by | Meaning |
583
+ | --- | --- | --- |
584
+ | `invalid_input` | all conversion APIs | Unsupported input type; non-boolean `applyOrientation`/`continueOnError`/`reuseDecoders`/`preserveExif`, unknown `output`, malformed `signal`; or worker helper called with a `decoder` |
585
+ | `invalid_quality` | all conversion APIs | `quality` outside `0.0`โ€“`1.0` or not a finite number |
586
+ | `invalid_resize` | all conversion APIs | `scale`/`maxWidth`/`maxHeight` not positive finite numbers, or target size exceeds 16384 px |
587
+ | `invalid_format` | all conversion APIs | Unknown `to` value |
588
+ | `invalid_crop` | all conversion APIs | `crop` with non-integer/non-positive dimensions, negative offsets, or a rectangle beyond the image's display size |
589
+ | `invalid_concurrency` | all conversion APIs | `concurrency` (batch) or `maxConcurrentWorkers` (worker APIs) not a positive integer |
590
+ | `decoder_init_failed` | `convertHeic`, `convertMany` | WASM module could not be loaded (missing asset, CSP block) |
591
+ | `decode_failed` | `convertHeic`, `convertMany` | Invalid/corrupt HEIC bytes |
592
+ | `unsupported_environment` | `convertHeic`, `convertMany` | No canvas APIs (e.g. Node.js); the worker APIs reject with `worker_unsupported` there instead. Decode raw RGBA via `LibheifDecoder` |
593
+ | `render_encode_failed` | `convertHeic`, `convertMany` | Canvas render/encode failure (bad dimensions, `toBlob` returned null) |
594
+ | `format_unsupported` | `convertHeic`, `convertMany` | The canvas cannot encode the requested format (e.g. `avif` on Safari) |
595
+ | `aborted` | all conversion APIs | The `AbortSignal` was aborted before the work completed |
596
+ | `progress_callback_failed` | all conversion APIs | The host `onProgress` callback threw; message attributes the failure |
597
+ | `worker_unsupported` | `convertHeicInWorker`, `convertManyInWorker` | No global `Worker` (e.g. Node.js) |
598
+ | `worker_create_failed` | `convertHeicInWorker`, `convertManyInWorker` | `new Worker(...)` threw (wrong URL, MIME type) |
599
+ | `worker_post_failed` | `convertHeicInWorker`, `convertManyInWorker` | `postMessage` threw (non-cloneable option) |
600
+ | `worker_timeout` | `convertHeicInWorker`, `convertManyInWorker` | No result within `timeoutMs`; message includes progress/protocol diagnostics |
601
+ | `worker_failed` | `convertHeicInWorker`, `convertManyInWorker` | Worker reported `{ type: 'result', ok: false, error }` |
602
+ | `worker_load_failed` | `convertHeicInWorker`, `convertManyInWorker` | The lazily imported worker chunk (`dist/worker-*.mjs`) could not be fetched โ€” deploy every file in `dist/` together, check your bundler emitted the chunk, and inspect `error.cause`; use `convertHeic` to convert in the current thread |
603
+ | `batch_item_failed` | `convertMany`, `convertManyInWorker` | One or more items failed; see `itemIndex`/`itemTotal`/`failedCount`/`cause` |
604
+
605
+ Option validation for **all four** conversion APIs runs on the main thread before any worker is created, so a bad option keeps its own code. One ordering note: `convertHeicInWorker`/`convertManyInWorker` resolve the worker chunk before the implementation runs, so when that chunk itself cannot be fetched the rejection is `worker_load_failed` regardless of other option problems โ€” the original failure stays on `cause`. Stage errors raised *inside* a worker (decode, render, unsupported format) come back stringified under `worker_failed` โ€” the numeric `code` does not survive the worker boundary; match on the message instead.
606
+
607
+ ```typescript
608
+ import { convertHeic, type HeicConverterErrorCode } from '@keeratita/heic-converter';
609
+
610
+ try {
611
+ await convertHeic(input, { to: 'png' });
612
+ } catch (error) {
613
+ const code: HeicConverterErrorCode | undefined =
614
+ error && typeof error === 'object' && 'code' in error
615
+ ? (error as { code?: HeicConverterErrorCode }).code
616
+ : undefined;
617
+ if (code === 'unsupported_environment') {
618
+ // e.g. Node.js: decode raw RGBA with LibheifDecoder and encode externally
619
+ }
620
+ }
621
+ ```
339
622
 
340
623
  ### `freeSharedDecoder()`
341
624
 
@@ -362,7 +645,14 @@ To compile the underlying [`libheif`](https://github.com/strukturag/libheif) and
362
645
  npm run build:wasm
363
646
  ```
364
647
 
365
- See [WASM_DEPENDENCIES.md](WASM_DEPENDENCIES.md) for the pinned upstream library versions.
648
+ That rewrites **both** generated artifacts together โ€” `src/wasm/public/heic-decoder.wasm` and `src/wasm/wrapper/heic-decoder.js` come from a single `emcc` invocation, so never replace one alone โ€” and you must then refresh the pinned hashes or CI's verification step fails:
649
+
650
+ ```bash
651
+ npm run wasm:hashes # regenerates build-scripts/wasm-artifacts.json โ€” commit it with the artifacts
652
+ npm run verify:wasm # confirms the committed pair matches, and scans the glue for eval/new Function
653
+ ```
654
+
655
+ Never hand-edit either generated file. See [WASM_DEPENDENCIES.md](WASM_DEPENDENCIES.md) for the pinned upstream library versions.
366
656
 
367
657
  ### Build JS & TS Typings
368
658
 
@@ -374,15 +664,20 @@ npm run build
374
664
 
375
665
  ### Run Unit Tests
376
666
 
667
+ Build first: the suites that decode real HEIC files gate on `dist/heic-decoder.wasm` (a stale `dist/` is detected and reported rather than silently passing).
668
+
377
669
  ```bash
670
+ npm run build
378
671
  npm run test
672
+ npm run typecheck # tsc over src/ *and* test/ โ€” tests are excluded from tsconfig.json
379
673
  ```
380
674
 
381
675
  ### Run Browser E2E Tests
382
676
 
383
- Real browser conversions against the CSP sandbox and the GitHub Pages demo (`docs/`):
677
+ Real browser conversions against the CSP sandbox and the GitHub Pages demo (`docs/`). Build first โ€” both servers serve `dist/`:
384
678
 
385
679
  ```bash
680
+ npm run build
386
681
  npx playwright install chromium # one-time
387
682
  npm run test:e2e
388
683
  ```
@@ -418,6 +713,37 @@ This script will automatically run the linter, build the TS library, run the uni
418
713
 
419
714
  ---
420
715
 
716
+ ## ๐Ÿ›ก๏ธ Supply Chain & Trust
717
+
718
+ | Property | Status |
719
+ | -------- | ------ |
720
+ | Runtime dependencies | **none** โ€” the package ships no `dependencies` at all |
721
+ | Provenance | SLSA provenance attestation, from OIDC trusted publishing in `.github/workflows/publish.yml` |
722
+ | Install-time code | two dev-tree packages โ€” `esbuild` (links its platform binary) and `fsevents` (macOS-only native addon) โ€” pinned in an allowlist that CI enforces (`npm run check:scripts`) |
723
+ | `eval()` / `new Function()` | never generated (`-s DYNAMIC_EXECUTION=0`); CI scans the emitted glue for both |
724
+ | Decoder artifacts | committed WASM + glue pinned to SHA-256 and re-verified on every CI run (`npm run verify:wasm`) |
725
+ | Publish path | tag pushes only; the job asserts the attestation landed (`npm run verify:provenance`) |
726
+
727
+ After installing, you can verify the attestation yourself in your own project:
728
+
729
+ ```bash
730
+ npm audit signatures
731
+ ```
732
+
733
+ ### Scanner signals you will see
734
+
735
+ Automated scanners report three signals on this package. All three are expected; they are documented here rather than engineered around:
736
+
737
+ | Signal | What it actually is |
738
+ | ------ | ------------------- |
739
+ | **Network access** | The generated Emscripten glue calls `fetch()` / `XMLHttpRequest` to download `dist/heic-decoder.wasm` (~800 KB). That download is how a browser gets the decoder. Nothing else in the package touches the network โ€” `src/` contains no `fetch`, `XMLHttpRequest`, or socket code. |
740
+ | **URL strings** | The literal `http://www.w3.org/2000/svg`, the XML namespace on the root element of SVG output. It is a format identifier and is never fetched. |
741
+ | **Minified code** | `dist/` is Terser-minified deliberately (~6% smaller gzipped, more on the glue). The published bytes come from the tagged commit through CI, with provenance attached. |
742
+
743
+ If one of these ever becomes actually exploitable rather than merely noisy, that is a bug โ€” see [SECURITY.md](SECURITY.md).
744
+
745
+ ---
746
+
421
747
  ## ๐Ÿ“„ License
422
748
 
423
749
  MIT ยฉ Keerati Tansawatcharoen