12 KiB
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. (Seeopenspec/AGENTS.mdSection 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
OptimizationResultcarriesoriginalBytes > 0,optimizedBytes > 0,pluginsApplied.length >= 1,durationMs >= 0, andratio = optimizedBytes / originalBytes
Scenario: pipeline failure raises a stable error code
- WHEN any plugin in the pipeline throws while
optimizeis running - THEN
optimizerejects withSuaveplanErrorwhosecodeisCOMPRESS_SVG.OPTIMIZER_PIPELINE_FAILEDand whosecauseis 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>andresult.flattenedGroups === 1
Scenario: consecutive identical-style paths merge
- WHEN two adjacent
<path>elements have identicalfill/stroke/style - THEN they are merged into one
<path>whosedconcatenates the two sourcedstrings andresult.mergedPaths === 1
Scenario: invalid input raises a stable error code
- WHEN the input cannot be parsed as SVG
- THEN
optimizethrowsSuaveplanErrorwith codeCOMPRESS_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
dedupethrowsSuaveplanErrorwith codeCOMPRESS_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 andformatTokensis 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: trueandabsolute: falserespectively
Scenario: malformed input raises a stable error code
- WHEN
tokenizePathis called with adstring that is not a valid path - THEN it throws
SuaveplanErrorwith codeCOMPRESS_SVG.PATH_TOKEN_PARSE_FAILEDand 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.packand then unpacked viaSvgUnpacker.unpack - THEN every output SVG is byte-equal to its input
Scenario: version mismatch raises a stable error code
- WHEN
SvgUnpacker.unpackis called against an archive whose version header does not match a supported version - THEN it throws
SuaveplanErrorwith codeCOMPRESS_SVG.ARCHIVE_VERSION_MISMATCH
Scenario: checksum mismatch raises a stable error code
- WHEN
SvgUnpacker.unpackis called against an archive whose payload trailer checksum does not match the recomputed payload checksum - THEN it throws
SuaveplanErrorwith codeCOMPRESS_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
SuaveplanErrorwith codeCOMPRESS_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) andSvgUnpackerBrowser(browser) - THEN the produced SVG strings are byte-equal
Scenario: missing browser environment raises a stable error code
- WHEN
SvgUnpackerBrowser.unpackis invoked in an environment whereglobalThis.crypto?.subtleorCompressionStreamis undefined - THEN it throws
SuaveplanErrorwith codeCOMPRESS_SVG.BROWSER_UNSUPPORTED_ENVIRONMENT
Scenario: static import scan finds no Node-only APIs
- WHEN the test reads every
.tsfile undersrc/browser/and scans forBuffer,node:fs,node:zlib,node:cryptoimport 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
SuaveplanErrorwith codeCOMPRESS_SVG.CLI_INVALID_GLOBand exits with code 2
Scenario: archive mode produces a valid archive
- WHEN
--in "fixtures/*.svg" --archive out/bundle.svgarmatches 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"