first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
# @suaveplan/compress-image - Runtime-agnostic image compression utilities: resize, quality reduction, format conversion, and metadata stripping via pluggable adapters
Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, format conversion (JPEG, PNG, WebP, AVIF), and metadata stripping. The compressor core is pure TypeScript with zero runtime dependencies; only the Sharp adapter requires the native `sharp` library.
<!-- begin:auto-docs -->
## Documentation tree
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compress-image-error](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compress-options.schema](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [image-utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/compress-image
```
## Quick Start
```ts
import { createCompressor } from "@suaveplan/compress-image/compressor";
import { SharpAdapter } from "@suaveplan/compress-image/adapters/sharp";
import sharp from "sharp";
const compressor = createCompressor(new SharpAdapter(sharp));
const result = await compressor.compress(imageBuffer, {
format: "webp",
quality: 75,
width: 1280,
height: 720,
resizeMode: "cover",
stripMetadata: true,
});
console.log(result.compressionRatio, result.compressedSize);
```
## Modules
- [`src/core/compressor/compressor.md`](./src/core/compressor/compressor.md) — `ImageCompressor` core and `createCompressor`
- [`src/adapters/memory/memory.md`](./src/adapters/memory/memory.md) — `MemoryAdapter` for testing without native deps
- [`src/adapters/sharp/sharp.md`](./src/adapters/sharp/sharp.md) — `SharpAdapter` for Node.js production compression
- [`src/errors/compress-image-error.md`](./src/errors/compress-image-error.md) — `CompressImageError` and error codes
- [`src/schemas/compress-options.schema.md`](./src/schemas/compress-options.schema.md) — Zod schemas for compression options
- [`src/types/compress-image.types.md`](./src/types/compress-image.types.md) — TypeScript interfaces for results and adapters
- [`src/utils/image-utils.md`](./src/utils/image-utils.md) — Pure TypeScript image utilities
## Subpath Exports
- `.` — All public types, errors, utilities, adapters, and the compressor
- `./compressor` — `ImageCompressor`, `createCompressor`
- `./adapters/memory` — `MemoryAdapter`
- `./adapters/sharp` — `SharpAdapter`, `SharpLike`, `SharpInstance`
- `./errors` — `CompressImageError`, `COMPRESS_IMAGE_CODES`

View File

@@ -0,0 +1,81 @@
# memory-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a pure-TypeScript implementation of `ImageAdapter` that operates entirely on raw bytes — no native addons, no WebAssembly, no external codec dependencies. Its primary purpose is to enable 100% test coverage of the `ImageCompressor` class, Zod validation paths, and OTel instrumentation without requiring Sharp or any platform-specific library.
The adapter parses PNG IHDR chunks and JPEG SOF markers to derive real pixel dimensions from test fixtures, and synthesises `CompressResult` metadata (including a simulated `compressionRatio`) from the requested `quality` option.
## Features
- Implements the full `ImageAdapter` interface (`compress`, `getInfo`, `resize`)
- PNG dimension extraction via IHDR chunk parsing
- JPEG dimension extraction via SOF0/SOF2 marker scanning
- Synthetic `compressionRatio` computation: `ratio = 100 / quality`
- Conservative alpha detection: PNG and WebP are flagged as potentially having alpha
- Throws `CompressImageError` with code `UNSUPPORTED_FORMAT` for unrecognized inputs
- Zero external runtime dependencies
## Usage: Basic Example
```ts
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
import { createCompressor } from '@suaveplan/compress-image/compressor';
const compressor = createCompressor(new MemoryAdapter());
const result = await compressor.compress(pngBuffer, { quality: 60, format: 'webp' });
console.log(result.compressionRatio); // ~1.67
```
## Usage: Advanced Example
```ts
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
const adapter = new MemoryAdapter();
// getInfo: detect format and dimensions
const info = await adapter.getInfo(pngBuffer);
// { width: 1, height: 1, format: 'png', size: 68, hasAlpha: true }
// compress: returns original bytes with synthetic metadata
const result = await adapter.compress(pngBuffer, {
quality: 50,
format: 'webp',
width: 200,
height: 200,
});
// result.compressionRatio === 2.0
// result.data === pngBuffer (no actual re-encoding)
// resize: passthrough (no real pixel manipulation)
const resized = await adapter.resize(pngBuffer, 100, 100, 'cover');
// resized === pngBuffer
```
## API Reference
### `class MemoryAdapter implements ImageAdapter`
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
Returns a `CompressResult` where `data` is identical to `input`. The `format` defaults to the detected input format. `compressionRatio` is computed as `100 / quality` (quality defaults to 80, giving ratio 1.25). `width` and `height` in the result reflect `options.width`/`options.height` when supplied, otherwise the parsed dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for unrecognized input.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Returns `{ width, height, format, size, hasAlpha }` derived from magic-byte parsing. Alpha is `true` for PNG and WebP, `false` for JPEG. AVIF and unrecognized formats fall back to `{ width: 1, height: 1 }`. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if no format is detected.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Returns `input` unchanged. Validates that the format is recognizable before returning; throws `CompressImageError(UNSUPPORTED_FORMAT)` otherwise.
## Implementation Notes
The simulated `compressionRatio = 100 / quality` formula is a deliberate simplification. Real compressors produce ratios that depend on image content complexity, format, and encoder state. The formula satisfies the test contract that "higher quality = lower ratio", while being deterministic and dependency-free.
`compressedSize` is `Math.max(1, Math.round(originalSize / compressionRatio))` — the `Math.max(1, ...)` guard prevents a zero byte count when the input buffer is a single byte at quality 100.
WebP and AVIF dimension parsing is not implemented because these formats require a full codec to parse the container headers. The fallback `{ width: 1, height: 1 }` is clearly documented so test authors set explicit `width`/`height` options when asserting on those fields.

View File

@@ -0,0 +1,102 @@
# sharp-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a production-grade `ImageAdapter` implementation backed by the Sharp image processing library. Sharp delivers native-speed JPEG, PNG, WebP, and AVIF encoding through libvips, making it the recommended adapter for server-side workloads where pixel-accurate compression and real resizing are required.
Sharp is deliberately **not** a direct dependency of this package. The caller supplies a Sharp factory function at construction time, which eliminates the native-addon from the `@suaveplan/compress-image` dependency tree and allows the package to be used in environments where Sharp cannot be installed (e.g., edge runtimes, bundled frontends, or CI test runs).
## Features
- Full `ImageAdapter` implementation using the real Sharp pipeline
- JPEG progressive encoding, PNG compression levels, WebP lossless, AVIF effort tuning
- Metadata stripping by default; opt-in preservation via `stripMetadata: false`
- `ResizeMode` mapped to Sharp's `fit` option (cover / contain / fill / inside / outside)
- Caller-supplied Sharp factory for dependency injection
- Wraps all Sharp errors in `CompressImageError` with cause-chain forwarding
## Usage: Basic Example
```ts
import sharp from 'sharp';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
import { createCompressor } from '@suaveplan/compress-image/compressor';
const compressor = createCompressor(new SharpAdapter(sharp));
const result = await compressor.compress(imageBuffer, {
format: 'webp',
quality: 75,
width: 800,
height: 600,
});
```
## Usage: Advanced Example
```ts
import sharp from 'sharp';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
const adapter = new SharpAdapter(sharp);
// Get metadata
const info = await adapter.getInfo(jpegBuffer);
console.log(info.width, info.height, info.format, info.hasAlpha);
// Convert JPEG to AVIF with high compression effort
const result = await adapter.compress(jpegBuffer, {
format: 'avif',
quality: 60,
effort: 6,
stripMetadata: true,
});
// Resize to thumbnail keeping aspect ratio (contain mode)
const thumb = await adapter.resize(jpegBuffer, 128, 128, 'contain');
```
## API Reference
### `interface SharpLike`
A minimal factory interface matching the Sharp module export signature. Implement this to inject a mock in tests.
```ts
interface SharpLike {
(input: Buffer | Uint8Array): SharpInstance;
}
```
### `interface SharpInstance`
The fluent pipeline instance returned by `SharpLike`. Covers the methods used by `SharpAdapter`.
### `class SharpAdapter implements ImageAdapter`
#### `constructor(sharp: SharpLike)`
Stores the provided Sharp factory. No I/O occurs at construction time.
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
Runs the Sharp pipeline with format conversion, quality settings, and optional resize. Returns a `CompressResult` with real output bytes and actual dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for undetectable inputs; throws `CompressImageError(COMPRESS_FAILED)` for all other Sharp errors.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Calls `sharp(input).metadata()` to extract width, height, format, and alpha channel information. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if Sharp returns a format not in the known set.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Resizes using `sharp.resize()` with the mapped `fit` option. Returns a `Uint8Array` wrapping the Sharp output `Buffer`. Default mode is `'cover'`.
## Implementation Notes
Format encoding options are mapped per-format: JPEG receives `quality` and `progressive`; WebP receives `quality`, `lossless`, and `effort`; AVIF receives `quality` and `effort`; PNG receives `compressionLevel` (mapped from `effort`) but no quality parameter since PNG is lossless.
A second `.metadata()` pass on the output buffer is used to derive the final `width` and `height` in `CompressResult`. This adds a small overhead but guarantees accuracy after resize operations where the actual output dimensions may differ from the requested values (e.g., when using `inside` or `outside` fit modes).
Sharp's `Buffer` is wrapped in `new Uint8Array(buffer)` to satisfy the `Uint8Array` contract in `CompressResult.data`, ensuring interoperability with environments that do not use Node.js `Buffer`.

View File

@@ -0,0 +1,105 @@
# compressor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ImageCompressor` class is the primary entry point for the `@suaveplan/compress-image` package. It orchestrates all image processing operations through a pluggable `ImageAdapter`, adds Zod-based option validation, dimension range checking, and OpenTelemetry instrumentation, and exposes a convenience `compressIfLarger` method for conditional compression pipelines.
Application code should interact with `ImageCompressor` (or the `createCompressor` factory) rather than calling adapters directly, to benefit from validation and observability.
## Features
- Pluggable adapter pattern: swap `MemoryAdapter` ↔ `SharpAdapter` without changing application code
- Zod option validation with structured `CompressImageError(INVALID_OPTIONS)` on failure
- Dimension range validation (1–32767 px) before delegating to adapter
- OTel counter `compress_image.compress.total{format, outcome}` — incremented on every `compress()` call
- OTel histogram `compress_image.compress.duration_ms{format, outcome}` — recorded per call
- `compressIfLarger` for size-threshold-based conditional compression
- `createCompressor` factory for functional-style initialization
## Usage: Basic Example
```ts
import { createCompressor } from '@suaveplan/compress-image/compressor';
// Default adapter: MemoryAdapter (testing)
const compressor = createCompressor();
const result = await compressor.compress(pngBuffer, { quality: 80 });
console.log(result.compressionRatio, result.format);
```
## Usage: Advanced Example
```ts
import sharp from 'sharp';
import { createCompressor } from '@suaveplan/compress-image/compressor';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
const compressor = createCompressor(new SharpAdapter(sharp));
// Compress with resize and format conversion
const result = await compressor.compress(rawBuffer, {
format: 'webp',
quality: 75,
width: 1280,
height: 720,
resizeMode: 'contain',
stripMetadata: true,
progressive: false,
effort: 5,
});
// Conditionally compress: only if > 200 KB
const maybe = await compressor.compressIfLarger(rawBuffer, 200 * 1024, {
format: 'jpeg',
quality: 70,
});
// Get info without compressing
const info = await compressor.getInfo(rawBuffer);
// Resize only
const thumb = await compressor.resize(rawBuffer, 128, 128, 'cover');
```
## API Reference
### `class ImageCompressor`
#### `constructor(adapter?: ImageAdapter)`
Creates a new compressor. Defaults to `MemoryAdapter` when no adapter is provided.
#### `setAdapter(adapter: ImageAdapter): void`
Replaces the active adapter. Useful for switching between testing and production adapters at runtime.
#### `compress(input: Uint8Array, options?: CompressOptions): Promise<CompressResult>`
Validates options with Zod, checks dimension bounds, delegates to the adapter, and records OTel metrics. Returns a `CompressResult`. Throws `CompressImageError(INVALID_OPTIONS)` on bad options; throws `CompressImageError(UNSUPPORTED_FORMAT)` or `CompressImageError(COMPRESS_FAILED)` from the adapter.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Delegates directly to `adapter.getInfo()` — no option validation required.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Validates `width` and `height` ranges, then delegates to `adapter.resize()`. Throws `CompressImageError(INVALID_OPTIONS)` for out-of-range dimensions.
#### `compressIfLarger(input: Uint8Array, maxBytes: number, options?: CompressOptions): Promise<CompressResult>`
Returns the original bytes wrapped in a synthetic `CompressResult` (`compressionRatio = 1`) when `input.length <= maxBytes`. Otherwise delegates to `compress()`. Always returns `CompressResult` so downstream code has a consistent return type.
### `createCompressor(adapter?: ImageAdapter): ImageCompressor`
Equivalent to `new ImageCompressor(adapter)`. Returns a configured `ImageCompressor`.
## Implementation Notes
OTel instruments are lazily initialized on first use inside `getCounter()` and `getDuration()`. This avoids registering metrics at module load time, which would interfere with test environments that configure the OTel SDK after imports are resolved.
The `format` OTel attribute defaults to `'unknown'` when `options.format` is not specified, since the detected format is not available until the adapter resolves it. This is a deliberate trade-off: the counter fires unconditionally so monitoring captures 100% of calls.
`compressIfLarger` calls `adapter.getInfo()` for the bypass path to populate `format`, `width`, and `height` in the returned `CompressResult`. This costs one metadata parse but gives callers a usable result regardless of whether compression ran.

View File

@@ -0,0 +1,82 @@
# compress-image-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error class and stable code registry for the `@suaveplan/compress-image` package. All compression failures are surfaced through `CompressImageError`, which extends the project-wide `SuaveplanError` base class to guarantee consistent wire format, cause-chain traversal, OTel span-event integration, and Zod-validated JSON/binary serialization.
Centralising codes in `COMPRESS_IMAGE_CODES` (produced by `defineCodes`) ensures that every error string is unique within the project namespace and will not silently change across package updates.
## Features
- Stable `COMPRESS_IMAGE_CODES` registry with four codes covering all failure surfaces
- `CompressImageError` class with typed `code` property for narrow `instanceof` discrimination
- Full cause-chain forwarding via the `cause` option
- Compatible with the `SuaveplanError` OTel adapter and redaction middleware
- Zero external runtime dependencies beyond `@suaveplan/error`
## Usage: Basic Example
```ts
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
throw new CompressImageError(
'Unsupported input format: tiff',
COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT,
);
```
## Usage: Advanced Example
```ts
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
async function runCompress(input: Uint8Array) {
try {
await adapter.compress(input, { quality: 80 });
} catch (err) {
throw new CompressImageError(
'Compression pipeline failed',
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
{ cause: err instanceof Error ? err : undefined },
);
}
}
```
## API Reference
### `COMPRESS_IMAGE_CODES`
An object map produced by `defineCodes('COMPRESS_IMAGE', { ... })`. Each value is a stable string code of the form `COMPRESS_IMAGE.<KEY>`.
| Key | Description |
|---|---|
| `UNSUPPORTED_FORMAT` | Input image format is not recognized or not supported by the adapter |
| `COMPRESS_FAILED` | The compression operation itself threw an error |
| `INVALID_OPTIONS` | Zod validation rejected the provided `CompressOptions` |
| `ADAPTER_NOT_SET` | `compress` / `resize` / `getInfo` was called before an adapter was configured |
### `CompressImageError`
```ts
class CompressImageError extends SuaveplanError {
constructor(message: string, code: string, options?: { cause?: Error }): void
}
```
**Parameters**
| Name | Type | Description |
|---|---|---|
| `message` | `string` | Human-readable description of the failure |
| `code` | `string` | A value from `COMPRESS_IMAGE_CODES` |
| `options.cause` | `Error \| undefined` | Originating error for cause-chain traversal |
## Implementation Notes
`SuaveplanError` stores `code` on its instance and enforces the Zod-validated wire format so errors survive cross-process transport. The `name = 'CompressImageError'` assignment enables `instanceof` checks even after deserialization with the error registry.
The constructor signature deviates slightly from `DatetimeError` — it accepts a plain `string` message rather than a messageKey because image compression errors carry enough context inline and do not require i18n resolution.

View File

@@ -0,0 +1,103 @@
# compress-options.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a Zod schema (`CompressOptionsSchema`) that validates and normalises
`CompressOptions` at runtime before any image-processing work begins. Keeping
validation in a dedicated schema file separates concerns cleanly: the
compressor delegates option-checking to this module and never needs to repeat
range guards inline.
## Features
- `format` — optional enum restricted to `"jpeg" | "png" | "webp" | "avif"`
- `quality` — integer `1–100`; rejects floats and out-of-range values
- `width` / `height` — positive integers capped at 32767 (JPEG/PNG spec limit)
- `resizeMode` — optional enum of five Sharp `fit` strategies
- `stripMetadata` / `progressive` / `lossless` — optional booleans
- `effort` — integer `0–6` for compression effort / speed trade-off
- Re-exports `CompressOptionsInput` (pre-parse) and `CompressOptionsParsed` (post-parse) type aliases for strong typing at call sites
## Usage: Basic Example
```ts
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
const result = CompressOptionsSchema.safeParse({ quality: 85, format: 'webp' });
if (!result.success) {
console.error(result.error.issues);
} else {
const opts = result.data; // CompressOptionsParsed
}
```
## Usage: Advanced Example
```ts
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
import type { CompressOptionsInput } from '@suaveplan/compress-image/schemas';
function toCompressOptions(raw: unknown): CompressOptionsInput {
// Strict parse — throws ZodError on invalid input
return CompressOptionsSchema.parse(raw);
}
// Combining with ImageCompressor validation:
// The ImageCompressor calls CompressOptionsSchema.safeParse internally and
// maps ZodError issues into a structured CompressImageError(INVALID_OPTIONS).
// Direct use of this schema is useful in API route handlers that want to
// validate options before constructing an ImageCompressor.
const validated = CompressOptionsSchema.safeParse({
format: 'avif',
quality: 60,
width: 1920,
height: 1080,
resizeMode: 'cover',
effort: 4,
stripMetadata: true,
});
```
## API Reference
### `CompressOptionsSchema`
`ZodObject` schema validating `CompressOptions`. All fields are optional.
| Field | Type | Constraints |
|----------------|-----------------------------------|----------------------------|
| `format` | `"jpeg" \| "png" \| "webp" \| "avif"` | enum |
| `quality` | `number` | int, 1–100 |
| `width` | `number` | int, 1–32767 |
| `height` | `number` | int, 1–32767 |
| `resizeMode` | `ResizeMode` | enum of 5 strategies |
| `stripMetadata`| `boolean` | optional |
| `progressive` | `boolean` | optional |
| `lossless` | `boolean` | optional |
| `effort` | `number` | int, 0–6 |
### `CompressOptionsInput`
`z.input<typeof CompressOptionsSchema>` — the shape of data accepted before parsing (all fields optional).
### `CompressOptionsParsed`
`z.output<typeof CompressOptionsSchema>` — the shape returned after successful parsing.
## Implementation Notes
The schema intentionally mirrors the `CompressOptions` interface defined in
`compress-image.types.ts`. No coercion is applied; callers are expected to
pass the correct JS types. Integer constraints (`z.number().int()`) are
necessary because Sharp interprets quality as a C `int`; passing `80.5` would
produce unpredictable output.
Effort range `0–6` follows the WebP/AVIF encoder convention. Sharp maps this
field directly to the codec's speed parameter, where 0 is fastest (lower
quality at equal bit-rate) and 6 is slowest (best compression).
The schema is intentionally stateless and side-effect-free — it is safe to
import and reuse across workers, edge functions, and test helpers.

View File

@@ -0,0 +1,149 @@
# compress-image.types
## Purpose
Defines all public TypeScript types and interfaces for the
`@suaveplan/compress-image` package. Keeping type definitions in a single
dedicated file avoids circular imports between adapter and compressor modules
and makes it straightforward for consumers to import only the types they need
without pulling in any runtime code.
## Features
- `ImageFormat` — union of four supported output formats (`jpeg`, `png`, `webp`, `avif`)
- `ResizeMode` — five resize strategies mirroring the Sharp `fit` option
- `ColorSpace` — two colour-space choices (`srgb`, `p3`)
- `CompressOptions` — all parameters accepted by `ImageCompressor.compress()`
- `CompressResult` — structured result returned after a compress call
- `ImageInfo` — image metadata returned by `getInfo()` without modifying the image
- `ImageAdapter` — pluggable backend interface implemented by `MemoryAdapter` and `SharpAdapter`
## Usage: Basic Example
```ts
import type {
CompressOptions,
CompressResult,
ImageFormat,
} from '@suaveplan/compress-image';
function makeWebpOptions(quality: number): CompressOptions {
return { format: 'webp' as ImageFormat, quality };
}
async function printResult(result: CompressResult): Promise<void> {
console.log(`Compressed to ${result.compressedSize} bytes (ratio ${result.compressionRatio.toFixed(2)})`);
}
```
## Usage: Advanced Example
```ts
import type { ImageAdapter, CompressOptions, CompressResult, ImageInfo, ResizeMode } from '@suaveplan/compress-image';
// Implement a custom adapter backed by a WASM codec:
class WasmAdapter implements ImageAdapter {
async compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult> {
// delegate to WASM encoder...
return {
data: input,
format: options.format ?? 'jpeg',
width: 100,
height: 100,
originalSize: input.length,
compressedSize: input.length,
compressionRatio: 1,
};
}
async getInfo(input: Uint8Array): Promise<ImageInfo> {
return { width: 100, height: 100, format: 'jpeg', size: input.length, hasAlpha: false };
}
async resize(input: Uint8Array, _w: number, _h: number, _mode?: ResizeMode): Promise<Uint8Array> {
return input;
}
}
```
## API Reference
### `ImageFormat`
```ts
type ImageFormat = "jpeg" | "png" | "webp" | "avif";
```
### `ResizeMode`
```ts
type ResizeMode = "cover" | "contain" | "fill" | "inside" | "outside";
```
Mirrors the Sharp `fit` option. `cover` crops to fill; `contain` letterboxes; `fill` stretches; `inside` shrinks only; `outside` enlarges only.
### `ColorSpace`
```ts
type ColorSpace = "srgb" | "p3";
```
### `CompressOptions`
All fields optional.
| Field | Type | Description |
|-----------------|---------------|----------------------------------------------------------|
| `format` | `ImageFormat` | Target format. Defaults to detected input format. |
| `quality` | `number` | 1–100 quality level. Default 80. |
| `width` | `number` | Target width in pixels. |
| `height` | `number` | Target height in pixels. |
| `resizeMode` | `ResizeMode` | Resize strategy. Default `"cover"`. |
| `stripMetadata` | `boolean` | Strip EXIF and other metadata. Default `true`. |
| `progressive` | `boolean` | Enable progressive JPEG. Default `false`. |
| `lossless` | `boolean` | WebP lossless encoding. Default `false`. |
| `effort` | `number` | Compression effort 0–6. Default 4. |
### `CompressResult`
| Field | Type | Description |
|--------------------|---------------|------------------------------------------------------|
| `data` | `Uint8Array` | Compressed image bytes. |
| `format` | `ImageFormat` | Output format. |
| `width` | `number` | Output width in pixels. |
| `height` | `number` | Output height in pixels. |
| `originalSize` | `number` | Input byte length. |
| `compressedSize` | `number` | Output byte length. |
| `compressionRatio` | `number` | `originalSize / compressedSize` (>1 = effective compression). |
### `ImageInfo`
| Field | Type | Description |
|------------|---------------|------------------------------------------|
| `width` | `number` | Image width in pixels. |
| `height` | `number` | Image height in pixels. |
| `format` | `ImageFormat` | Detected image format. |
| `size` | `number` | File size in bytes. |
| `hasAlpha` | `boolean` | Whether the image has an alpha channel. |
### `ImageAdapter`
Interface for pluggable image-processing backends.
| Method | Signature | Description |
|-------------|---------------------------------------------------------------------------|-----------------------------------|
| `compress` | `(input: Uint8Array, options: CompressOptions) => Promise<CompressResult>`| Compress with given options. |
| `getInfo` | `(input: Uint8Array) => Promise<ImageInfo>` | Extract metadata without encoding.|
| `resize` | `(input: Uint8Array, w: number, h: number, mode?: ResizeMode) => Promise<Uint8Array>` | Resize to target dimensions. |
## Implementation Notes
These types are pure TypeScript interfaces — they emit no runtime code and add
no bundle weight. The `ImageAdapter` interface is the primary extension point:
any backend (Sharp, WebAssembly, Canvas API, remote service) that satisfies
the three-method contract can be plugged into `ImageCompressor` via the
`setAdapter()` method or the `createCompressor()` factory.
`ColorSpace` is defined here for forward compatibility but is not yet consumed
by any built-in adapter. It is available for custom adapters that support
wide-gamut output.

View File

@@ -0,0 +1,89 @@
# image-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides low-level, dependency-free utility functions for working with raw image bytes. These utilities power both the `MemoryAdapter` (testing) and the `SharpAdapter` (production), and are also available to application code for format detection and dimension extraction without instantiating a full adapter.
All functions operate on `Uint8Array` buffers and are designed to be pure and free of side effects, enabling tree-shaking and straightforward unit testing.
## Features
- Magic-byte format detection for JPEG, PNG, WebP, and AVIF
- PNG IHDR chunk dimension parser (big-endian uint32 at fixed offsets)
- JPEG SOF0/SOF2 marker scanner for baseline and progressive dimension extraction
- Aspect ratio calculation using the Euclidean GCD algorithm
- Dimension range validation with descriptive `CompressImageError` on failure
## Usage: Basic Example
```ts
import { detectFormat, parsePngDimensions } from '@suaveplan/compress-image';
const format = detectFormat(buffer); // 'png' | 'jpeg' | 'webp' | 'avif' | null
const dims = parsePngDimensions(buffer);
if (dims) {
console.log(dims.width, dims.height);
}
```
## Usage: Advanced Example
```ts
import {
detectFormat,
parseJpegDimensions,
validateDimensions,
calculateAspectRatio,
} from '@suaveplan/compress-image';
function analyzeImage(data: Uint8Array) {
const format = detectFormat(data);
if (!format) throw new Error('Unknown format');
let dims: { width: number; height: number } | null = null;
if (format === 'jpeg') dims = parseJpegDimensions(data);
else if (format === 'png') dims = parsePngDimensions(data);
if (!dims) throw new Error('Could not determine dimensions');
// Validate target dimensions before a resize
validateDimensions(800, 600);
const ratio = calculateAspectRatio(dims.width, dims.height);
console.log(`Aspect ratio: ${ratio.width}:${ratio.height}`);
}
```
## API Reference
### `detectFormat(data: Uint8Array): ImageFormat | null`
Inspects magic bytes at the beginning of `data` to determine the image format. Returns `null` for unrecognized signatures. Requires at least 4 bytes; WebP and AVIF detection requires 12 bytes.
### `validateDimensions(width?: number, height?: number): void`
Validates that the provided dimensions (when present) are positive integers not exceeding 32767. Throws `CompressImageError` with code `INVALID_OPTIONS` on any violation.
### `calculateAspectRatio(width: number, height: number): { width: number; height: number }`
Returns the reduced aspect ratio using the Euclidean GCD. For example `(1920, 1080)` returns `{ width: 16, height: 9 }`.
### `parsePngDimensions(data: Uint8Array): { width: number; height: number } | null`
Reads the IHDR chunk at the fixed offset in a PNG file (bytes 16–23) and returns the width and height. Returns `null` if the magic bytes are wrong, the buffer is too short, or the IHDR tag is not found.
### `parseJpegDimensions(data: Uint8Array): { width: number; height: number } | null`
Scans the JPEG segment stream for SOF0 (0xFF 0xC0) or SOF2 (0xFF 0xC2) markers and reads the frame header dimensions. Returns `null` if no SOF marker is found or the buffer is too short.
## Implementation Notes
The AVIF detection reads the `ftyp` box major brand field at bytes 8–11. This covers `avif` (still image) and `avis` (AVIF image sequence). Files with compatible brands listed only in the compatible-brands array (not the major brand) are not detected; this is an acceptable limitation for a magic-byte heuristic.
`parseJpegDimensions` handles 0xFF padding bytes and segment-length arithmetic correctly, but does not attempt to parse progressive scans beyond SOF2. DNL (Define Number of Lines) segments used in some streaming encoders are not supported.
Dimension overflow: width and height are read as unsigned 32-bit big-endian integers and immediately checked against 0 to reject pathological inputs before they can propagate.