Files
2026-05-27 23:29:53 +01:00

177 lines
12 KiB
Markdown
Raw Permalink 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: 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 -->