utilful 3.2.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 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
- The `cli` module is the exception: it is Node-only (22.13 or later) and lives on its subpaths `utilful/cli` and `utilful/cli/testing` alone.
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`
@@ -69,7 +69,7 @@ function createCliHarness(command, { entry, ...runOptions } = {}) {
69
69
  return new Promise((resolve, reject) => {
70
70
  execFile(process.execPath, [entry, ...argv], {
71
71
  cwd: options.cwd,
72
- maxBuffer: 64 * 1024 * 1024
72
+ maxBuffer: 67108864
73
73
  }, (spawnError, stdout, stderr) => {
74
74
  if (spawnError && typeof spawnError.code !== "number") reject(spawnError);
75
75
  else resolve({
@@ -4,7 +4,7 @@ import process from "node:process";
4
4
  //#region src/cli/usage.ts
5
5
  function renderUsage(command, { parent, stream = process.stdout } = {}) {
6
6
  const color = (style, text) => paint(style, text, stream);
7
- const heading = (title) => color(["bold", "underline"], title);
7
+ const heading = (title) => color("bold", title);
8
8
  const meta = command.meta ?? {};
9
9
  const parentMeta = parent?.meta ?? {};
10
10
  const commandName = [parentMeta.name, meta.name].filter((name) => name !== void 0).join(" ");
@@ -29,7 +29,7 @@ function renderUsage(command, { parent, stream = process.stdout } = {}) {
29
29
  }
30
30
  const spellings = [definition.alias === void 0 ? void 0 : `-${definition.alias}`, `--${name}`].filter((spelling) => spelling !== void 0).join(", ");
31
31
  const value = definition.type === "string" ? `<${definition.valueHint ?? name}>` : void 0;
32
- optionLines.push([color("cyan", value === void 0 ? spellings : `${spellings}=${value}`), hints]);
32
+ optionLines.push([color("cyan", spellings) + (value === void 0 ? "" : color("dim", `=${value}`)), hints]);
33
33
  if (definition.type === "boolean" && definition.default === true) optionLines.push([color("cyan", `--no-${name}`), ""]);
34
34
  if (isRequired) usageLine.push(`--${name}=${value}`);
35
35
  }
@@ -37,9 +37,10 @@ function renderUsage(command, { parent, stream = process.stdout } = {}) {
37
37
  const hasArguments = positionalLines.length > 0 || optionLines.length > 0;
38
38
  if (commandLines.length > 0 && !hasArguments) usageLine.push(Object.keys(command.subCommands).join("|"));
39
39
  const lines = [
40
- color("gray", `${meta.description ?? ""} (${commandName}${version === void 0 ? "" : ` v${version}`})`),
40
+ color("bold", version === void 0 ? commandName : `${commandName} v${version}`),
41
+ ...meta.description === void 0 ? [] : [meta.description],
41
42
  "",
42
- `${heading("USAGE")} ${color("cyan", [
43
+ `${heading("USAGE")} ${color("cyan", [
43
44
  commandName,
44
45
  hasArguments ? "[OPTIONS]" : void 0,
45
46
  ...usageLine
package/dist/csv.mjs CHANGED
@@ -154,15 +154,16 @@ var CSVParserCore = class {
154
154
  const character = text[i];
155
155
  const nextCharacter = i + 1 < text.length ? text[i + 1] : "";
156
156
  if (this.isFieldQuoted && !this.inQuotes && character !== this.delimiter && (character === SPACE || character === TAB)) continue;
157
- if (character === DOUBLE_QUOTE) if (this.currentField.length === 0 && !this.inQuotes) {
158
- this.inQuotes = true;
159
- this.isFieldQuoted = true;
160
- } else if (this.inQuotes && nextCharacter === DOUBLE_QUOTE) {
161
- this.currentField += DOUBLE_QUOTE;
162
- i++;
163
- } else if (this.inQuotes) this.inQuotes = false;
164
- else this.currentField += character;
165
- else if (character === this.delimiter && !this.inQuotes) this.appendField();
157
+ if (character === DOUBLE_QUOTE) {
158
+ if (this.currentField.length === 0 && !this.inQuotes) {
159
+ this.inQuotes = true;
160
+ this.isFieldQuoted = true;
161
+ } else if (this.inQuotes && nextCharacter === DOUBLE_QUOTE) {
162
+ this.currentField += DOUBLE_QUOTE;
163
+ i++;
164
+ } else if (this.inQuotes) this.inQuotes = false;
165
+ else this.currentField += character;
166
+ } else if (character === this.delimiter && !this.inQuotes) this.appendField();
166
167
  else if ((character === NEWLINE || character === CARRIAGE_RETURN) && !this.inQuotes) {
167
168
  if (character === CARRIAGE_RETURN && nextCharacter === NEWLINE) i++;
168
169
  this.appendRow();
package/dist/emitter.mjs CHANGED
@@ -28,8 +28,10 @@ function createEmitter(events) {
28
28
  */
29
29
  off(type, handler) {
30
30
  const handlers = events.get(type);
31
- if (handlers) if (handler) handlers.splice(handlers.indexOf(handler) >>> 0, 1);
32
- else events.set(type, []);
31
+ if (handlers) {
32
+ if (handler) handlers.splice(handlers.indexOf(handler) >>> 0, 1);
33
+ else events.set(type, []);
34
+ }
33
35
  },
34
36
  /**
35
37
  * Invokes all handlers for the given type.
@@ -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,8 +1,8 @@
1
1
  {
2
2
  "name": "utilful",
3
3
  "type": "module",
4
- "version": "3.2.0",
5
- "packageManager": "pnpm@11.15.1",
4
+ "version": "3.4.0",
5
+ "packageManager": "pnpm@11.25.0",
6
6
  "description": "A collection of TypeScript utilities",
7
7
  "author": "Johann Schopplich <hello@johannschopplich.com>",
8
8
  "license": "MIT",
@@ -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"
@@ -104,13 +109,13 @@
104
109
  }
105
110
  },
106
111
  "devDependencies": {
107
- "@antfu/eslint-config": "^9.1.0",
108
- "@types/node": "^26.1.1",
109
- "bumpp": "^12.0.0",
110
- "eslint": "^10.7.0",
112
+ "@antfu/eslint-config": "^9.5.0",
113
+ "@types/node": "^26.4.1",
114
+ "bumpp": "^12.2.2",
115
+ "eslint": "^10.9.1",
111
116
  "eslint-flat-config-utils": "^3.2.0",
112
- "tsdown": "^0.22.13",
117
+ "tsdown": "^0.22.14",
113
118
  "typescript": "^6.0.3",
114
- "vitest": "^4.1.10"
119
+ "vitest": "^4.1.11"
115
120
  }
116
121
  }