Files
suaveplan/openspec/changes/compress-svg-completion/specs/compress-svg/spec.md
2026-05-27 23:29:53 +01:00

12 KiB
Raw Blame History

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"