177 lines
12 KiB
Markdown
177 lines
12 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
||
|
||
# Capability Spec Delta: compress-svg (compress-svg-completion)
|
||
|
||
**Package**: `@suaveplan/compress-svg`
|
||
**Status**: PROPOSED
|
||
|
||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||
|
||
## ADDED Requirements
|
||
|
||
### Requirement: SvgOptimizer façade
|
||
|
||
The package SHALL expose an `SvgOptimizer` orchestrator class that runs a configured plugin pipeline against one or more SVG documents and returns a typed `OptimizationResult` describing the bytes-before/bytes-after, the plugins applied, any warnings emitted, and the wall-clock duration. Callers can rely on the optimizer to delegate compression to an injectable `CompressorAdapter` so the same façade works on Node and in the browser; callers cannot rely on any specific Node API being available at the call site.
|
||
|
||
#### Scenario: single-document optimisation returns a populated result
|
||
|
||
- **WHEN** a caller invokes `new SvgOptimizer(config).optimize(svg)` with a valid SVG string and a config that selects at least one plugin
|
||
- **THEN** the returned `OptimizationResult` carries `originalBytes > 0`, `optimizedBytes > 0`, `pluginsApplied.length >= 1`, `durationMs >= 0`, and `ratio = optimizedBytes / originalBytes`
|
||
|
||
#### Scenario: pipeline failure raises a stable error code
|
||
|
||
- **WHEN** any plugin in the pipeline throws while `optimize` is running
|
||
- **THEN** `optimize` rejects with `SuaveplanError` whose `code` is `COMPRESS_SVG.OPTIMIZER_PIPELINE_FAILED` and whose `cause` is the original plugin error
|
||
|
||
#### Scenario: batch mode returns one result per input
|
||
|
||
- **WHEN** a caller invokes `optimizeBatch([svg1, svg2, svg3])`
|
||
- **THEN** the returned array has length 3 and the indices line up with the inputs
|
||
|
||
**Verified by:** `src/optimizer/svg-optimizer.test.ts::"single-document optimisation returns a populated OptimizationResult"` AND `src/optimizer/svg-optimizer.spec-conformance.test.ts::"raises COMPRESS_SVG.OPTIMIZER_PIPELINE_FAILED with original cause when a plugin throws"` AND `src/optimizer/svg-optimizer.test.ts::"optimizeBatch returns one result per input in order"`
|
||
|
||
---
|
||
|
||
### Requirement: SvgStructuralOptimizer
|
||
|
||
The package SHALL expose an `SvgStructuralOptimizer` class that performs four structural rewrites against a parsed SVG tree: flatten redundant `<g>` wrappers (single-child groups with no styling), collapse identical attribute sets across siblings, hoist common attributes to a parent `<g>`, and merge consecutive `<path>` elements that share `fill`, `stroke`, and `style`. Each rule is independently configurable on `StructuralOptimizerConfig` and the optimiser returns a `StructuralOptimizationResult` with per-rule counts so callers can audit what changed.
|
||
|
||
#### Scenario: single-child redundant `<g>` is flattened
|
||
|
||
- **WHEN** the input contains `<g><path d="…"/></g>` and the parent `<g>` carries no attributes
|
||
- **THEN** the output replaces the wrapper with the inner `<path>` and `result.flattenedGroups === 1`
|
||
|
||
#### Scenario: consecutive identical-style paths merge
|
||
|
||
- **WHEN** two adjacent `<path>` elements have identical `fill`/`stroke`/`style`
|
||
- **THEN** they are merged into one `<path>` whose `d` concatenates the two source `d` strings and `result.mergedPaths === 1`
|
||
|
||
#### Scenario: invalid input raises a stable error code
|
||
|
||
- **WHEN** the input cannot be parsed as SVG
|
||
- **THEN** `optimize` throws `SuaveplanError` with code `COMPRESS_SVG.STRUCTURAL_INVALID_TREE`
|
||
|
||
**Verified by:** `src/structural/svg-structural-optimizer.test.ts::"flattens single-child redundant <g> wrappers"` AND `src/structural/svg-structural-optimizer.test.ts::"merges consecutive paths with identical fill/stroke/style"` AND `src/structural/svg-structural-optimizer.spec-conformance.test.ts::"throws COMPRESS_SVG.STRUCTURAL_INVALID_TREE on parser-rejectable input"`
|
||
|
||
---
|
||
|
||
### Requirement: SvgDeduplicator (cross-file)
|
||
|
||
The package SHALL expose an `SvgDeduplicator` class that performs cross-file content-addressed deduplication: SHA-256 hashes the canonicalised inner content of each repeating subtree, hoists `<symbol id="dedup-{shortHash}">` definitions into a single `<defs>` block at the document root, and replaces each duplicate occurrence with `<use href="#dedup-{shortHash}"/>`. The deduplicator returns a `DeduplicationReport` with `candidatesScanned`, `duplicatesFound`, `bytesSaved`, and a `symbolMap` of short-hash → original subtree.
|
||
|
||
#### Scenario: identical subtrees in different files dedupe to one symbol
|
||
|
||
- **WHEN** two input SVGs each contain the same `<g>…</g>` subtree
|
||
- **THEN** the merged output contains exactly one `<symbol id="dedup-…">` and two `<use href="#dedup-…"/>` references; `report.duplicatesFound === 1`
|
||
|
||
#### Scenario: hash collision raises a stable error code
|
||
|
||
- **WHEN** an injected hash function is configured to return the same digest for two distinct subtrees
|
||
- **THEN** `dedupe` throws `SuaveplanError` with code `COMPRESS_SVG.DEDUPE_HASH_COLLISION`
|
||
|
||
#### Scenario: report carries accurate bytes-saved
|
||
|
||
- **WHEN** dedup runs against a corpus where 100 KB of subtree content is duplicated 4×
|
||
- **THEN** `report.bytesSaved >= 200 KB` (3 of 4 occurrences replaced by short `<use>` references)
|
||
|
||
**Verified by:** `src/deduplicator/svg-deduplicator.test.ts::"merges identical cross-file subtrees into one <symbol> with <use> references"` AND `src/deduplicator/svg-deduplicator.spec-conformance.test.ts::"throws COMPRESS_SVG.DEDUPE_HASH_COLLISION when injected hash collides"` AND `src/deduplicator/svg-deduplicator.test.ts::"DeduplicationReport.bytesSaved reflects the actual byte savings"`
|
||
|
||
---
|
||
|
||
### Requirement: PathTokenizer
|
||
|
||
The package SHALL expose `tokenizePath(d: string): PathToken[]` and `formatTokens(tokens: PathToken[]): string` that round-trip every valid SVG path `d` string. A `PathToken` carries the command letter, its arguments, and a flag indicating whether the command was absolute or relative. The tokeniser SHALL accept every standard SVG path command (`MmLlHhVvCcSsQqTtAaZz`).
|
||
|
||
#### Scenario: round-trip preserves input
|
||
|
||
- **WHEN** `tokenizePath(d)` is called on a valid path string and `formatTokens` is applied to the result
|
||
- **THEN** the output normalises whitespace but parses to the same `PathToken[]` as the input
|
||
|
||
#### Scenario: absolute/relative flag preserved
|
||
|
||
- **WHEN** the input contains both absolute (`M 10 10`) and relative (`m 10 10`) commands
|
||
- **THEN** the corresponding tokens carry `absolute: true` and `absolute: false` respectively
|
||
|
||
#### Scenario: malformed input raises a stable error code
|
||
|
||
- **WHEN** `tokenizePath` is called with a `d` string that is not a valid path
|
||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.PATH_TOKEN_PARSE_FAILED` and the error carries the byte offset of the first invalid character
|
||
|
||
**Verified by:** `src/path-tokenizer/path-tokenizer.test.ts::"round-trips every SVG path command via tokenizePath/formatTokens"` AND `src/path-tokenizer/path-tokenizer.spec-conformance.test.ts::"preserves absolute/relative flag through round-trip"` AND `src/path-tokenizer/path-tokenizer.spec-conformance.test.ts::"throws COMPRESS_SVG.PATH_TOKEN_PARSE_FAILED on malformed d string"`
|
||
|
||
---
|
||
|
||
### Requirement: Binary archive (SvgPacker / SvgUnpacker)
|
||
|
||
The package SHALL expose `SvgPacker` and `SvgUnpacker` classes that produce and consume a binary archive format with a fixed-byte version header, a manifest table mapping logical SVG names to byte ranges, a content-addressed payload table, and a checksum trailer. The on-disk layout matches the legacy `../suaveplan/packages/universal/compress-svg/src/SvgPacker.ts` byte-for-byte so archives produced by genesis can be consumed by legacy tooling and vice versa.
|
||
|
||
#### Scenario: round-trip preserves every input byte
|
||
|
||
- **WHEN** a directory of N SVG documents is packed via `SvgPacker.pack` and then unpacked via `SvgUnpacker.unpack`
|
||
- **THEN** every output SVG is byte-equal to its input
|
||
|
||
#### Scenario: version mismatch raises a stable error code
|
||
|
||
- **WHEN** `SvgUnpacker.unpack` is called against an archive whose version header does not match a supported version
|
||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_VERSION_MISMATCH`
|
||
|
||
#### Scenario: checksum mismatch raises a stable error code
|
||
|
||
- **WHEN** `SvgUnpacker.unpack` is called against an archive whose payload trailer checksum does not match the recomputed payload checksum
|
||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_CHECKSUM_FAILED`
|
||
|
||
#### Scenario: missing payload raises a stable error code
|
||
|
||
- **WHEN** a caller invokes `SvgUnpacker.unpack(archive).get(name)` for a name not present in the manifest
|
||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.ARCHIVE_PAYLOAD_NOT_FOUND`
|
||
|
||
**Verified by:** `src/archive/svg-packer.test.ts::"round-trips a directory of SVGs byte-for-byte through pack/unpack"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_VERSION_MISMATCH on unsupported header"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_CHECKSUM_FAILED on tampered payload"` AND `src/archive/svg-packer.spec-conformance.test.ts::"throws COMPRESS_SVG.ARCHIVE_PAYLOAD_NOT_FOUND on unknown name"`
|
||
|
||
---
|
||
|
||
### Requirement: Browser unpacker
|
||
|
||
The package SHALL expose an `ArchiveUnpacker` class and an `SvgUnpackerBrowser` class under the `./browser` subpath that consume the binary archive format using only browser APIs (`globalThis.crypto.subtle`, `CompressionStream`, `DecompressionStream`, `Web Streams`, typed arrays). The browser subpath SHALL NOT statically import any Node-only API (`Buffer`, `node:fs`, `node:zlib`, `node:crypto`).
|
||
|
||
#### Scenario: archive round-trip via the browser unpacker matches Node
|
||
|
||
- **WHEN** the same archive bytes are unpacked by `SvgUnpacker` (Node) and `SvgUnpackerBrowser` (browser)
|
||
- **THEN** the produced SVG strings are byte-equal
|
||
|
||
#### Scenario: missing browser environment raises a stable error code
|
||
|
||
- **WHEN** `SvgUnpackerBrowser.unpack` is invoked in an environment where `globalThis.crypto?.subtle` or `CompressionStream` is undefined
|
||
- **THEN** it throws `SuaveplanError` with code `COMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT`
|
||
|
||
#### Scenario: static import scan finds no Node-only APIs
|
||
|
||
- **WHEN** the test reads every `.ts` file under `src/browser/` and scans for `Buffer`, `node:fs`, `node:zlib`, `node:crypto` import specifiers
|
||
- **THEN** zero matches are found
|
||
|
||
**Verified by:** `src/browser/svg-unpacker-browser.test.ts::"produces byte-equal output to the Node unpacker for the same archive bytes"` AND `src/browser/svg-unpacker-browser.spec-conformance.test.ts::"throws COMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT when globalThis.crypto.subtle is missing"` AND `src/browser/svg-unpacker-browser.spec-conformance.test.ts::"static import scan finds no Node-only APIs in src/browser"`
|
||
|
||
---
|
||
|
||
### Requirement: CLI bin (`suaveplan-svg-optimize`)
|
||
|
||
The package SHALL ship a `suaveplan-svg-optimize` CLI bin that consumes glob patterns and writes optimised SVGs to disk, to a directory, or to a binary archive. The CLI SHALL accept the flags `--in <glob>`, `--out <dir>`, `--preset <name>`, `--archive <path>`, `--dedupe`, `--structural`, `--dry-run`, `--quiet`, and `--json`. The CLI exits with POSIX-conventional exit codes (0 success, 2 usage error, 1 runtime error).
|
||
|
||
#### Scenario: glob expansion writes one optimised file per match
|
||
|
||
- **WHEN** `--in "fixtures/*.svg" --out out/` matches three files
|
||
- **THEN** three files are written to `out/` and each is byte-smaller than its input
|
||
|
||
#### Scenario: invalid glob raises a stable error code and exits 2
|
||
|
||
- **WHEN** `--in "no-such-glob/*.svg"` matches zero files
|
||
- **THEN** the CLI prints a `SuaveplanError` with code `COMPRESS_SVG.CLI_INVALID_GLOB` and exits with code 2
|
||
|
||
#### Scenario: archive mode produces a valid archive
|
||
|
||
- **WHEN** `--in "fixtures/*.svg" --archive out/bundle.svgar` matches three files
|
||
- **THEN** `SvgUnpacker.unpack(out/bundle.svgar)` produces three SVG strings byte-equal to the inputs
|
||
|
||
**Verified by:** `src/cli/cli.test.ts::"writes one optimised file per glob match into --out directory"` AND `src/cli/cli.spec-conformance.test.ts::"exits 2 with COMPRESS_SVG.CLI_INVALID_GLOB on no-match glob"` AND `src/cli/cli.test.ts::"archive mode produces a valid archive consumable by SvgUnpacker"`
|
||
|
||
<!-- markdownlint-enable MD024 -->
|