Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
<!-- 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 -->