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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,174 @@
{
"name": "@suaveplan/compress-svg",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "SVG optimisation and minification: whitespace removal, redundant attribute stripping, path data precision reduction, element collapsing, ID mangling, and SVGO-compatible plugin pipeline",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core/compress-svg": {
"import": {
"types": "./dist/core/compress-svg/index.d.ts",
"default": "./dist/core/compress-svg/index.js"
},
"require": {
"types": "./dist/core/compress-svg/index.d.ts",
"default": "./dist/core/compress-svg/index.js"
}
},
"./parser/svg": {
"import": {
"types": "./dist/parser/svg/index.d.ts",
"default": "./dist/parser/svg/index.js"
},
"require": {
"types": "./dist/parser/svg/index.d.ts",
"default": "./dist/parser/svg/index.js"
}
},
"./plugins/remove-comments": {
"import": {
"types": "./dist/plugins/remove-comments/index.d.ts",
"default": "./dist/plugins/remove-comments/index.js"
},
"require": {
"types": "./dist/plugins/remove-comments/index.d.ts",
"default": "./dist/plugins/remove-comments/index.js"
}
},
"./plugins/remove-metadata": {
"import": {
"types": "./dist/plugins/remove-metadata/index.d.ts",
"default": "./dist/plugins/remove-metadata/index.js"
},
"require": {
"types": "./dist/plugins/remove-metadata/index.d.ts",
"default": "./dist/plugins/remove-metadata/index.js"
}
},
"./plugins/remove-doctype": {
"import": {
"types": "./dist/plugins/remove-doctype/index.d.ts",
"default": "./dist/plugins/remove-doctype/index.js"
},
"require": {
"types": "./dist/plugins/remove-doctype/index.d.ts",
"default": "./dist/plugins/remove-doctype/index.js"
}
},
"./plugins/collapse-groups": {
"import": {
"types": "./dist/plugins/collapse-groups/index.d.ts",
"default": "./dist/plugins/collapse-groups/index.js"
},
"require": {
"types": "./dist/plugins/collapse-groups/index.d.ts",
"default": "./dist/plugins/collapse-groups/index.js"
}
},
"./plugins/cleanup-attrs": {
"import": {
"types": "./dist/plugins/cleanup-attrs/index.d.ts",
"default": "./dist/plugins/cleanup-attrs/index.js"
},
"require": {
"types": "./dist/plugins/cleanup-attrs/index.d.ts",
"default": "./dist/plugins/cleanup-attrs/index.js"
}
},
"./plugins/convert-path-data": {
"import": {
"types": "./dist/plugins/convert-path-data/index.d.ts",
"default": "./dist/plugins/convert-path-data/index.js"
},
"require": {
"types": "./dist/plugins/convert-path-data/index.d.ts",
"default": "./dist/plugins/convert-path-data/index.js"
}
},
"./plugins/convert-transforms": {
"import": {
"types": "./dist/plugins/convert-transforms/index.d.ts",
"default": "./dist/plugins/convert-transforms/index.js"
},
"require": {
"types": "./dist/plugins/convert-transforms/index.d.ts",
"default": "./dist/plugins/convert-transforms/index.js"
}
},
"./plugins/mangle-ids": {
"import": {
"types": "./dist/plugins/mangle-ids/index.d.ts",
"default": "./dist/plugins/mangle-ids/index.js"
},
"require": {
"types": "./dist/plugins/mangle-ids/index.d.ts",
"default": "./dist/plugins/mangle-ids/index.js"
}
},
"./presets": {
"import": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
},
"require": {
"types": "./dist/presets/index.d.ts",
"default": "./dist/presets/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,20 @@
export const DEFAULT_FLOAT_PRECISION = 3;
export const DEFAULT_TRANSFORM_PRECISION = 5;
export const MAX_MULTIPASS_ITERATIONS = 10;
export const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
export const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink";
export const DEFAULT_ATTRS: Record<string, string> = {
fill: "black",
"fill-opacity": "1",
"stroke-opacity": "1",
"stroke-width": "1",
"stroke-linecap": "butt",
"stroke-linejoin": "miter",
"stroke-miterlimit": "4",
opacity: "1",
display: "inline",
visibility: "visible",
overflow: "visible",
};

View File

@@ -0,0 +1,74 @@
# compress-svg — Core SVG Compression Function
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`compressSvg` is the primary entry point for the `@suaveplan/compress-svg` package. It accepts an SVG string and a set of options, runs the SVG through the configured plugin pipeline (optionally in multiple passes until the output stabilises), and returns a structured `CompressResult` with the optimised SVG string and byte-size metrics. The function is instrumented with OpenTelemetry counters and histograms for production observability.
## Features
- Accepts any SVG string and optional `CompressOptions` configuration
- Uses the standard plugin pipeline model: each plugin can transform or remove any node in the tree
- Multipass loop: re-runs plugins until two consecutive passes produce identical output or `MAX_MULTIPASS_ITERATIONS` is reached
- `multipass: false` to run exactly one pass
- Propagates `SvgCompressError(PARSE_FAILED)` from the parser
- Wraps plugin exceptions in `SvgCompressError(PLUGIN_FAILED)` with the original error as `cause`
- Throws `SvgCompressError(INVALID_SVG)` if the plugin pipeline removes all nodes
- OTel: counter `svg_compress_total`, histogram `svg_compress_duration_ms`, histogram `svg_compress_ratio`
- Returns `originalSize`, `compressedSize`, `ratio`, and `passCount` in the result
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><!-- comment --><rect width="100" height="100"/></svg>';
const result = compressSvg(svg);
console.log(result.data); // '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>'
console.log(result.ratio); // e.g. 0.82
console.log(result.passCount); // 2
```
## Usage: Advanced Example
```typescript
import { compressSvg, PRESET_AGGRESSIVE, createMangleIds } from '@suaveplan/compress-svg';
const result = compressSvg(largeSvg, {
plugins: [...PRESET_AGGRESSIVE, createMangleIds()],
floatPrecision: 1,
transformPrecision: 3,
multipass: true,
});
if (result.ratio < 0.5) {
console.log(`Compressed to ${(result.ratio * 100).toFixed(1)}% of original`);
}
```
## API Reference
### `compressSvg(svg: string, options?: CompressOptions): CompressResult`
| Parameter | Type | Description |
|-----------|------|-------------|
| `svg` | `string` | The SVG string to compress |
| `options.plugins` | `SvgPlugin[]` | Plugin pipeline; defaults to `PRESET_DEFAULT` |
| `options.floatPrecision` | `number` | Decimal places for path data; default `3` |
| `options.transformPrecision` | `number` | Decimal places for transforms; default `5` |
| `options.multipass` | `boolean` | Re-run until stable; default `true` |
**Returns:** `CompressResult` — `{ data, originalSize, compressedSize, ratio, passCount }`
**Throws:**
- `SvgCompressError(PARSE_FAILED)` — SVG string is not valid
- `SvgCompressError(PLUGIN_FAILED)` — a plugin threw an exception
- `SvgCompressError(INVALID_SVG)` — the pipeline removed all nodes
## Implementation Notes
The multipass loop terminates when `encoder.encode(serialized).byteLength` equals the previous pass's size, meaning no plugin produced any further change. The maximum iteration count `MAX_MULTIPASS_ITERATIONS` (10) guards against pathological cycles where two plugins reverse each other's changes. Plugin application recurses depth-first: the plugin is applied to each node before its children are visited, so a plugin returning `null` removes the entire subtree without visiting descendants.

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg } from "../../parser/svg/svg.js";
import { convertPathData } from "../../plugins/convert-path-data/convert-path-data.js";
import {
PRESET_AGGRESSIVE,
PRESET_DEFAULT,
PRESET_SAFE,
} from "../../presets/index.js";
import { compressSvg } from "./compress-svg.js";
const BASIC_SVG =
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>';
describe("compressSvg", () => {
it("returns a CompressResult with data, sizes, ratio, passCount", {
timeout: 5000,
}, () => {
const result = compressSvg(BASIC_SVG);
expect(result.data).toBeTruthy();
expect(result.originalSize).toBe(
new TextEncoder().encode(BASIC_SVG).byteLength
);
expect(result.compressedSize).toBeGreaterThan(0);
expect(result.ratio).toBeGreaterThan(0);
expect(result.passCount).toBeGreaterThanOrEqual(1);
});
it("compressed output is valid SVG (parseable)", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG);
expect(() => parseSvg(result.data)).not.toThrow();
});
it("removes comments with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg = '<svg><!-- hello --><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<!--");
});
it("removes metadata with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg = '<svg><metadata><rdf:RDF/></metadata><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<metadata>");
});
it("removes doctype with PRESET_DEFAULT", { timeout: 5000 }, () => {
const svg =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg><circle r="5"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("DOCTYPE");
});
it("collapses single-child groups with PRESET_DEFAULT", {
timeout: 5000,
}, () => {
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<g>");
expect(result.data).toContain("<rect");
});
it("uses PRESET_SAFE which does not include convertTransforms", {
timeout: 5000,
}, () => {
const names = PRESET_SAFE.map((p) => p.name);
expect(names).not.toContain("convertTransforms");
expect(names).not.toContain("mangleIds");
});
it("uses PRESET_AGGRESSIVE which includes mangleIds", {
timeout: 5000,
}, () => {
const names = PRESET_AGGRESSIVE.map((p) => p.name);
expect(names).toContain("mangleIds");
expect(names).toContain("convertTransforms");
});
it("multipass runs until stable", { timeout: 5000 }, () => {
const svg = "<svg><!-- a --><!-- b --><g><g><rect/></g></g></svg>";
const result = compressSvg(svg, {
multipass: true,
plugins: PRESET_DEFAULT,
});
expect(result.passCount).toBeGreaterThanOrEqual(1);
expect(result.data).not.toContain("<!--");
});
it("multipass: false runs exactly one pass", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG, { multipass: false });
expect(result.passCount).toBe(1);
});
it("custom plugin is called", { timeout: 5000 }, () => {
let called = false;
const plugin = {
name: "spy",
apply: (node: unknown) => {
called = true;
return node as never;
},
};
compressSvg(BASIC_SVG, { plugins: [plugin] });
expect(called).toBe(true);
});
it("ratio is compressedSize / originalSize", { timeout: 5000 }, () => {
const result = compressSvg(BASIC_SVG);
expect(result.ratio).toBeCloseTo(
result.compressedSize / result.originalSize,
5
);
});
it("throws PARSE_FAILED on invalid SVG", { timeout: 5000 }, () => {
expect(() => compressSvg("<not valid xml")).toThrow(SvgCompressError);
try {
compressSvg("<not valid xml");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("throws PLUGIN_FAILED when a plugin throws", { timeout: 5000 }, () => {
const badPlugin = {
name: "thrower",
apply: () => {
throw new Error("plugin crashed");
},
};
expect(() => compressSvg(BASIC_SVG, { plugins: [badPlugin] })).toThrow(
SvgCompressError
);
try {
compressSvg(BASIC_SVG, { plugins: [badPlugin] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PLUGIN_FAILED
);
}
});
it("uses floatPrecision option for path data", { timeout: 5000 }, () => {
const svg = '<svg><path d="M 1.123456 2.654321"/></svg>';
const result = compressSvg(svg, {
plugins: [convertPathData],
floatPrecision: 2,
});
expect(result.data).toContain("1.12");
});
it("returns ratio <= 1 for typical SVG with comments removed", {
timeout: 5000,
}, () => {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg"><!-- lots of comments -->' +
"<!-- more text that makes it larger -->" +
'<circle cx="50" cy="50" r="40"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.ratio).toBeLessThanOrEqual(1);
});
it("throws PLUGIN_FAILED when a plugin throws a non-Error value", {
timeout: 5000,
}, () => {
const badPlugin = {
name: "strThrower",
apply: () => {
throw "string error";
},
};
try {
compressSvg(BASIC_SVG, { plugins: [badPlugin] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PLUGIN_FAILED
);
expect((e as SvgCompressError).message).toContain("string error");
}
});
it("throws INVALID_SVG when plugin removes the root element", {
timeout: 5000,
}, () => {
const removeAll = { name: "removeAll", apply: () => null };
expect(() => compressSvg(BASIC_SVG, { plugins: [removeAll] })).toThrow(
SvgCompressError
);
try {
compressSvg(BASIC_SVG, { plugins: [removeAll] });
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.INVALID_SVG
);
}
});
});

View File

@@ -0,0 +1,122 @@
import metrics from "@opentelemetry/api";
import {
DEFAULT_FLOAT_PRECISION,
DEFAULT_TRANSFORM_PRECISION,
MAX_MULTIPASS_ITERATIONS,
} from "../../constants/index.js";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg, serializeSvg } from "../../parser/svg/svg.js";
import { PRESET_DEFAULT } from "../../presets/index.js";
import type {
CompressOptions,
CompressResult,
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
const meter = metrics.metrics.getMeter("@suaveplan/compress-svg");
const compressTotal = meter.createCounter("svg_compress_total");
const compressDuration = meter.createHistogram("svg_compress_duration_ms");
const compressRatio = meter.createHistogram("svg_compress_ratio");
function applyPlugins(
node: SvgNode,
plugins: SvgPlugin[],
options: SvgPluginOptions
): SvgNode | null {
let current: SvgNode | null = node;
for (const plugin of plugins) {
if (current === null) return null;
try {
current = plugin.apply(current, options);
} catch (err) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PLUGIN_FAILED,
`Plugin "${plugin.name}" threw an error: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err }
);
}
}
if (current === null) return null;
if (current.type === "element" && current.children) {
const newChildren: SvgNode[] = [];
for (const child of current.children) {
const result = applyPlugins(child, plugins, options);
if (result !== null) newChildren.push(result);
}
return { ...current, children: newChildren };
}
return current;
}
export function compressSvg(
svg: string,
options?: CompressOptions
): CompressResult {
const start = Date.now();
const plugins = options?.plugins ?? PRESET_DEFAULT;
const floatPrecision = options?.floatPrecision ?? DEFAULT_FLOAT_PRECISION;
const transformPrecision =
options?.transformPrecision ?? DEFAULT_TRANSFORM_PRECISION;
const multipass = options?.multipass !== false;
const encoder = new TextEncoder();
const originalSize = encoder.encode(svg).byteLength;
const pluginOptions: SvgPluginOptions = {
floatPrecision,
transformPrecision,
};
let current = parseSvg(svg);
let passCount = 0;
let serialized = "";
const maxPasses = multipass ? MAX_MULTIPASS_ITERATIONS : 1;
let prevSize = -1;
for (let pass = 0; pass < maxPasses; pass++) {
passCount++;
const result = applyPlugins(current, plugins, pluginOptions);
if (result === null) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.INVALID_SVG,
"All nodes were removed by plugins"
);
}
current = result;
serialized = serializeSvg(current);
const size = encoder.encode(serialized).byteLength;
if (multipass && size === prevSize) break;
prevSize = size;
}
const compressedSize = encoder.encode(serialized).byteLength;
const ratio =
originalSize > 0
? compressedSize / originalSize
: /* istanbul ignore next */ 1;
const duration = Date.now() - start;
compressTotal.add(1);
compressDuration.record(duration);
compressRatio.record(ratio);
return {
data: serialized,
originalSize,
compressedSize,
ratio,
passCount,
};
}

View File

@@ -0,0 +1 @@
export type { CompressOptions, CompressResult } from "../../types/common.js";

View File

@@ -0,0 +1 @@
export { compressSvg } from "./compress-svg.js";

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SVG_COMPRESS_CODES, SvgCompressError } from "./error-codes.js";
describe("SVG_COMPRESS_CODES", () => {
it("exposes all three codes", { timeout: 5000 }, () => {
expect(SVG_COMPRESS_CODES.PARSE_FAILED).toBe(
"SVG_COMPRESS.PARSE_FAILED"
);
expect(SVG_COMPRESS_CODES.INVALID_SVG).toBe("SVG_COMPRESS.INVALID_SVG");
expect(SVG_COMPRESS_CODES.PLUGIN_FAILED).toBe(
"SVG_COMPRESS.PLUGIN_FAILED"
);
});
it("is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(SVG_COMPRESS_CODES)).toBe(true);
});
});
describe("SvgCompressError", () => {
it("has correct name", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"test"
);
expect(err.name).toBe("SvgCompressError");
});
it("sets code and message", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PLUGIN_FAILED,
"plugin error"
);
expect(err.code).toBe(SVG_COMPRESS_CODES.PLUGIN_FAILED);
expect(err.message).toBe("plugin error");
});
it("is instanceof SvgCompressError and Error", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.INVALID_SVG,
"bad svg"
);
expect(err instanceof SvgCompressError).toBe(true);
expect(err instanceof Error).toBe(true);
});
it("accepts cause as Error", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"wrapper",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"wrapper",
{
cause: "string cause",
}
);
expect(err.cause).toBeUndefined();
});
it("accepts context metadata", { timeout: 5000 }, () => {
const err = new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"test",
{
context: { line: 42 },
}
);
expect(err.context).toEqual({ line: 42 });
});
});

View File

@@ -0,0 +1,31 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const SVG_COMPRESS_CODES = defineCodes("SVG_COMPRESS", [
"PARSE_FAILED",
"INVALID_SVG",
"PLUGIN_FAILED",
] as const);
export type SvgCompressErrorCode =
(typeof SVG_COMPRESS_CODES)[keyof typeof SVG_COMPRESS_CODES];
export class SvgCompressError extends SuaveplanError<SvgCompressErrorCode> {
public constructor(
code: SvgCompressErrorCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
const superOptions: {
cause?: Error;
context?: Record<string, unknown>;
} = {};
if (options?.cause instanceof Error) {
superOptions.cause = options.cause;
}
if (options?.context !== undefined) {
superOptions.context = options.context;
}
super(code, message, superOptions);
this.name = "SvgCompressError";
}
}

View File

@@ -0,0 +1,5 @@
export type { SvgCompressErrorCode } from "./error-codes.js";
export {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "./error-codes.js";

View File

@@ -0,0 +1,13 @@
export * from "./core/compress-svg/index.js";
export * from "./error-codes/index.js";
export { parseSvg, serializeSvg } from "./parser/svg/index.js";
export { cleanupAttrs } from "./plugins/cleanup-attrs/index.js";
export { collapseGroups } from "./plugins/collapse-groups/index.js";
export { convertPathData } from "./plugins/convert-path-data/index.js";
export { convertTransforms } from "./plugins/convert-transforms/index.js";
export { createMangleIds, mangleIds } from "./plugins/mangle-ids/index.js";
export { removeComments } from "./plugins/remove-comments/index.js";
export { removeDoctype } from "./plugins/remove-doctype/index.js";
export { removeMetadata } from "./plugins/remove-metadata/index.js";
export * from "./presets/index.js";
export * from "./types/common.js";

View File

@@ -0,0 +1 @@
export { parseSvg, serializeSvg } from "./svg.js";

View File

@@ -0,0 +1,87 @@
# svg-parser — SVG Parser and Serializer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseSvg` and `serializeSvg` are the low-level I/O layer for `@suaveplan/compress-svg`. The parser is a pure-TypeScript push parser that processes SVG strings character-by-character without requiring `DOMParser`, making it safe to use in Node.js, Bun, edge runtimes, and workers. `serializeSvg` produces compact, whitespace-free output optimised for minimum byte count.
## Features
- `parseSvg(svg)` — converts an SVG string to an `SvgNode` tree; no DOM dependency; handles element, text, comment, CDATA, and doctype nodes
- `serializeSvg(node)` — converts an `SvgNode` tree back to a compact SVG string; self-closing tags for empty elements
- Handles XML declarations (`<?xml ...?>`) by silently skipping them (irrelevant to the SVG tree)
- Decodes HTML/XML character entities (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&apos;`, numeric `&#...;` and `&#x...;`)
- Throws `SvgCompressError(SVG_COMPRESS_PARSE_FAILED)` with a descriptive message on malformed input
- Supports both double-quoted and single-quoted attribute values
- Supports boolean (value-less) attributes
## Usage: Basic Example
```typescript
import { parseSvg, serializeSvg } from '@suaveplan/compress-svg';
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"><circle r="40"/></svg>');
console.log(node.name); // 'svg'
console.log(node.children![0]!.name); // 'circle'
const output = serializeSvg(node);
// '<svg xmlns="http://www.w3.org/2000/svg"><circle r="40"/></svg>'
```
## Usage: Advanced Example
```typescript
import { parseSvg, serializeSvg } from '@suaveplan/compress-svg';
import { SvgCompressError, SVG_COMPRESS_CODES } from '@suaveplan/compress-svg';
function safeRoundTrip(svg: string): string | null {
try {
return serializeSvg(parseSvg(svg));
} catch (e) {
if (e instanceof SvgCompressError && e.code === SVG_COMPRESS_CODES.PARSE_FAILED) {
return null;
}
throw e;
}
}
// Handles comments, CDATA, and nested structures
const svg = `
<?xml version="1.0"?>
<!-- icon -->
<svg><![CDATA[raw]]><g><rect/></g></svg>
`;
const output = safeRoundTrip(svg.trim());
// '<svg><![CDATA[raw]]><g><rect/></g></svg>'
```
## API Reference
### `parseSvg(svg: string): SvgNode`
Parses an SVG string into a tree of `SvgNode` objects. The returned node is always the root element. XML declarations are silently skipped. Doctype nodes are captured as children of the virtual root but not returned as the root element — the root is always the first element node.
**Throws:** `SvgCompressError(SVG_COMPRESS_PARSE_FAILED)` when:
- Input is empty or all whitespace
- A tag is left unclosed
- A closing tag does not match the open tag
- There is no root element in the input
### `serializeSvg(node: SvgNode): string`
Serializes a single `SvgNode` (and all its descendants) to a compact SVG string. Empty element nodes (no children) are serialized as self-closing (`<rect/>`). No whitespace or newlines are added between elements.
| Node type | Output format |
|------------|---------------------------------------|
| `element` | `<name attrs>children</name>` or `<name attrs/>` |
| `text` | Raw text value |
| `comment` | `<!--value-->` |
| `cdata` | `<![CDATA[value]]>` |
| `doctype` | `<!DOCTYPE value>` |
## Implementation Notes
The parser is a single-pass, index-advancing loop over the input string. It maintains an explicit stack of open elements rather than recursing, so deeply nested SVGs do not risk stack overflow. Attribute parsing uses a single regex that correctly handles double-quoted, single-quoted, and unquoted values. The `findTagEnd` helper handles `>` characters inside attribute values by tracking quote state. Entity decoding covers the five XML predefined entities plus numeric decimal and hexadecimal code points. The serializer uses a switch statement over node type, making it branch-exhaustive with TypeScript's `never` check at compile time.

View File

@@ -0,0 +1,327 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import { parseSvg, serializeSvg } from "./svg.js";
describe("parseSvg", () => {
it("parses a minimal SVG element", { timeout: 5000 }, () => {
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
expect(node.type).toBe("element");
expect(node.name).toBe("svg");
expect(node.attributes).toEqual({
xmlns: "http://www.w3.org/2000/svg",
});
expect(node.children).toEqual([]);
});
it("parses self-closing elements", { timeout: 5000 }, () => {
const node = parseSvg('<svg><circle cx="50" cy="50" r="40"/></svg>');
expect(node.children).toHaveLength(1);
const circle = node.children![0]!;
expect(circle.type).toBe("element");
expect(circle.name).toBe("circle");
expect(circle.attributes).toEqual({ cx: "50", cy: "50", r: "40" });
expect(circle.children).toEqual([]);
});
it("parses nested elements", { timeout: 5000 }, () => {
const svg =
'<svg><g><rect x="0" y="0" width="100" height="100"/></g></svg>';
const node = parseSvg(svg);
const g = node.children![0]!;
expect(g.name).toBe("g");
const rect = g.children![0]!;
expect(rect.name).toBe("rect");
expect(rect.attributes?.x).toBe("0");
});
it("parses text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><text>Hello world</text></svg>");
const text = node.children![0]!;
expect(text.name).toBe("text");
const textChild = text.children![0]!;
expect(textChild.type).toBe("text");
expect(textChild.value).toBe("Hello world");
});
it("parses XML comments", { timeout: 5000 }, () => {
const node = parseSvg("<svg><!-- a comment --></svg>");
const comment = node.children![0]!;
expect(comment.type).toBe("comment");
expect(comment.value).toBe(" a comment ");
});
it("parses CDATA sections", { timeout: 5000 }, () => {
const node = parseSvg("<svg><![CDATA[raw content]]></svg>");
const cdata = node.children![0]!;
expect(cdata.type).toBe("cdata");
expect(cdata.value).toBe("raw content");
});
it("parses doctype declarations", { timeout: 5000 }, () => {
const input =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg></svg>';
const node = parseSvg(input);
expect(node.type).toBe("element");
expect(node.name).toBe("svg");
});
it("parses XML declaration", { timeout: 5000 }, () => {
const node = parseSvg(
'<?xml version="1.0" encoding="UTF-8"?><svg></svg>'
);
expect(node.name).toBe("svg");
});
it("parses attributes with single quotes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><rect x='10' y='20'/></svg>");
const rect = node.children![0]!;
expect(rect.attributes?.x).toBe("10");
expect(rect.attributes?.y).toBe("20");
});
it("parses attributes with no value (boolean)", { timeout: 5000 }, () => {
const node = parseSvg("<svg><use disabled/></svg>");
const use = node.children![0]!;
expect(use.attributes?.disabled).toBe("");
});
it("parses multiple children", { timeout: 5000 }, () => {
const node = parseSvg(
"<svg><circle r='5'/><rect width='10'/><path d='M0 0'/></svg>"
);
expect(node.children).toHaveLength(3);
});
it("parses whitespace-only text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg>\n <circle r='5'/>\n</svg>");
const nonElement = node.children!.filter((c) => c.type !== "element");
expect(nonElement.every((n) => n.type === "text")).toBe(true);
});
it("handles deeply nested structure", { timeout: 5000 }, () => {
const svg = "<svg><g><g><g><rect/></g></g></g></svg>";
const node = parseSvg(svg);
const inner =
node.children![0]!.children![0]!.children![0]!.children![0]!;
expect(inner.name).toBe("rect");
});
it("throws PARSE_FAILED on unclosed tag", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg><circle>")).toThrow(SvgCompressError);
try {
parseSvg("<svg><circle>");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("throws PARSE_FAILED on mismatched closing tag", {
timeout: 5000,
}, () => {
expect(() => parseSvg("<svg><g></h></svg>")).toThrow(SvgCompressError);
});
it("throws PARSE_FAILED on closing tag with empty stack", {
timeout: 5000,
}, () => {
expect(() => parseSvg("</svg>")).toThrow(SvgCompressError);
try {
parseSvg("</svg>");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
expect((e as SvgCompressError).message).toContain("none");
}
});
it("throws PARSE_FAILED on empty input", { timeout: 5000 }, () => {
expect(() => parseSvg("")).toThrow(SvgCompressError);
});
it("throws PARSE_FAILED on non-SVG input (no root element)", {
timeout: 5000,
}, () => {
expect(() => parseSvg("just text")).toThrow(SvgCompressError);
});
it("parses attributes with namespace prefixes", { timeout: 5000 }, () => {
const node = parseSvg(
'<svg xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="#id"/></svg>'
);
const use = node.children![0]!;
expect(use.attributes?.["xlink:href"]).toBe("#id");
});
it("parses attribute values with encoded entities", {
timeout: 5000,
}, () => {
const node = parseSvg('<svg title="a &amp; b"></svg>');
expect(node.attributes?.title).toBe("a & b");
});
it("parses multiple attributes on same element", { timeout: 5000 }, () => {
const node = parseSvg(
'<svg width="100" height="200" viewBox="0 0 100 200" xmlns="http://www.w3.org/2000/svg"></svg>'
);
expect(node.attributes?.width).toBe("100");
expect(node.attributes?.height).toBe("200");
expect(node.attributes?.viewBox).toBe("0 0 100 200");
});
it("throws PARSE_FAILED on unclosed processing instruction", {
timeout: 5000,
}, () => {
expect(() => parseSvg("<?xml version='1.0'<svg/>")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed comment", { timeout: 5000 }, () => {
expect(() => parseSvg("<!-- unclosed comment")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed CDATA", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg><![CDATA[unclosed")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed DOCTYPE", { timeout: 5000 }, () => {
expect(() => parseSvg("<!DOCTYPE svg unclosed")).toThrow(
SvgCompressError
);
});
it("throws PARSE_FAILED on unclosed closing tag", { timeout: 5000 }, () => {
expect(() => parseSvg("<svg></svg")).toThrow(SvgCompressError);
try {
parseSvg("<svg></svg");
} catch (e) {
expect((e as SvgCompressError).code).toBe(
SVG_COMPRESS_CODES.PARSE_FAILED
);
}
});
it("skips non-DOCTYPE declarations (e.g. <!ENTITY>)", {
timeout: 5000,
}, () => {
const node = parseSvg("<!ENTITY foo bar><svg/>");
expect(node.name).toBe("svg");
});
it("decodes hex character entities (&#xHH;)", { timeout: 5000 }, () => {
const node = parseSvg('<svg title="&#x41;&#x42;"/>');
expect(node.attributes?.title).toBe("AB");
});
it("decodes decimal character entities (&#DD;)", { timeout: 5000 }, () => {
const node = parseSvg('<svg title="&#65;&#66;"/>');
expect(node.attributes?.title).toBe("AB");
});
});
describe("serializeSvg", () => {
it("serializes a minimal SVG element", { timeout: 5000 }, () => {
const node = parseSvg('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
const result = serializeSvg(node);
expect(result).toBe('<svg xmlns="http://www.w3.org/2000/svg"/>');
});
it("serializes self-closing elements with children empty", {
timeout: 5000,
}, () => {
const node = parseSvg('<svg><circle r="5"/></svg>');
const result = serializeSvg(node);
expect(result).toContain('<circle r="5"/>');
});
it("serializes text nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><text>Hello</text></svg>");
const result = serializeSvg(node);
expect(result).toContain("<text>Hello</text>");
});
it("serializes comment nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><!-- note --></svg>");
const result = serializeSvg(node);
expect(result).toContain("<!-- note -->");
});
it("serializes CDATA nodes", { timeout: 5000 }, () => {
const node = parseSvg("<svg><![CDATA[data]]></svg>");
const result = serializeSvg(node);
expect(result).toContain("<![CDATA[data]]>");
});
it("serializes elements with children (non-self-closing)", {
timeout: 5000,
}, () => {
const node = parseSvg("<svg><g><rect/></g></svg>");
const result = serializeSvg(node);
expect(result).toContain("<g><rect/></g>");
});
it("produces compact output (no extra whitespace)", {
timeout: 5000,
}, () => {
const _node = parseSvg("<svg>\n <rect/>\n</svg>");
const result = serializeSvg({
type: "element",
name: "svg",
attributes: {},
children: [
{ type: "element", name: "rect", attributes: {}, children: [] },
],
});
expect(result).toBe("<svg><rect/></svg>");
});
it("round-trips parse → serialize for complex SVG", {
timeout: 5000,
}, () => {
const input =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="layer1"><circle cx="50" cy="50" r="40" fill="red"/><text x="10" y="90">label</text></g></svg>';
const node = parseSvg(input);
const output = serializeSvg(node);
// Re-parse the output and check structure
const reparsed = parseSvg(output);
expect(reparsed.name).toBe("svg");
expect(reparsed.children).toHaveLength(1);
const g = reparsed.children![0]!;
expect(g.name).toBe("g");
expect(g.children).toHaveLength(2);
});
it("serializes a text-type node directly", { timeout: 5000 }, () => {
const textNode = { type: "text" as const, value: "hello" };
expect(serializeSvg(textNode)).toBe("hello");
});
it("serializes a doctype node", { timeout: 5000 }, () => {
const doctype = {
type: "doctype" as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
};
expect(serializeSvg(doctype)).toBe(
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">'
);
});
it("omits undefined attributes", { timeout: 5000 }, () => {
const node = serializeSvg({
type: "element",
name: "svg",
children: [],
});
expect(node).toBe("<svg/>");
});
});

View File

@@ -0,0 +1,254 @@
import {
SVG_COMPRESS_CODES,
SvgCompressError,
} from "../../error-codes/error-codes.js";
import type { SvgNode } from "../../types/common.js";
const HTML_ENTITIES: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
};
function decodeEntities(s: string): string {
return s.replace(/&([^;]+);/g, (_match, name: string) => {
if (name.startsWith("#x")) {
return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
}
if (name.startsWith("#")) {
return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
}
return HTML_ENTITIES[name] ?? /* istanbul ignore next */ `&${name};`;
});
}
function parseAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re =
/([^\s=/"'<>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'`<>=]*)))?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const name = m[1]!;
const val = m[2] ?? m[3] ?? m[4] ?? "";
attrs[name] = decodeEntities(val);
}
return attrs;
}
export function parseSvg(svg: string): SvgNode {
if (!svg.trim()) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Empty input string"
);
}
const stack: SvgNode[] = [];
let pos = 0;
let root: SvgNode | null = null;
function pushNode(node: SvgNode): void {
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push(node);
} else if (!root) {
root = node;
}
}
while (pos < svg.length) {
if (svg[pos] !== "<") {
const end = svg.indexOf("<", pos);
const text = end === -1 ? svg.slice(pos) : svg.slice(pos, end);
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push({ type: "text", value: text });
}
pos = end === -1 ? svg.length : end;
continue;
}
// XML/processing instruction: <?...?>
if (svg.startsWith("<?", pos)) {
const end = svg.indexOf("?>", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed processing instruction"
);
}
pos = end + 2;
continue;
}
// Comment: <!--...-->
if (svg.startsWith("<!--", pos)) {
const end = svg.indexOf("-->", pos + 4);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed comment"
);
}
const value = svg.slice(pos + 4, end);
pushNode({ type: "comment", value });
pos = end + 3;
continue;
}
// CDATA: <![CDATA[...]]>
if (svg.startsWith("<![CDATA[", pos)) {
const end = svg.indexOf("]]>", pos + 9);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed CDATA section"
);
}
const value = svg.slice(pos + 9, end);
pushNode({ type: "cdata", value });
pos = end + 3;
continue;
}
// DOCTYPE: <!...>
if (svg.startsWith("<!", pos)) {
const end = svg.indexOf(">", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed DOCTYPE"
);
}
const inner = svg.slice(pos + 2, end).trim();
if (inner.toUpperCase().startsWith("DOCTYPE")) {
const value = inner.slice(7).trim();
pushNode({ type: "doctype", value });
}
pos = end + 1;
continue;
}
// Closing tag: </name>
if (svg.startsWith("</", pos)) {
const end = svg.indexOf(">", pos + 2);
if (end === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed closing tag"
);
}
const name = svg.slice(pos + 2, end).trim();
const top = stack[stack.length - 1];
if (!top || top.name !== name) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
`Mismatched closing tag: </${name}>, expected </${top?.name ?? "none"}>`
);
}
const closed = stack.pop()!;
if (stack.length === 0) {
root = closed;
}
pos = end + 1;
continue;
}
// Opening tag: <name attrs> or <name attrs/>
const tagEnd = findTagEnd(svg, pos + 1);
if (tagEnd === -1) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"Unclosed opening tag"
);
}
const tagContent = svg.slice(pos + 1, tagEnd);
const selfClosing = tagContent.endsWith("/");
const inner = selfClosing ? tagContent.slice(0, -1) : tagContent;
const spaceIdx = inner.search(/[\s]/);
const name =
spaceIdx === -1 ? inner.trim() : inner.slice(0, spaceIdx).trim();
const attrStr = spaceIdx === -1 ? "" : inner.slice(spaceIdx + 1);
const attributes = parseAttrs(attrStr);
const node: SvgNode = {
type: "element",
name,
attributes,
children: [],
};
if (selfClosing) {
pushNode(node);
} else {
const parent = stack[stack.length - 1];
if (parent) {
(parent.children ??= []).push(node);
}
stack.push(node);
}
pos = tagEnd + 1;
}
if (stack.length > 0) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
`Unclosed elements: ${stack.map((n) => n.name).join(", ")}`
);
}
if (!root) {
throw new SvgCompressError(
SVG_COMPRESS_CODES.PARSE_FAILED,
"No root element found"
);
}
return root;
}
function findTagEnd(svg: string, start: number): number {
let i = start;
let inSingle = false;
let inDouble = false;
while (i < svg.length) {
const ch = svg[i];
if (ch === '"' && !inSingle) inDouble = !inDouble;
else if (ch === "'" && !inDouble) inSingle = !inSingle;
else if (ch === ">" && !inSingle && !inDouble) return i;
i++;
}
return -1;
}
export function serializeSvg(node: SvgNode): string {
switch (node.type) {
case "text":
return node.value ?? /* istanbul ignore next */ "";
case "comment":
return `<!--${node.value ?? /* istanbul ignore next */ ""}-->`;
case "cdata":
return `<![CDATA[${node.value ?? /* istanbul ignore next */ ""}]]>`;
case "doctype":
return `<!DOCTYPE ${node.value ?? /* istanbul ignore next */ ""}>`;
case "element": {
const name = node.name ?? /* istanbul ignore next */ "unknown";
const attrs = node.attributes
? Object.entries(node.attributes)
.map(([k, v]) => `${k}="${v}"`)
.join(" ")
: "";
const attrStr = attrs ? ` ${attrs}` : "";
const children = node.children ?? /* istanbul ignore next */ [];
if (children.length === 0) {
return `<${name}${attrStr}/>`;
}
const childStr = children.map(serializeSvg).join("");
return `<${name}${attrStr}>${childStr}</${name}>`;
}
}
}

View File

@@ -0,0 +1,67 @@
# cleanup-attrs — Remove Redundant SVG Attributes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cleanupAttrs` strips attributes whose values match the SVG specification defaults and trims whitespace from all remaining attribute values. Many SVG authoring tools emit default attribute values explicitly (e.g. `fill="black"`, `opacity="1"`) that carry no information. Removing them reduces file size without changing the rendered output.
## Features
- Removes attributes whose trimmed value matches the SVG default (sourced from `DEFAULT_ATTRS` in constants)
- Removes attributes with empty string values after trimming
- Trims leading/trailing whitespace from all attribute values
- Passes non-element nodes through unchanged
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { cleanupAttrs } from '@suaveplan/compress-svg/plugins/cleanup-attrs';
const svg = '<svg><rect fill="black" opacity="1" x="10" fill-opacity="1"/></svg>';
const result = compressSvg(svg, { plugins: [cleanupAttrs] });
// result.data === '<svg><rect x="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { cleanupAttrs } from '@suaveplan/compress-svg/plugins/cleanup-attrs';
const node = {
type: 'element' as const,
name: 'rect',
attributes: { fill: 'red', opacity: '1', style: '', x: ' 10 ' },
children: [],
};
const result = cleanupAttrs.apply(node, { floatPrecision: 3, transformPrecision: 5 });
// result.attributes === { fill: 'red', x: '10' }
// opacity removed (default), style removed (empty), x trimmed
```
## API Reference
### `cleanupAttrs: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"cleanupAttrs"` |
| `apply` | `(node, options) => SvgNode \| null` |
Iterates over each attribute:
1. Trims the value.
2. Drops the attribute if the trimmed value is empty.
3. Drops the attribute if `DEFAULT_ATTRS[key] === trimmedValue`.
4. Otherwise keeps the attribute with the trimmed value.
**Returns the node unchanged** for non-element nodes.
**Default attributes removed** (partial list): `fill="black"`, `fill-opacity="1"`, `stroke-opacity="1"`, `opacity="1"`, `display="inline"`, `visibility="visible"`, `overflow="visible"`.
## Implementation Notes
The `DEFAULT_ATTRS` constant is defined centrally in `src/constants/index.ts` so all plugins share the same definition. The plugin always returns a new node object with a fresh `attributes` record, even if no attributes were removed — callers should not rely on reference equality after the plugin runs. Only exact-string matches against the default values are checked; no numeric normalisation is performed (e.g. `opacity="1.0"` is not recognised as a default).

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cleanupAttrs } from "./cleanup-attrs.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("cleanupAttrs plugin", () => {
it("removes fill='black' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "black", x: "10" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBeUndefined();
expect(result?.attributes?.x).toBe("10");
});
it("removes fill-opacity='1' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { "fill-opacity": "1" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.["fill-opacity"]).toBeUndefined();
});
it("removes opacity='1' (SVG default)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { opacity: "1" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.opacity).toBeUndefined();
});
it("keeps non-default attribute values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBe("red");
});
it("keeps fill='none'", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "none" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.fill).toBe("none");
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(cleanupAttrs.apply(node, opts)).toBe(node);
});
it("trims whitespace from attribute values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: " 10 " },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.x).toBe("10");
});
it("removes empty string attributes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { style: "" },
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes?.style).toBeUndefined();
});
it("handles element with no attributes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes).toEqual({});
});
it("handles element with undefined attributes", { timeout: 5000 }, () => {
const node = { type: "element" as const, name: "rect", children: [] };
const result = cleanupAttrs.apply(node, opts);
expect(result?.attributes).toEqual({});
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(cleanupAttrs.name).toBe("cleanupAttrs");
});
});

View File

@@ -0,0 +1,24 @@
import { DEFAULT_ATTRS } from "../../constants/index.js";
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const cleanupAttrs: SvgPlugin = {
name: "cleanupAttrs",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const raw = node.attributes ?? {};
const cleaned: Record<string, string> = {};
for (const [key, val] of Object.entries(raw)) {
const trimmed = val.trim();
if (trimmed === "") continue;
if (DEFAULT_ATTRS[key] === trimmed) continue;
cleaned[key] = trimmed;
}
return { ...node, attributes: cleaned };
},
};

View File

@@ -0,0 +1 @@
export { cleanupAttrs } from "./cleanup-attrs.js";

View File

@@ -0,0 +1,68 @@
# collapse-groups — Collapse Redundant SVG Group Elements
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`collapseGroups` removes unnecessary `<g>` elements that wrap a single child by hoisting the child and merging any group attributes onto it. SVG authoring tools often emit groups around individual elements for organisational reasons; when those groups carry no meaningful attributes (or carry only non-conflicting ones), collapsing them reduces element count and serialised size.
## Features
- Collapses `<g>` elements with exactly one child by returning the child directly
- Merges group attributes onto the child (group attrs take lower precedence than child's own attrs)
- Refuses to collapse groups with `id` attributes (the id may be referenced by `<use>` or scripts)
- Refuses to collapse when the child already has an attribute that the group also has (conflict avoidance)
- Passes through all non-element nodes and all elements that are not `<g>` unchanged
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { collapseGroups } from '@suaveplan/compress-svg/plugins/collapse-groups';
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: [collapseGroups] });
// result.data === '<svg><rect width="10" height="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { collapseGroups } from '@suaveplan/compress-svg/plugins/collapse-groups';
// Group with transferable attrs
const group = {
type: 'element' as const,
name: 'g',
attributes: { opacity: '0.5', class: 'layer' },
children: [
{ type: 'element' as const, name: 'rect', attributes: { fill: 'red' }, children: [] },
],
};
const result = collapseGroups.apply(group, { floatPrecision: 3, transformPrecision: 5 });
// result.name === 'rect', result.attributes === { opacity: '0.5', class: 'layer', fill: 'red' }
```
## API Reference
### `collapseGroups: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"collapseGroups"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns the single child** (with merged attrs) when:
- `node.type === 'element'` and `node.name === 'g'`
- `node.attributes` does not contain `id`
- `node.children` has exactly one element-type child
- No attribute key in `node.attributes` conflicts with a key in `child.attributes`
**Returns the node unchanged** in all other cases.
## Implementation Notes
The merge order is `{ ...groupAttrs, ...childAttrs }`, meaning the child's attributes override the group's attributes. In practice, this should never occur because the conflict check guards against it — but the spread order is defined here for clarity. The `id` guard exists because removing a group with an `id` would break `url(#id)` references in `fill`, `filter`, `clip-path`, `mask`, and `<use>` elements; the mangleIds plugin handles those separately.

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { collapseGroups } from "./collapse-groups.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("collapseGroups plugin", () => {
it("collapses a single-child group by hoisting the child", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result).not.toBeNull();
expect(result?.name).toBe("rect");
});
it("merges group attributes onto child when child has no conflicting attrs", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { opacity: "0.5" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.attributes?.opacity).toBe("0.5");
expect(result?.attributes?.fill).toBe("red");
});
it("does not collapse a group with multiple children", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [
{
type: "element" as const,
name: "rect",
attributes: {},
children: [],
},
{
type: "element" as const,
name: "circle",
attributes: {},
children: [],
},
],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
expect(result?.children).toHaveLength(2);
});
it("does not collapse a group with zero children", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse non-group elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
const result = collapseGroups.apply(node, opts);
expect(result).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(collapseGroups.apply(node, opts)).toBe(node);
});
it("does not collapse when child has conflicting attr", {
timeout: 5000,
}, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: { opacity: "1" },
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { opacity: "0.5" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse a group with id attr", { timeout: 5000 }, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const group = {
type: "element" as const,
name: "g",
attributes: { id: "layer1" },
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse a group with undefined children", {
timeout: 5000,
}, () => {
const group = { type: "element" as const, name: "g", attributes: {} };
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("does not collapse group when single child is non-element", {
timeout: 5000,
}, () => {
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [{ type: "text" as const, value: "hello" }],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("g");
});
it("handles group with undefined attributes", { timeout: 5000 }, () => {
const child = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
const group = {
type: "element" as const,
name: "g",
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("rect");
});
it("handles child element with undefined attributes", {
timeout: 5000,
}, () => {
const child = { type: "element" as const, name: "rect", children: [] };
const group = {
type: "element" as const,
name: "g",
attributes: {},
children: [child],
};
const result = collapseGroups.apply(group, opts);
expect(result?.name).toBe("rect");
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(collapseGroups.name).toBe("collapseGroups");
});
});

View File

@@ -0,0 +1,35 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const collapseGroups: SvgPlugin = {
name: "collapseGroups",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element" || node.name !== "g") return node;
const children = node.children ?? [];
if (children.length !== 1) return node;
const child = children[0]!;
if (child.type !== "element") return node;
const groupAttrs = node.attributes ?? {};
// Never collapse if group has an id (may be referenced)
if ("id" in groupAttrs) return node;
const childAttrs = child.attributes ?? {};
// Check for conflicting attributes
for (const key of Object.keys(groupAttrs)) {
if (key in childAttrs) return node;
}
// Merge group attrs onto child
return {
...child,
attributes: { ...groupAttrs, ...childAttrs },
};
},
};

View File

@@ -0,0 +1 @@
export { collapseGroups } from "./collapse-groups.js";

View File

@@ -0,0 +1,59 @@
# convert-path-data — Reduce Float Precision in SVG Path Data
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`convertPathData` reduces the number of decimal places in the `d` attribute of `<path>` elements to the configured `floatPrecision`. Path coordinates output by design tools routinely carry many more decimal places than the display resolution requires — coordinates like `M 10.000000001 20.000000001` are visually indistinguishable from `M 10 20`. Rounding reduces the serialised size of path data proportionally to the number of coordinates and original precision.
## Features
- Rounds all numeric values in the `d` attribute to `floatPrecision` decimal places
- Handles all path commands: `M`, `L`, `C`, `S`, `Q`, `T`, `A`, `Z` and their lowercase variants
- Supports scientific notation in coordinates (`1.5e-4`)
- Passes through non-`<path>` elements unchanged
- Passes through `<path>` elements that have no `d` attribute
- Passes through non-element nodes unchanged
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { convertPathData } from '@suaveplan/compress-svg/plugins/convert-path-data';
const svg = '<svg><path d="M 10.12345678 20.98765432 L 30 40"/></svg>';
const result = compressSvg(svg, { plugins: [convertPathData], floatPrecision: 3 });
// result.data contains 'd="M 10.123 20.988 L 30 40"'
```
## Usage: Advanced Example
```typescript
import { convertPathData } from '@suaveplan/compress-svg/plugins/convert-path-data';
const node = {
type: 'element' as const,
name: 'path',
attributes: { d: 'C 1.111111 2.222222 3.333333 4.444444 5.555555 6.666666' },
children: [],
};
const result = convertPathData.apply(node, { floatPrecision: 2, transformPrecision: 5 });
// result.attributes.d === 'C 1.11 2.22 3.33 4.44 5.56 6.67'
```
## API Reference
### `convertPathData: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"convertPathData"` |
| `apply` | `(node, options) => SvgNode \| null` |
Reads `options.floatPrecision` (integer, 0–20). Applies `Number.toFixed(precision)` to every numeric token in the `d` attribute string, then converts back to the minimal string representation via `parseFloat(n.toFixed(p)).toString()` to strip trailing zeros.
## Implementation Notes
The rounding regex matches the full range of SVG numeric literals including optional sign, decimal point, and scientific-notation exponent. Each matched token is parsed as a 64-bit float, rounded, and re-stringified; no attempt is made to normalise the command letters or collapse redundant moves. The plugin operates purely on the string level, making it independent of any path-command parsing library and keeping the implementation small and auditable.

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertPathData } from "./convert-path-data.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("convertPathData plugin", () => {
it("reduces decimal precision in path d attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 1.23456789 2.98765432 L 10 20" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBe("M 1.235 2.988 L 10 20");
});
it("respects floatPrecision option", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 1.123456 2.654321" },
children: [],
};
const result = convertPathData.apply(node, {
floatPrecision: 2,
transformPrecision: 5,
});
expect(result?.attributes?.d).toBe("M 1.12 2.65");
});
it("does not modify non-path elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: "1.23456789" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(convertPathData.apply(node, opts)).toBe(node);
});
it("handles integer coordinates without modification", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "path",
attributes: { d: "M 10 20 L 30 40" },
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBe("M 10 20 L 30 40");
});
it("handles path with no d attribute", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "path",
attributes: {},
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBeUndefined();
});
it("handles curves (cubic bezier)", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "path",
attributes: {
d: "C 1.111111 2.222222 3.333333 4.444444 5.555555 6.666666",
},
children: [],
};
const result = convertPathData.apply(node, opts);
expect(result?.attributes?.d).toBe(
"C 1.111 2.222 3.333 4.444 5.556 6.667"
);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(convertPathData.name).toBe("convertPathData");
});
});

View File

@@ -0,0 +1,29 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
function roundNumbers(str: string, precision: number): string {
return str.replace(/-?[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/g, (num) => {
const n = Number.parseFloat(num);
const rounded = Number.parseFloat(n.toFixed(precision));
return String(rounded);
});
}
export const convertPathData: SvgPlugin = {
name: "convertPathData",
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element" || node.name !== "path") return node;
const attrs = node.attributes;
if (!attrs?.["d"]) return node;
return {
...node,
attributes: {
...attrs,
d: roundNumbers(attrs["d"], options.floatPrecision),
},
};
},
};

View File

@@ -0,0 +1 @@
export { convertPathData } from "./convert-path-data.js";

View File

@@ -0,0 +1,57 @@
# convert-transforms — Reduce Float Precision in SVG Transform Attributes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`convertTransforms` reduces the decimal precision of numeric values in `transform` attributes to `transformPrecision` decimal places. Transform matrices output by CAD tools, Inkscape, and Adobe Illustrator often carry 10–15 decimal places where 5 is more than sufficient for any practical rendering context. Rounding reduces file size without any visible effect on the final image.
## Features
- Rounds all numeric values in the `transform` attribute to `transformPrecision` decimal places
- Handles all transform function forms: `translate`, `scale`, `rotate`, `skewX`, `skewY`, `matrix`
- Passes through elements without a `transform` attribute unchanged (returns the same reference)
- Passes through non-element nodes unchanged
- Not included in `PRESET_DEFAULT` or `PRESET_SAFE` (only in `PRESET_AGGRESSIVE`) to avoid modifying transforms without explicit opt-in
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { convertTransforms } from '@suaveplan/compress-svg/plugins/convert-transforms';
const svg = '<svg><rect transform="translate(10.123456789 20.987654321)"/></svg>';
const result = compressSvg(svg, { plugins: [convertTransforms], transformPrecision: 3 });
// result.data contains 'transform="translate(10.123 20.988)"'
```
## Usage: Advanced Example
```typescript
import { convertTransforms } from '@suaveplan/compress-svg/plugins/convert-transforms';
const node = {
type: 'element' as const,
name: 'g',
attributes: { transform: 'matrix(1.000001 0.000001 -0.000001 1.000001 10.123456 20.987654)' },
children: [],
};
const result = convertTransforms.apply(node, { floatPrecision: 3, transformPrecision: 2 });
// result.attributes.transform === 'matrix(1 0 0 1 10.12 20.99)'
```
## API Reference
### `convertTransforms: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"convertTransforms"` |
| `apply` | `(node, options) => SvgNode \| null` |
Reads `options.transformPrecision` (separate from `floatPrecision` because transforms often need more decimal places than path coordinates). Applies `parseFloat(n.toFixed(precision)).toString()` to every numeric token in the `transform` attribute string.
## Implementation Notes
The rounding implementation is identical to `convertPathData` except it operates on the `transform` attribute and reads `transformPrecision` instead of `floatPrecision`. Both plugins share a local `roundNumbers` helper function. The separation into two distinct plugins allows callers to apply path-data rounding without touching transforms (useful when animating transforms with JavaScript that expects exact values).

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { convertTransforms } from "./convert-transforms.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("convertTransforms plugin", () => {
it("reduces decimal precision in transform attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(1.123456789 2.987654321)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe(
"translate(1.12346 2.98765)"
);
});
it("respects transformPrecision option", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(1.123456 2.654321)" },
children: [],
};
const result = convertTransforms.apply(node, {
floatPrecision: 3,
transformPrecision: 2,
});
expect(result?.attributes?.transform).toBe("translate(1.12 2.65)");
});
it("passes through elements without transform attribute", {
timeout: 5000,
}, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { x: "10" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result).toBe(node);
});
it("handles integer transform values", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "translate(10 20)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe("translate(10 20)");
});
it("handles scale transform", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: { transform: "scale(1.123456789)" },
children: [],
};
const result = convertTransforms.apply(node, opts);
expect(result?.attributes?.transform).toBe("scale(1.12346)");
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(convertTransforms.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(convertTransforms.name).toBe("convertTransforms");
});
});

View File

@@ -0,0 +1,32 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
function roundNumbers(str: string, precision: number): string {
return str.replace(/-?[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/g, (num) => {
const n = Number.parseFloat(num);
const rounded = Number.parseFloat(n.toFixed(precision));
return String(rounded);
});
}
export const convertTransforms: SvgPlugin = {
name: "convertTransforms",
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const attrs = node.attributes;
if (!attrs?.["transform"]) return node;
return {
...node,
attributes: {
...attrs,
transform: roundNumbers(
attrs["transform"],
options.transformPrecision
),
},
};
},
};

View File

@@ -0,0 +1 @@
export { convertTransforms } from "./convert-transforms.js";

View File

@@ -0,0 +1 @@
export { createMangleIds, mangleIds } from "./mangle-ids.js";

View File

@@ -0,0 +1,67 @@
# mangle-ids — Replace Element IDs with Short Sequential Identifiers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`mangleIds` replaces verbose `id` attribute values with short sequential identifiers (`a`, `b`, ..., `z`, `A`, ..., `Z`, `aa`, `ab`, ...) and rewrites all `href` and `xlink:href` reference attributes that point to the old IDs. This significantly reduces the size of SVGs that use many named elements for gradients, patterns, clip paths, filters, or `<use>` references.
## Features
- Replaces `id` attribute values with the shortest possible unique identifier
- Rewrites `href="#originalId"` and `xlink:href="#originalId"` to point to the new mangled ID
- Stateful per-instance ID map — create a fresh instance per compression run via `createMangleIds()`
- The exported `mangleIds` singleton is suitable for single-pass use; use `createMangleIds()` in test or multi-run scenarios
- Only in `PRESET_AGGRESSIVE`; opt-in only because mangling IDs breaks external CSS selectors that target the SVG by ID
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { createMangleIds } from '@suaveplan/compress-svg/plugins/mangle-ids';
const svg = '<svg><defs><linearGradient id="myGradient"/></defs><rect fill="url(#myGradient)"/></svg>';
const result = compressSvg(svg, { plugins: [createMangleIds()] });
// IDs in result.data are short: id="a", not id="myGradient"
```
## Usage: Advanced Example
```typescript
import { createMangleIds } from '@suaveplan/compress-svg/plugins/mangle-ids';
const plugin = createMangleIds();
const opts = { floatPrecision: 3, transformPrecision: 5 };
const def = { type: 'element' as const, name: 'path', attributes: { id: 'clipPath1' }, children: [] };
const use = { type: 'element' as const, name: 'use', attributes: { href: '#clipPath1' }, children: [] };
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef!.attributes!.id; // e.g. 'a'
// mangledUse.attributes.href === '#a'
```
## API Reference
### `createMangleIds(): SvgPlugin`
Factory function that returns a new stateful `mangleIds` plugin instance. Each call creates an independent ID counter and map, so multiple independent SVGs can be compressed without ID collisions.
### `mangleIds: SvgPlugin`
Pre-created singleton instance. Suitable for single-use scenarios where a fresh instance is created per module load.
| Property | Value |
|----------|-------|
| `name` | `"mangleIds"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Rewrites `id`** on element nodes to the next sequential short identifier.
**Rewrites `href`** and `xlink:href`** on element nodes when the value is a fragment reference (`#...`).
## Implementation Notes
Short IDs are generated with a base-52 alphabet (lowercase `a–z` then uppercase `A–Z`). The sequence is 0→`a`, 1→`b`, ..., 25→`z`, 26→`A`, ..., 51→`Z`, 52→`aa`, 53→`ab`, etc. This avoids numeric IDs (which are invalid XML `id` values starting with a digit) and produces the shortest possible identifiers. The ID map is keyed by original ID string, so multiple nodes with the same original ID receive the same mangled ID — though well-formed SVG should not have duplicate IDs.

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createMangleIds } from "./mangle-ids.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("mangleIds plugin", () => {
it("replaces id attribute with a short sequential id", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "rect",
attributes: { id: "longDescriptiveName" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.id).toBeDefined();
expect((result?.attributes?.id ?? "").length).toBeLessThan(
"longDescriptiveName".length
);
});
it("produces sequential ids for multiple nodes", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node1 = {
type: "element" as const,
name: "rect",
attributes: { id: "alpha" },
children: [],
};
const node2 = {
type: "element" as const,
name: "circle",
attributes: { id: "beta" },
children: [],
};
const r1 = plugin.apply(node1, opts);
const r2 = plugin.apply(node2, opts);
expect(r1?.attributes?.id).not.toBe(r2?.attributes?.id);
});
it("passes through nodes without id attribute", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "rect",
attributes: { fill: "red" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.id).toBeUndefined();
});
it("passes through element nodes with no attributes field", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = { type: "element" as const, name: "rect", children: [] };
expect(plugin.apply(node, opts)).toBe(node);
});
it("passes through non-element nodes", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const node = { type: "text" as const, value: "hello" };
expect(plugin.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
const plugin = createMangleIds();
expect(plugin.name).toBe("mangleIds");
});
it("rewrites href references to mangled ids", { timeout: 5000 }, () => {
const plugin = createMangleIds();
const def = {
type: "element" as const,
name: "path",
attributes: { id: "originalId" },
children: [],
};
const use = {
type: "element" as const,
name: "use",
attributes: { href: "#originalId" },
children: [],
};
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef?.attributes?.id ?? "";
expect(mangledUse?.attributes?.href).toBe(`#${newId}`);
});
it("rewrites xlink:href references to mangled ids", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const def = {
type: "element" as const,
name: "path",
attributes: { id: "myId" },
children: [],
};
const use = {
type: "element" as const,
name: "use",
attributes: { "xlink:href": "#myId" },
children: [],
};
const mangledDef = plugin.apply(def, opts);
const mangledUse = plugin.apply(use, opts);
const newId = mangledDef?.attributes?.id ?? "";
expect(mangledUse?.attributes?.["xlink:href"]).toBe(`#${newId}`);
});
it("does not rewrite xlink:href that does not start with #", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const node = {
type: "element" as const,
name: "use",
attributes: { "xlink:href": "relative-url.svg" },
children: [],
};
const result = plugin.apply(node, opts);
expect(result?.attributes?.["xlink:href"]).toBe("relative-url.svg");
});
it("generates multi-character ids after 52 unique ids", {
timeout: 5000,
}, () => {
const plugin = createMangleIds();
const ids: string[] = [];
for (let i = 0; i < 54; i++) {
const node = {
type: "element" as const,
name: "rect",
attributes: { id: `original${i}` },
children: [],
};
const result = plugin.apply(node, opts);
ids.push(result?.attributes?.id ?? "");
}
// After 52 single-char IDs, next ones are multi-char
const multiChar = ids.slice(52);
expect(multiChar.every((id) => id.length > 1)).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function toShortId(n: number): string {
if (n < CHARS.length) return CHARS[n]!;
return (
toShortId(Math.floor(n / CHARS.length) - 1) + CHARS[n % CHARS.length]!
);
}
export function createMangleIds(): SvgPlugin {
const idMap = new Map<string, string>();
let counter = 0;
function mangle(originalId: string): string {
const existing = idMap.get(originalId);
if (existing !== undefined) return existing;
const newId = toShortId(counter++);
idMap.set(originalId, newId);
return newId;
}
function rewriteRef(ref: string): string {
const originalId = ref.slice(1);
return `#${mangle(originalId)}`;
}
return {
name: "mangleIds",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type !== "element") return node;
const attrs = node.attributes;
if (!attrs) return node;
let changed = false;
const next: Record<string, string> = { ...attrs };
if (next["id"]) {
next["id"] = mangle(next["id"]);
changed = true;
}
if (next["href"]?.startsWith("#")) {
next["href"] = rewriteRef(next["href"]);
changed = true;
}
if (next["xlink:href"]?.startsWith("#")) {
next["xlink:href"] = rewriteRef(next["xlink:href"]!);
changed = true;
}
return changed ? { ...node, attributes: next } : node;
},
};
}
export const mangleIds: SvgPlugin = createMangleIds();

View File

@@ -0,0 +1 @@
export { removeComments } from "./remove-comments.js";

View File

@@ -0,0 +1,60 @@
# remove-comments — Remove XML Comment Nodes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeComments` is a stateless SVG plugin that removes all XML comment nodes (`<!-- ... -->`) from the SVG tree. Comments are human-readable annotations with no visual effect on the rendered image, so they can be safely eliminated to reduce file size. This plugin is included in all three built-in presets.
## Features
- Removes any `SvgNode` with `type: 'comment'` by returning `null`
- Passes all other node types through unchanged (elements, text, CDATA, doctype)
- Stateless — safe to share across multiple compression passes and parallel uses
- Zero configuration — no options are read from `SvgPluginOptions`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeComments } from '@suaveplan/compress-svg/plugins/remove-comments';
const svg = '<svg><!-- this is a comment --><rect width="100"/></svg>';
const result = compressSvg(svg, { plugins: [removeComments] });
// result.data === '<svg><rect width="100"/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeComments } from '@suaveplan/compress-svg/plugins/remove-comments';
import type { SvgNode, SvgPluginOptions } from '@suaveplan/compress-svg';
const opts: SvgPluginOptions = { floatPrecision: 3, transformPrecision: 5 };
const commentNode: SvgNode = { type: 'comment', value: ' copyright 2024 ' };
const result = removeComments.apply(commentNode, opts);
// result === null (node is removed)
const elementNode: SvgNode = { type: 'element', name: 'svg', children: [] };
const kept = removeComments.apply(elementNode, opts);
// kept === elementNode (same reference)
```
## API Reference
### `removeComments: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeComments"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'comment'`, causing the plugin pipeline to remove the node from its parent's children.
**Returns the node unchanged** for all other node types.
## Implementation Notes
The plugin is a plain object literal satisfying the `SvgPlugin` interface. Because it holds no mutable state, it is safe to use as a module-level singleton and to include in multiple preset arrays without aliasing hazards. The `apply` method does not read any `SvgPluginOptions` fields because comment removal is unconditional — there is no configurable threshold or condition for keeping comments.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeComments } from "./remove-comments.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeComments plugin", () => {
it("removes comment nodes", { timeout: 5000 }, () => {
const node = { type: "comment" as const, value: " a comment " };
expect(removeComments.apply(node, opts)).toBeNull();
});
it("passes through element nodes unchanged", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "rect",
attributes: {},
children: [],
};
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through text nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through cdata nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "cdata" as const, value: "data" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("passes through doctype nodes unchanged", { timeout: 5000 }, () => {
const node = { type: "doctype" as const, value: "svg" };
expect(removeComments.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeComments.name).toBe("removeComments");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeComments: SvgPlugin = {
name: "removeComments",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "comment") return null;
return node;
},
};

View File

@@ -0,0 +1 @@
export { removeDoctype } from "./remove-doctype.js";

View File

@@ -0,0 +1,56 @@
# remove-doctype — Remove DOCTYPE Declarations
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeDoctype` is a stateless SVG plugin that removes `doctype` nodes from the SVG tree. DOCTYPE declarations are required only for XML validation and are not needed by browsers or SVG renderers processing inline SVG. Removing them reduces file size and eliminates external DTD references that can slow down parsing.
## Features
- Removes `SvgNode` instances with `type: 'doctype'` by returning `null`
- Passes all other node types through unchanged
- Stateless, no configuration required
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeDoctype } from '@suaveplan/compress-svg/plugins/remove-doctype';
const svg = '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "..."><svg><rect/></svg>';
const result = compressSvg(svg, { plugins: [removeDoctype] });
// result.data === '<svg><rect/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeDoctype } from '@suaveplan/compress-svg/plugins/remove-doctype';
const doctype = {
type: 'doctype' as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"',
};
const result = removeDoctype.apply(doctype, { floatPrecision: 3, transformPrecision: 5 });
// result === null
```
## API Reference
### `removeDoctype: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeDoctype"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'doctype'`.
**Returns the node unchanged** for all other nodes.
## Implementation Notes
DOCTYPE nodes are typically at the top level, not nested inside elements. The `parseSvg` parser adds them as children of a temporary root container before finding the real SVG root element, so the compressor's tree walk will still encounter them. This plugin removes them before `serializeSvg` runs, ensuring clean output with no DOCTYPE declaration.

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeDoctype } from "./remove-doctype.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeDoctype plugin", () => {
it("removes doctype nodes", { timeout: 5000 }, () => {
const node = {
type: "doctype" as const,
value: 'svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
};
expect(removeDoctype.apply(node, opts)).toBeNull();
});
it("passes through element nodes", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
expect(removeDoctype.apply(node, opts)).toBe(node);
});
it("passes through text nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeDoctype.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeDoctype.name).toBe("removeDoctype");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeDoctype: SvgPlugin = {
name: "removeDoctype",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "doctype") return null;
return node;
},
};

View File

@@ -0,0 +1 @@
export { removeMetadata } from "./remove-metadata.js";

View File

@@ -0,0 +1,57 @@
# remove-metadata — Remove SVG Metadata Elements
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`removeMetadata` is a stateless SVG plugin that removes `<metadata>` elements from the SVG tree. Metadata elements carry machine-readable annotations (RDF, Dublin Core, license info) that are invisible to renderers and not needed in production assets. Removing them reduces file size without any visual change.
## Features
- Removes `SvgNode` elements with `name: 'metadata'` by returning `null`
- Passes all other node types through unchanged
- Stateless singleton safe for multi-pass use and preset sharing
- Included in `PRESET_DEFAULT`, `PRESET_SAFE`, and `PRESET_AGGRESSIVE`
## Usage: Basic Example
```typescript
import { compressSvg } from '@suaveplan/compress-svg';
import { removeMetadata } from '@suaveplan/compress-svg/plugins/remove-metadata';
const svg = '<svg><metadata><dc:title>My Icon</dc:title></metadata><circle r="10"/></svg>';
const result = compressSvg(svg, { plugins: [removeMetadata] });
// result.data === '<svg><circle r="10"/></svg>'
```
## Usage: Advanced Example
```typescript
import { removeMetadata } from '@suaveplan/compress-svg/plugins/remove-metadata';
const node = {
type: 'element' as const,
name: 'metadata',
children: [{ type: 'element' as const, name: 'rdf:RDF', children: [] }],
};
const result = removeMetadata.apply(node, { floatPrecision: 3, transformPrecision: 5 });
// result === null (entire subtree removed)
```
## API Reference
### `removeMetadata: SvgPlugin`
| Property | Value |
|----------|-------|
| `name` | `"removeMetadata"` |
| `apply` | `(node, options) => SvgNode \| null` |
**Returns `null`** when `node.type === 'element' && node.name === 'metadata'`.
**Returns the node unchanged** for all other nodes.
## Implementation Notes
The plugin targets only the `<metadata>` element itself. Because the compressor's `applyPlugins` function passes the `<metadata>` element to the plugin before recursing into its children, returning `null` removes the entire subtree in one step — the children never need to be visited. This makes metadata removal O(1) in the size of the metadata subtree.

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { removeMetadata } from "./remove-metadata.js";
const opts = { floatPrecision: 3, transformPrecision: 5 };
describe("removeMetadata plugin", () => {
it("removes metadata elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "metadata",
attributes: {},
children: [],
};
expect(removeMetadata.apply(node, opts)).toBeNull();
});
it("passes through non-metadata elements", { timeout: 5000 }, () => {
const node = {
type: "element" as const,
name: "svg",
attributes: {},
children: [],
};
expect(removeMetadata.apply(node, opts)).toBe(node);
});
it("passes through text nodes", { timeout: 5000 }, () => {
const node = { type: "text" as const, value: "hello" };
expect(removeMetadata.apply(node, opts)).toBe(node);
});
it("has correct plugin name", { timeout: 5000 }, () => {
expect(removeMetadata.name).toBe("removeMetadata");
});
});

View File

@@ -0,0 +1,12 @@
import type {
SvgNode,
SvgPlugin,
SvgPluginOptions,
} from "../../types/common.js";
export const removeMetadata: SvgPlugin = {
name: "removeMetadata",
apply(node: SvgNode, _options: SvgPluginOptions): SvgNode | null {
if (node.type === "element" && node.name === "metadata") return null;
return node;
},
};

View File

@@ -0,0 +1,37 @@
import { cleanupAttrs } from "../plugins/cleanup-attrs/index.js";
import { collapseGroups } from "../plugins/collapse-groups/index.js";
import { convertPathData } from "../plugins/convert-path-data/index.js";
import { convertTransforms } from "../plugins/convert-transforms/index.js";
import { mangleIds } from "../plugins/mangle-ids/index.js";
import { removeComments } from "../plugins/remove-comments/index.js";
import { removeDoctype } from "../plugins/remove-doctype/index.js";
import { removeMetadata } from "../plugins/remove-metadata/index.js";
import type { SvgPlugin } from "../types/common.js";
export const PRESET_DEFAULT: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
convertPathData,
];
export const PRESET_SAFE: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
];
export const PRESET_AGGRESSIVE: SvgPlugin[] = [
removeComments,
removeMetadata,
removeDoctype,
collapseGroups,
cleanupAttrs,
convertPathData,
convertTransforms,
mangleIds,
];

View File

@@ -0,0 +1,102 @@
# common — Shared Types and Zod Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public type surface for `@suaveplan/compress-svg`. All interfaces, Zod schemas, and option bundles live here so that consumers can import types independently of the implementation. Using a centralised types module avoids circular imports between the parser, plugin system, and core compressor.
## Features
- `SvgNode` — recursive tree node representing any part of an SVG document: elements, text, comments, CDATA sections, and doctypes
- `SvgPlugin` — the single-function contract every compression plugin must satisfy; returning `null` removes the node from the tree
- `SvgPluginOptions` — shared options passed to every plugin during a compression pass
- `CompressOptions` — top-level user-facing configuration for `compressSvg`; all fields optional with sensible defaults
- `CompressResult` — structured result returned by `compressSvg` with size metrics and pass count
- `CompressOptionsSchema` — Zod schema for validating user-supplied `CompressOptions` at runtime boundaries
- `CompressResultSchema` — Zod schema for validating `CompressResult` at serialisation/deserialisation boundaries
## Usage: Basic Example
```typescript
import type { SvgNode, SvgPlugin, CompressOptions } from '@suaveplan/compress-svg';
const myPlugin: SvgPlugin = {
name: 'my-plugin',
apply(node: SvgNode): SvgNode | null {
if (node.type === 'comment') return null;
return node;
},
};
const options: CompressOptions = {
floatPrecision: 2,
multipass: true,
plugins: [myPlugin],
};
```
## Usage: Advanced Example
```typescript
import { CompressOptionsSchema, CompressResultSchema } from '@suaveplan/compress-svg';
function parseOptions(raw: unknown) {
return CompressOptionsSchema.parse(raw);
}
function validateResult(raw: unknown) {
return CompressResultSchema.parse(raw);
}
const opts = parseOptions({ floatPrecision: 3, mangleIds: true });
```
## API Reference
### `interface SvgNode`
| Field | Type | Description |
|--------------|--------------------------------|------------------------------------------|
| `type` | `'element' \| 'text' \| 'comment' \| 'cdata' \| 'doctype'` | Node discriminant |
| `name` | `string \| undefined` | Element tag name (for `element` nodes) |
| `attributes` | `Record<string, string> \| undefined` | Element attributes |
| `children` | `SvgNode[] \| undefined` | Child nodes (for `element` nodes) |
| `value` | `string \| undefined` | Text content (for text/comment/cdata/doctype) |
### `interface SvgPlugin`
| Field | Type | Description |
|----------|------------------------------------------------|-------------------------------|
| `name` | `string` | Plugin identifier |
| `apply` | `(node, options) => SvgNode \| null` | Transform or remove the node |
### `interface CompressOptions`
| Field | Type | Default | Description |
|---------------------|-----------------|----------|---------------------------------------|
| `plugins` | `SvgPlugin[]` | standard | Override the full plugin pipeline |
| `floatPrecision` | `number` | `3` | Decimal places for path coordinates |
| `transformPrecision`| `number` | `5` | Decimal places for transform values |
| `multipass` | `boolean` | `true` | Re-run until output size stabilises |
| `removeComments` | `boolean` | `true` | Enable `removeComments` plugin |
| `removeMetadata` | `boolean` | `true` | Enable `removeMetadata` plugin |
| `removeDoctype` | `boolean` | `true` | Enable `removeDoctype` plugin |
| `collapseGroups` | `boolean` | `true` | Enable `collapseGroups` plugin |
| `mergeStyles` | `boolean` | `true` | Enable `mergeStyles` plugin |
| `mangleIds` | `boolean` | `false` | Enable `mangleIds` plugin |
### `interface CompressResult`
| Field | Type | Description |
|------------------|----------|------------------------------------------|
| `data` | `string` | Optimised SVG string |
| `originalSize` | `number` | Byte length of input |
| `compressedSize` | `number` | Byte length of output |
| `ratio` | `number` | `compressedSize / originalSize` |
| `passCount` | `number` | Number of multipass iterations performed |
## Implementation Notes
`CompressOptionsSchema` uses `.strict()` to reject unknown keys at validation time, preventing silent misconfiguration. The `plugins` field is intentionally omitted from the Zod schema because `SvgPlugin` contains function values that are not Zod-serialisable — callers pass plugin arrays directly through the TypeScript interface. `CompressResultSchema` validates the numeric invariants (non-negative sizes, positive pass count) that the core compressor always satisfies but which downstream consumers may need to re-validate after deserialization.

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CompressOptionsSchema, CompressResultSchema } from "./common.js";
describe("CompressOptionsSchema", () => {
it("accepts empty options object", { timeout: 5000 }, () => {
expect(() => CompressOptionsSchema.parse({})).not.toThrow();
});
it("accepts valid options", { timeout: 5000 }, () => {
const result = CompressOptionsSchema.parse({
floatPrecision: 3,
transformPrecision: 5,
multipass: true,
removeComments: false,
removeMetadata: true,
removeDoctype: true,
collapseGroups: false,
mergeStyles: true,
mangleIds: false,
});
expect(result.floatPrecision).toBe(3);
expect(result.multipass).toBe(true);
});
it("rejects unknown keys (strict)", { timeout: 5000 }, () => {
expect(() =>
CompressOptionsSchema.parse({ unknownKey: true })
).toThrow();
});
it("rejects out-of-range floatPrecision", { timeout: 5000 }, () => {
expect(() =>
CompressOptionsSchema.parse({ floatPrecision: 25 })
).toThrow();
expect(() =>
CompressOptionsSchema.parse({ floatPrecision: -1 })
).toThrow();
});
});
describe("CompressResultSchema", () => {
it("accepts valid result", { timeout: 5000 }, () => {
const result = CompressResultSchema.parse({
data: "<svg/>",
originalSize: 100,
compressedSize: 80,
ratio: 0.8,
passCount: 2,
});
expect(result.ratio).toBe(0.8);
expect(result.passCount).toBe(2);
});
it("rejects negative originalSize", { timeout: 5000 }, () => {
expect(() =>
CompressResultSchema.parse({
data: "<svg/>",
originalSize: -1,
compressedSize: 80,
ratio: 0.8,
passCount: 1,
})
).toThrow();
});
it("rejects zero passCount", { timeout: 5000 }, () => {
expect(() =>
CompressResultSchema.parse({
data: "<svg/>",
originalSize: 100,
compressedSize: 80,
ratio: 0.8,
passCount: 0,
})
).toThrow();
});
});

View File

@@ -0,0 +1,62 @@
import z from "zod";
export interface SvgNode {
type: "element" | "text" | "comment" | "cdata" | "doctype";
name?: string;
attributes?: Record<string, string>;
children?: SvgNode[];
value?: string;
}
export interface SvgPluginOptions {
floatPrecision: number;
transformPrecision: number;
}
export interface SvgPlugin {
name: string;
apply(node: SvgNode, options: SvgPluginOptions): SvgNode | null;
}
export interface CompressOptions {
plugins?: SvgPlugin[];
floatPrecision?: number;
transformPrecision?: number;
multipass?: boolean;
removeComments?: boolean;
removeMetadata?: boolean;
removeDoctype?: boolean;
collapseGroups?: boolean;
mergeStyles?: boolean;
mangleIds?: boolean;
}
export interface CompressResult {
data: string;
originalSize: number;
compressedSize: number;
ratio: number;
passCount: number;
}
export const CompressOptionsSchema = z
.object({
floatPrecision: z.number().int().min(0).max(20).optional(),
transformPrecision: z.number().int().min(0).max(20).optional(),
multipass: z.boolean().optional(),
removeComments: z.boolean().optional(),
removeMetadata: z.boolean().optional(),
removeDoctype: z.boolean().optional(),
collapseGroups: z.boolean().optional(),
mergeStyles: z.boolean().optional(),
mangleIds: z.boolean().optional(),
})
.strict();
export const CompressResultSchema = z.object({
data: z.string(),
originalSize: z.number().nonnegative(),
compressedSize: z.number().nonnegative(),
ratio: z.number().nonnegative(),
passCount: z.number().int().positive(),
});

View File

@@ -0,0 +1,130 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compressSvg } from "../src/core/compress-svg/compress-svg.js";
import { parseSvg } from "../src/parser/svg/svg.js";
import {
PRESET_AGGRESSIVE,
PRESET_DEFAULT,
PRESET_SAFE,
} from "../src/presets/index.js";
const REAL_SVG_ICON = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- This is an SVG icon -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24" width="24" height="24" fill="black" opacity="1">
<metadata>
<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Home Icon</dc:title>
</metadata>
<!-- house shape -->
<g id="layer1">
<g>
<path d="M 10.000000001 20.0000000009 L 14.000000001 20.0000000009 L 14.000000001 15.0000000009 L 10.000000001 15.0000000009 Z" fill="black" opacity="1"/>
</g>
</g>
<path d="M 12 3.0000000009 L 2.0000000001 12 L 4.0000000001 12 L 4.0000000001 21 L 10.000000001 21 L 10.000000001 15 L 14 15 L 14 21 L 20 21 L 20 12 L 22 12 Z" fill-opacity="1" stroke-opacity="1"/>
</svg>`;
const MINIMAL_SVG =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
describe("integration: PRESET_DEFAULT", () => {
it("compresses real SVG icon and reduces size", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.ratio).toBeLessThanOrEqual(1);
expect(result.data).not.toContain("<!--");
expect(result.data).not.toContain("DOCTYPE");
expect(result.data).not.toContain("<metadata>");
});
it("removes XML declaration", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<?xml");
});
it("output is parseable after compression", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(() => parseSvg(result.data)).not.toThrow();
});
it("reduces float precision in path data", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, {
plugins: PRESET_DEFAULT,
floatPrecision: 1,
});
expect(result.data).not.toMatch(/\d\.\d{4,}/);
});
it("collapses single-child groups", { timeout: 5000 }, () => {
const svg = '<svg><g><rect width="10" height="10"/></g></svg>';
const result = compressSvg(svg, { plugins: PRESET_DEFAULT });
expect(result.data).not.toContain("<g>");
expect(result.data).toContain("<rect");
});
it("reports passCount >= 1", { timeout: 5000 }, () => {
const result = compressSvg(REAL_SVG_ICON, { plugins: PRESET_DEFAULT });
expect(result.passCount).toBeGreaterThanOrEqual(1);
});
});
describe("integration: PRESET_SAFE", () => {
it("compresses without converting transforms", { timeout: 5000 }, () => {
const svg =
'<svg><rect transform="translate(1.123456789 2.987654321)"/></svg>';
const defaultResult = compressSvg(svg, { plugins: PRESET_DEFAULT });
const safeResult = compressSvg(svg, { plugins: PRESET_SAFE });
// PRESET_SAFE does not include convertTransforms
expect(safeResult.data).toContain("1.123456789");
// PRESET_DEFAULT does not include convertTransforms either, so both same
expect(defaultResult.data).toContain("1.123456789");
});
it("still removes comments", { timeout: 5000 }, () => {
const svg = "<svg><!-- note --><rect/></svg>";
const result = compressSvg(svg, { plugins: PRESET_SAFE });
expect(result.data).not.toContain("<!--");
});
});
describe("integration: PRESET_AGGRESSIVE", () => {
it("mangles ids", { timeout: 5000 }, () => {
const svg = '<svg><rect id="veryLongDescriptiveName"/></svg>';
const result = compressSvg(svg, { plugins: PRESET_AGGRESSIVE });
expect(result.data).not.toContain("veryLongDescriptiveName");
expect(result.data).toContain("id=");
});
it("converts transforms", { timeout: 5000 }, () => {
const svg =
'<svg><rect transform="translate(1.123456789 2.987654321)"/></svg>';
const result = compressSvg(svg, {
plugins: PRESET_AGGRESSIVE,
transformPrecision: 2,
});
expect(result.data).toContain("translate(1.12 2.99)");
});
it("has smaller output than PRESET_SAFE for icon", {
timeout: 5000,
}, () => {
const safe = compressSvg(REAL_SVG_ICON, { plugins: PRESET_SAFE });
const aggressive = compressSvg(REAL_SVG_ICON, {
plugins: PRESET_AGGRESSIVE,
});
expect(aggressive.compressedSize).toBeLessThanOrEqual(
safe.compressedSize
);
});
});
describe("integration: minimal SVG", () => {
it("handles minimal SVG without error", { timeout: 5000 }, () => {
const result = compressSvg(MINIMAL_SVG);
expect(result.data).toBeTruthy();
expect(result.originalSize).toBeGreaterThan(0);
});
it("passCount is 1 when multipass=false", { timeout: 5000 }, () => {
const result = compressSvg(MINIMAL_SVG, { multipass: false });
expect(result.passCount).toBe(1);
});
});

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { compressSvg } from "../src/core/compress-svg/compress-svg.js";
import { parseSvg, serializeSvg } from "../src/parser/svg/svg.js";
import { PRESET_DEFAULT } from "../src/presets/index.js";
const SIMPLE_SVGS = [
'<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>',
'<svg><rect width="100" height="100"/></svg>',
'<svg><g><path d="M 10 20 L 30 40"/></g></svg>',
'<svg><text x="10" y="20">hello</text></svg>',
"<svg><g><g><rect/></g></g></svg>",
];
describe("property-based: compression idempotency", () => {
it("compress(compress(svg)) === compress(svg) for all sample SVGs", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const first = compressSvg(svg, { plugins: PRESET_DEFAULT });
const second = compressSvg(first.data, {
plugins: PRESET_DEFAULT,
});
expect(second.data).toBe(first.data);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: parse ↔ serialize round-trip", () => {
it("serializeSvg(parseSvg(svg)) is re-parseable for sample SVGs", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const node = parseSvg(svg);
const serialized = serializeSvg(node);
const reparsed = parseSvg(serialized);
expect(reparsed.type).toBe("element");
return true;
}),
{ numRuns: 10 }
);
});
it("parse → serialize preserves element names", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const node = parseSvg(svg);
const serialized = serializeSvg(node);
const reparsed = parseSvg(serialized);
expect(reparsed.name).toBe(node.name);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: ratio invariants", () => {
it("ratio is always > 0 for valid SVG", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.ratio).toBeGreaterThan(0);
return true;
}),
{ numRuns: 10 }
);
});
it("originalSize matches byte length of input", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
const expected = new TextEncoder().encode(svg).byteLength;
expect(result.originalSize).toBe(expected);
return true;
}),
{ numRuns: 10 }
);
});
it("compressedSize matches byte length of output", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
const expected = new TextEncoder().encode(
result.data
).byteLength;
expect(result.compressedSize).toBe(expected);
return true;
}),
{ numRuns: 10 }
);
});
it("ratio equals compressedSize / originalSize", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.ratio).toBeCloseTo(
result.compressedSize / result.originalSize,
5
);
return true;
}),
{ numRuns: 10 }
);
});
});
describe("property-based: passCount invariants", () => {
it("passCount >= 1 for all valid SVGs", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg);
expect(result.passCount).toBeGreaterThanOrEqual(1);
return true;
}),
{ numRuns: 10 }
);
});
it("passCount === 1 when multipass: false", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.constantFrom(...SIMPLE_SVGS), (svg) => {
const result = compressSvg(svg, { multipass: false });
expect(result.passCount).toBe(1);
return true;
}),
{ numRuns: 10 }
);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/compress-svg",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/types",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});