Initial Commit
This commit is contained in:
221
openspec/specs/image/spec.md
Normal file
221
openspec/specs/image/spec.md
Normal file
@@ -0,0 +1,221 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec: image
|
||||
|
||||
**Package**: `@suaveplan/image`
|
||||
**Status**: STABLE
|
||||
|
||||
> 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 -->
|
||||
Reference in New Issue
Block a user