utilful 3.3.0 → 3.4.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/README.md +51 -1
- package/dist/image/index.d.mts +32 -0
- package/dist/image/index.mjs +69 -0
- package/dist/image/size.mjs +11 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A collection of TypeScript utilities that I use across my projects.
|
|
|
11
11
|
- [CSV](#csv)
|
|
12
12
|
- [Defu](#defu)
|
|
13
13
|
- [Emitter](#emitter)
|
|
14
|
+
- [Image](#image)
|
|
14
15
|
- [JSON](#json)
|
|
15
16
|
- [Module](#module)
|
|
16
17
|
- [Object](#object)
|
|
@@ -38,7 +39,7 @@ import { defu } from 'utilful' // Everything
|
|
|
38
39
|
import { joinURL } from 'utilful/path' // Just the path helpers
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Two modules are the exception and live on their subpaths alone. `cli` is Node-only (22.13 or later) and ships as `utilful/cli` and `utilful/cli/testing`. `image` is browser-only and ships as `utilful/image`.
|
|
42
43
|
|
|
43
44
|
## API
|
|
44
45
|
|
|
@@ -428,6 +429,55 @@ emitter.on('foo', onFoo) // Listen
|
|
|
428
429
|
emitter.off('foo', onFoo) // Unlisten
|
|
429
430
|
```
|
|
430
431
|
|
|
432
|
+
### Image
|
|
433
|
+
|
|
434
|
+
#### `toReducedBlob`
|
|
435
|
+
|
|
436
|
+
Downscales an image blob so its longer side fits `maxDimension` and re-encodes it. Resizing happens in `createImageBitmap` with `resizeQuality: 'high'`, so the canvas only ever holds the reduced image.
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
type ReducedBlobType = 'image/jpeg' | 'image/png' | 'image/webp'
|
|
440
|
+
|
|
441
|
+
interface ReducedBlobOptions {
|
|
442
|
+
/**
|
|
443
|
+
* Maximum width or height in pixels. Smaller images are never upscaled.
|
|
444
|
+
*/
|
|
445
|
+
maxDimension?: number
|
|
446
|
+
/**
|
|
447
|
+
* MIME type of the output. Defaults to the source type if it is one of these, otherwise `image/jpeg`.
|
|
448
|
+
*/
|
|
449
|
+
type?: ReducedBlobType
|
|
450
|
+
/**
|
|
451
|
+
* Encoder quality between 0 and 1, applied to JPEG and WebP.
|
|
452
|
+
* @default 0.85
|
|
453
|
+
*/
|
|
454
|
+
quality?: number
|
|
455
|
+
/**
|
|
456
|
+
* Whether to re-encode an image that already has the right size and type, which drops EXIF data such as the location.
|
|
457
|
+
* @default false
|
|
458
|
+
*/
|
|
459
|
+
stripMetadata?: boolean
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
declare function toReducedBlob(blob: Blob, options?: ReducedBlobOptions): Promise<Blob>
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
The original blob comes back untouched when the image already fits and the output type matches the source type. Anything else is re-encoded:
|
|
466
|
+
|
|
467
|
+
- Re-encoding drops EXIF data such as the location. Set `stripMetadata` to re-encode an image that would otherwise be returned as it is.
|
|
468
|
+
- Any other source type, like HEIC or GIF, becomes JPEG. Animation is lost and transparent pixels turn black – pass `type: 'image/png'` to keep transparency.
|
|
469
|
+
- The function throws if the browser cannot encode the output type, rather than silently returning PNG. Safari cannot encode WebP, which includes a WebP source without an explicit `type`.
|
|
470
|
+
- Re-encoding an image that is not resized draws it at full size. Safari on iOS 17 and earlier limits a canvas to 16.7 megapixels and throws beyond that, so pass `maxDimension` along for camera photos.
|
|
471
|
+
|
|
472
|
+
**Example:**
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { toReducedBlob } from 'utilful/image'
|
|
476
|
+
|
|
477
|
+
const reduced = await toReducedBlob(file, { maxDimension: 2048 })
|
|
478
|
+
const webp = await toReducedBlob(file, { maxDimension: 1024, type: 'image/webp', quality: 0.8 })
|
|
479
|
+
```
|
|
480
|
+
|
|
431
481
|
### JSON
|
|
432
482
|
|
|
433
483
|
#### `tryParseJSON`
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/image/index.d.ts
|
|
2
|
+
type ReducedBlobType = typeof REDUCED_BLOB_TYPES[number];
|
|
3
|
+
interface ReducedBlobOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Maximum width or height in pixels. Smaller images are never upscaled.
|
|
6
|
+
*/
|
|
7
|
+
maxDimension?: number;
|
|
8
|
+
/**
|
|
9
|
+
* MIME type of the output. Defaults to the source type if it is one of these, otherwise `image/jpeg`.
|
|
10
|
+
*/
|
|
11
|
+
type?: ReducedBlobType;
|
|
12
|
+
/**
|
|
13
|
+
* Encoder quality between 0 and 1, applied to JPEG and WebP.
|
|
14
|
+
* @default 0.85
|
|
15
|
+
*/
|
|
16
|
+
quality?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Whether to re-encode an image that already has the right size and type, which drops EXIF data such as the location.
|
|
19
|
+
* @default false
|
|
20
|
+
*/
|
|
21
|
+
stripMetadata?: boolean;
|
|
22
|
+
}
|
|
23
|
+
declare const REDUCED_BLOB_TYPES: readonly ["image/jpeg", "image/png", "image/webp"];
|
|
24
|
+
/**
|
|
25
|
+
* Downscales an image blob to fit the maximum dimension and re-encodes it, preserving the aspect ratio.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Returns the original blob if nothing needs to change. Browser-only.
|
|
29
|
+
*/
|
|
30
|
+
declare function toReducedBlob(blob: Blob, options?: ReducedBlobOptions): Promise<Blob>;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { ReducedBlobOptions, ReducedBlobType, toReducedBlob };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getReducedSize } from "./size.mjs";
|
|
2
|
+
//#region src/image/index.ts
|
|
3
|
+
const REDUCED_BLOB_TYPES = [
|
|
4
|
+
"image/jpeg",
|
|
5
|
+
"image/png",
|
|
6
|
+
"image/webp"
|
|
7
|
+
];
|
|
8
|
+
const DEFAULT_QUALITY = .85;
|
|
9
|
+
/**
|
|
10
|
+
* Downscales an image blob to fit the maximum dimension and re-encodes it, preserving the aspect ratio.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Returns the original blob if nothing needs to change. Browser-only.
|
|
14
|
+
*/
|
|
15
|
+
async function toReducedBlob(blob, options = {}) {
|
|
16
|
+
const { maxDimension, type, quality = DEFAULT_QUALITY, stripMetadata = false } = options;
|
|
17
|
+
if (!maxDimension && !type && !stripMetadata) return blob;
|
|
18
|
+
const outputType = type ?? (isReducedBlobType(blob.type) ? blob.type : "image/jpeg");
|
|
19
|
+
const source = await createImageBitmap(blob);
|
|
20
|
+
let resized;
|
|
21
|
+
try {
|
|
22
|
+
const size = getReducedSize(source, maxDimension);
|
|
23
|
+
const isResized = size.width !== source.width || size.height !== source.height;
|
|
24
|
+
if (!isResized && outputType === blob.type && !stripMetadata) return blob;
|
|
25
|
+
if (isResized) resized = await createImageBitmap(source, {
|
|
26
|
+
resizeWidth: size.width,
|
|
27
|
+
resizeHeight: size.height,
|
|
28
|
+
resizeQuality: "high"
|
|
29
|
+
});
|
|
30
|
+
return await encodeBitmap(resized ?? source, outputType, quality);
|
|
31
|
+
} finally {
|
|
32
|
+
source.close();
|
|
33
|
+
resized?.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isReducedBlobType(type) {
|
|
37
|
+
return REDUCED_BLOB_TYPES.includes(type);
|
|
38
|
+
}
|
|
39
|
+
async function encodeBitmap(bitmap, type, quality) {
|
|
40
|
+
let blob;
|
|
41
|
+
if (typeof OffscreenCanvas !== "undefined") {
|
|
42
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
43
|
+
drawBitmap(canvas.getContext("2d"), bitmap);
|
|
44
|
+
blob = await canvas.convertToBlob({
|
|
45
|
+
type,
|
|
46
|
+
quality
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
const canvas = document.createElement("canvas");
|
|
50
|
+
canvas.width = bitmap.width;
|
|
51
|
+
canvas.height = bitmap.height;
|
|
52
|
+
try {
|
|
53
|
+
drawBitmap(canvas.getContext("2d"), bitmap);
|
|
54
|
+
blob = await new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
55
|
+
} finally {
|
|
56
|
+
canvas.width = 0;
|
|
57
|
+
canvas.height = 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!blob) throw new Error("Failed to encode the image");
|
|
61
|
+
if (blob.type !== type) throw new Error(`This browser cannot encode ${type}`);
|
|
62
|
+
return blob;
|
|
63
|
+
}
|
|
64
|
+
function drawBitmap(context, bitmap) {
|
|
65
|
+
if (!context) throw new Error("Failed to get a 2D canvas context");
|
|
66
|
+
context.drawImage(bitmap, 0, 0);
|
|
67
|
+
}
|
|
68
|
+
//#endregion
|
|
69
|
+
export { toReducedBlob };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region src/image/size.ts
|
|
2
|
+
function getReducedSize(size, maxDimension) {
|
|
3
|
+
if (!maxDimension || Math.max(size.width, size.height) <= maxDimension) return size;
|
|
4
|
+
const ratio = maxDimension / Math.max(size.width, size.height);
|
|
5
|
+
return {
|
|
6
|
+
width: Math.max(1, Math.round(size.width * ratio)),
|
|
7
|
+
height: Math.max(1, Math.round(size.height * ratio))
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { getReducedSize };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "utilful",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.4.0",
|
|
5
5
|
"packageManager": "pnpm@11.25.0",
|
|
6
6
|
"description": "A collection of TypeScript utilities",
|
|
7
7
|
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"csv",
|
|
20
20
|
"defu",
|
|
21
21
|
"emitter",
|
|
22
|
+
"image",
|
|
22
23
|
"result",
|
|
23
24
|
"typescript",
|
|
24
25
|
"url",
|
|
@@ -54,6 +55,10 @@
|
|
|
54
55
|
"types": "./dist/emitter.d.mts",
|
|
55
56
|
"default": "./dist/emitter.mjs"
|
|
56
57
|
},
|
|
58
|
+
"./image": {
|
|
59
|
+
"types": "./dist/image/index.d.mts",
|
|
60
|
+
"default": "./dist/image/index.mjs"
|
|
61
|
+
},
|
|
57
62
|
"./json": {
|
|
58
63
|
"types": "./dist/json.d.mts",
|
|
59
64
|
"default": "./dist/json.mjs"
|