Files
suaveplan/openspec/archive/2026-05-22-migrate-image/specs/image/spec.md
2026-05-27 23:29:53 +01:00

222 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: image (migrate-image)
**Package**: `@suaveplan/image`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Stable error code registry via defineCodes
The package SHALL declare a single `defineCodes("IMAGE", [...])` registry named `IMAGE_CODES` containing every error code emitted by the package. No hand-typed string literals for codes SHALL appear in `src/` or tests.
#### Scenario: Code values use IMAGE.* namespace
- **WHEN** a consumer reads `IMAGE_CODES.NOT_FOUND`
- **THEN** the value is exactly `"IMAGE.NOT_FOUND"`
#### Scenario: Every code is listed in the registry
- **WHEN** introspecting `IMAGE_CODES`
- **THEN** it contains `NOT_FOUND`, `DECODE_FAILED`, `ENCODE_FAILED`, `FORMAT_UNSUPPORTED`, `TOO_LARGE`, `PROCESSING_FAILED`, `DIMENSIONS_INVALID`
**Verified by:** `src/error-codes/error-codes.test.ts::"code values use IMAGE.* namespace"` AND `src/error-codes/error-codes.test.ts::"contains expected codes"`
---
### Requirement: Typed error hierarchy extending SuaveplanError
The package SHALL export `ImageError` extending `SuaveplanError<ImageCode>` with an optional `ImageErrorContext` carrying `format`, `width`, `height`, `pixels`, `maxPixels`, and `path`. Typed factory functions SHALL exist for every failure mode.
#### Scenario: ImageError carries code, message, context, and cause
- **WHEN** constructing `new ImageError(IMAGE_CODES.FORMAT_UNSUPPORTED, "msg", { format: "heic" })`
- **THEN** `error.code === "IMAGE.FORMAT_UNSUPPORTED"`, `error.context.format === "heic"`, and `error instanceof SuaveplanError` is true
#### Scenario: Factory functions produce correctly-coded errors
- **WHEN** calling `imageNotFound("/a.png")`, `imageDecodeFailed("png")`, `imageTooLarge(10, 5)`, `imageDimensionsInvalid(-1, 0)`
- **THEN** each result has the matching `IMAGE.*` code and populated context
**Verified by:** `src/errors/errors.test.ts::"creates error with code and message"` AND `src/errors/errors.test.ts::"factory functions produce correctly-coded errors"`
---
### Requirement: Magic-byte format detection
The package SHALL detect image formats from the first bytes of a `Uint8Array` for PNG, JPEG, GIF, BMP, TIFF (little- and big-endian), ICO, WebP, and SVG. Unknown headers SHALL return `null`.
#### Scenario: Detect PNG from header
- **WHEN** calling `detectFormat(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))`
- **THEN** the result is `"png"`
#### Scenario: Reject RIFF without WEBP tag
- **WHEN** calling `detectFormat` on a RIFF header whose bytes 8-11 are not `WEBP`
- **THEN** the result is `null`
#### Scenario: Detect SVG from textual header
- **WHEN** calling `detectFormat` on a UTF-8 buffer containing `<svg` or `<?xml`
- **THEN** the result is `"svg"`
**Verified by:** `src/types/image-types.test.ts::"detects PNG"` AND `src/types/image-types.test.ts::"rejects RIFF without WEBP tag"` AND `src/types/image-types.test.ts::"detects SVG"`
---
### Requirement: Dimension parsing for supported formats
The package SHALL parse `width` and `height` from PNG, JPEG, GIF, and BMP headers. TIFF SHALL report format but dimensions zero (not yet parsed). Short or malformed data SHALL yield `{ width: 0, height: 0 }` without throwing.
#### Scenario: Parse PNG IHDR dimensions
- **WHEN** `ImageProcessor.extractMetadata` receives a valid PNG header
- **THEN** `metadata.width` and `metadata.height` match the IHDR values
#### Scenario: Parse JPEG SOF0 dimensions after APP0
- **WHEN** `ImageProcessor.extractMetadata` receives JPEG with an APP0 segment before SOF0
- **THEN** dimensions are read from the SOF0 segment
#### Scenario: Handle short BMP data gracefully
- **WHEN** `ImageProcessor.extractMetadata` receives a BMP shorter than 26 bytes
- **THEN** `metadata.width` and `metadata.height` are `0`
**Verified by:** `src/processor/processor.test.ts::"extracts PNG metadata"` AND `src/processor/processor.test.ts::"handles JPEG with multiple segments before SOF"` AND `src/processor/processor.test.ts::"handles short BMP data"`
---
### Requirement: Resize math with fit modes
The package SHALL calculate output dimensions from `ResizeOptions` supporting `cover`, `contain`, `fill`, `inside`, `outside` fits, plus `withoutEnlargement`. Omitting both `width` and `height` SHALL return original dimensions.
#### Scenario: Contain fit scales down to fit both axes
- **WHEN** `calculateResize({ width: 800, height: 600 }, { width: 200, height: 200, fit: "contain" })`
- **THEN** result width and height are both `<= 200` and maintain aspect ratio
#### Scenario: WithoutEnlargement prevents upscaling
- **WHEN** `calculateResize({ width: 200, height: 150 }, { width: 800, withoutEnlargement: true })`
- **THEN** result is `{ width: 200, height: 150 }`
**Verified by:** `src/types/image-types.test.ts::"contain fit scales down"` AND `src/types/image-types.test.ts::"withoutEnlargement prevents upscaling"`
---
### Requirement: Crop region validation
The package SHALL reject crop regions with negative origin, zero or negative size, or bounds exceeding the source dimensions.
#### Scenario: Reject crop exceeding image width
- **WHEN** `validateCrop({ width: 800, height: 600 }, { x: 700, y: 0, width: 200, height: 100 })`
- **THEN** the result is `false`
**Verified by:** `src/types/image-types.test.ts::"rejects crop exceeding width"`
---
### Requirement: ImageProcessor metadata extraction with limits and events
The package SHALL provide `ImageProcessor` with configurable `maxPixels`, `defaultQuality`, and `defaultFormat`. `extractMetadata` SHALL emit `"metadata.extracted"` via the internal event bus and throw `IMAGE_CODES.TOO_LARGE` when pixel count exceeds the limit.
#### Scenario: Throw when image exceeds maxPixels
- **WHEN** `extractMetadata` is called on an 800×600 image with `maxPixels: 100`
- **THEN** it throws `ImageError` with code `IMAGE.TOO_LARGE`
#### Scenario: Emit metadata.extracted event on success
- **WHEN** `extractMetadata` succeeds
- **THEN** the `"metadata.extracted"` event fires with the metadata object
**Verified by:** `src/processor/processor.test.ts::"throws for image exceeding maxPixels"` AND `src/processor/processor.test.ts::"emits metadata.extracted event"`
---
### Requirement: Transform pipeline planning
The package SHALL provide `PipelineBuilder` with a fluent API for `resize`, `crop`, `rotate`, `flip`, `quality`, and `toFormat`. `ImageProcessor.planTransform` SHALL compute final dimensions by applying operations in order and emit `"transform.planned"`.
#### Scenario: 90-degree rotation swaps dimensions
- **WHEN** planning a transform with `rotate(90)` on an 800×600 image
- **THEN** result dimensions are 600×800
#### Scenario: Invalid crop region throws processing error
- **WHEN** planning a transform with a crop region that exceeds current dimensions
- **THEN** it throws `ImageError` with code `IMAGE.PROCESSING_FAILED`
**Verified by:** `src/processor/processor.test.ts::"applies 90-degree rotation"` AND `src/processor/processor.test.ts::"throws for invalid crop"`
---
### Requirement: Zod schema validation on public boundaries
The package SHALL validate `ImageProcessorConfig`, `CropRegion`, `ResizeOptions`, `TransformPipeline`, and `TransformOp` via Zod schemas at public entrypoints. Invalid input SHALL throw `IMAGE_CODES.DIMENSIONS_INVALID` (or `IMAGE_CODES.PROCESSING_FAILED` where semantically appropriate).
#### Scenario: Invalid config rejects negative maxPixels
- **WHEN** constructing `ImageProcessor({ maxPixels: -1 })`
- **THEN** it throws `ImageError` with code `IMAGE.DIMENSIONS_INVALID`
#### Scenario: Invalid crop in PipelineBuilder throws
- **WHEN** `PipelineBuilder.crop({ x: 0, y: 0, width: -1, height: 10 })`
- **THEN** it throws `ImageError` with code `IMAGE.DIMENSIONS_INVALID`
**Verified by:** `src/processor/processor.spec-conformance.test.ts::"rejects invalid config"` AND `src/processor/processor.spec-conformance.test.ts::"rejects negative crop dimensions"`
---
### Requirement: OpenTelemetry instrumentation
The package SHALL emit OTel counters (`image.metadata.extracted_total`, `image.transform.planned_total`) and histograms (`image.metadata.extract_duration_ms`, `image.transform.plan_duration_ms`) from production code paths. Spans SHALL wrap `extractMetadata` and `planTransform`.
#### Scenario: Extract metadata increments counter and records histogram
- **WHEN** `extractMetadata` is called with a valid image
- **THEN** `image.metadata.extracted_total{format="png",outcome="success"}` increments by 1 and `image.metadata.extract_duration_ms` receives a non-negative observation
#### Scenario: Span wraps planTransform
- **WHEN** `planTransform` is called
- **THEN** a span named `image.transform.plan` is started and ended
**Verified by:** `src/processor/processor.spec-conformance.test.ts::"increments extracted counter on success"` AND `src/processor/processor.spec-conformance.test.ts::"creates planTransform span"`
---
### Requirement: AbortSignal propagation
The package SHALL accept an optional `AbortSignal` on async and synchronous entrypoints. A pre-aborted signal SHALL short-circuit before work begins with `IMAGE_CODES.PROCESSING_FAILED` and cause set to `AbortError`.
#### Scenario: Pre-aborted signal short-circuits extractMetadata
- **WHEN** `extractMetadata` receives `signal` that is already aborted
- **THEN** it throws `ImageError` with code `IMAGE.PROCESSING_FAILED` and `cause.name === "AbortError"`
**Verified by:** `src/processor/processor.spec-conformance.test.ts::"short-circuits on pre-aborted signal"`
---
### Requirement: Subpath exports and sideEffects false
The package SHALL declare `sideEffects: false` and provide subpath exports for `./error-codes/error-codes`, `./errors/errors`, `./types/image-types`, and `./processor/processor`. Feature-module barrels SHALL use explicit named re-exports.
#### Scenario: Tree-shake test passes
- **WHEN** bundling with only `detectFormat` imported
- **THEN** `ImageProcessor` code is not included in the output
**Verified by:** `tests/tree-shake.test.ts::"tree-shakes unused exports"`
<!-- markdownlint-enable MD024 -->