# 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 `` wrappers (single-child groups with no styling), collapse identical attribute sets across siblings, hoist common attributes to a parent ``, and merge consecutive `` 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 `` is flattened - **WHEN** the input contains `` and the parent `` carries no attributes - **THEN** the output replaces the wrapper with the inner `` and `result.flattenedGroups === 1` #### Scenario: consecutive identical-style paths merge - **WHEN** two adjacent `` elements have identical `fill`/`stroke`/`style` - **THEN** they are merged into one `` 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 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 `` definitions into a single `` block at the document root, and replaces each duplicate occurrence with ``. 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 `…` subtree - **THEN** the merged output contains exactly one `` and two `` 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 `` references) **Verified by:** `src/deduplicator/svg-deduplicator.test.ts::"merges identical cross-file subtrees into one with 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 `, `--out `, `--preset `, `--archive `, `--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"`