Skip to content

Commit cc27deb

Browse files
committed
[Feature] Image to SVG — convert raster images to SVG vector graphics using VTracer WASM
1 parent a60bd7f commit cc27deb

29 files changed

Lines changed: 1866 additions & 144 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.3] - 2026-03-03
9+
10+
* [Feature] Image to SVG — convert raster images to SVG vector graphics using VTracer WASM, with preset configurations (Default, Smallest file, High fidelity, Pixel art, Posterized) and collapsible advanced parameter panel
11+
812
## [1.0.2] - 2026-02-24
913

1014
* [Feature] SCIM 2.0 Server — create isolated SCIM endpoints with full User and Group CRUD, sample data pre-population, and built-in API documentation

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ A collection of lightweight, browser-native developer utilities. This toolbox is
99
- **Cheat Sheets**: Quick reference for Bash, CSS, Git, HTTP, and Regex.
1010
- **Color Converter**: Convert between Hex, RGB, HSL, and more.
1111
- **Epoch Converter**: Convert Unix timestamps to human-readable dates.
12+
- **Image to SVG**: Convert raster images (PNG, JPG, WebP) to SVG vector graphics using VTracer WASM, with presets and advanced parameter tuning.
1213
- **HTML Entity**: Encode and decode HTML entities.
1314
- **JSON Validator**: Format, repair, and validate JSON payloads.
1415
- **JWT Decoder**: Inspect JSON Web Tokens (header and payload).

biome.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
33
"files": {
4-
"includes": ["src/**", "server/**", "shared/**", "test/**", "*.ts", "*.json", "!worker-configuration.d.ts"]
4+
"includes": ["src/**", "server/**", "shared/**", "test/**", "*.ts", "*.json", "!worker-configuration.d.ts", "!src/**/vendor"]
55
},
66
"formatter": {
77
"indentStyle": "space",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "toolbox",
33
"private": true,
4-
"version": "1.0.2",
4+
"version": "1.0.3",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

pnpm-lock.yaml

Lines changed: 103 additions & 140 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<script setup lang="ts">
2+
import { computed, ref } from 'vue'
3+
import ParameterSlider from './ParameterSlider.vue'
4+
import { findActivePreset, presets } from './presets'
5+
import SegmentedControl from './SegmentedControl.vue'
6+
import type { ClusteringMode, ConversionParams, HierarchicalMode, TraceMode } from './types'
7+
8+
const props = defineProps<{
9+
disabled?: boolean
10+
}>()
11+
12+
const params = defineModel<ConversionParams>({ required: true })
13+
14+
const activePresetKey = computed(() => findActivePreset(params.value))
15+
const showAdvanced = ref(false)
16+
17+
function applyPreset(key: string) {
18+
const preset = presets.find(preset => preset.key === key)
19+
20+
if (preset) {
21+
params.value = { ...preset.params }
22+
}
23+
}
24+
25+
const traceModes: { value: TraceMode; label: string; description: string }[] = [
26+
{ value: 'spline', label: 'Spline', description: 'Smooth curves (best for most images)' },
27+
{ value: 'polygon', label: 'Polygon', description: 'Straight segments (pixel-art style)' },
28+
{ value: 'none', label: 'None', description: 'No path simplification (raw pixel edges)' },
29+
]
30+
31+
const clusteringModes: { value: ClusteringMode; label: string; description: string }[] = [
32+
{ value: 'color', label: 'Color', description: 'Full color tracing with clustering' },
33+
{ value: 'binary', label: 'B/W', description: 'Black and white tracing' },
34+
]
35+
36+
const hierarchicalModes: { value: HierarchicalMode; label: string; description: string }[] = [
37+
{ value: 'stacked', label: 'Stacked', description: 'Layers stacked on top of each other' },
38+
{ value: 'cutout', label: 'Cutout', description: 'Shapes cut out from layers below' },
39+
]
40+
41+
const sliders = computed(() => {
42+
const colorSliders = params.value.clusteringMode === 'color'
43+
? [
44+
{
45+
key: 'colorPrecision' as const,
46+
label: 'Color Precision',
47+
min: 1,
48+
max: 8,
49+
step: 1,
50+
description: 'How many colors to distinguish. Higher values preserve more color detail, lower values simplify.',
51+
},
52+
{
53+
key: 'layerDifference' as const,
54+
label: 'Gradient Step',
55+
min: 0,
56+
max: 128,
57+
step: 1,
58+
description: 'Color difference between gradient layers. Higher values produce fewer layers and a more posterized look.',
59+
},
60+
]
61+
: []
62+
63+
return [
64+
...colorSliders,
65+
{
66+
key: 'filterSpeckle' as const,
67+
label: 'Filter Speckle',
68+
min: 0,
69+
max: 128,
70+
step: 1,
71+
description: 'Removes clusters smaller than this size (in pixels). Increase to clean up noise and tiny artifacts.',
72+
},
73+
{
74+
key: 'cornerThreshold' as const,
75+
label: 'Corner Threshold',
76+
min: 0,
77+
max: 180,
78+
step: 1,
79+
description: 'Angle (in degrees) below which a point is treated as a corner. Lower values produce rounder shapes; higher values keep sharp corners.',
80+
},
81+
{
82+
key: 'lengthThreshold' as const,
83+
label: 'Length Threshold',
84+
min: 0,
85+
max: 100,
86+
step: 0.5,
87+
description: 'Minimum segment length to keep. Higher values simplify paths by dropping short segments, reducing file size.',
88+
},
89+
{
90+
key: 'spliceThreshold' as const,
91+
label: 'Splice Threshold',
92+
min: 0,
93+
max: 180,
94+
step: 1,
95+
description: 'Angle threshold for joining adjacent curves. Higher values merge more curves together for smoother output.',
96+
},
97+
{
98+
key: 'pathPrecision' as const,
99+
label: 'Path Precision',
100+
min: 0,
101+
max: 8,
102+
step: 1,
103+
description: 'Decimal places in SVG path coordinates. Higher values are more precise but produce larger files.',
104+
},
105+
]
106+
})
107+
</script>
108+
109+
<template>
110+
<div class="space-y-4" :class="{ 'opacity-50 pointer-events-none': props.disabled }">
111+
<!-- Presets -->
112+
<div class="flex flex-wrap items-center gap-2">
113+
<span class="text-xs font-semibold uppercase tracking-wider text-text-muted">Preset</span>
114+
<button
115+
v-for="preset in presets"
116+
:key="preset.key"
117+
type="button"
118+
:disabled="props.disabled"
119+
class="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors cursor-pointer"
120+
:class="activePresetKey === preset.key
121+
? 'bg-accent text-white'
122+
: 'bg-surface-overlay text-text-secondary hover:text-text-primary border border-border'"
123+
:title="preset.description"
124+
@click="applyPreset(preset.key)"
125+
>
126+
{{ preset.label }}
127+
</button>
128+
</div>
129+
130+
<!-- Advanced toggle -->
131+
<button
132+
type="button"
133+
class="flex items-center gap-1.5 text-xs text-text-muted hover:text-text-secondary transition-colors cursor-pointer"
134+
:disabled="props.disabled"
135+
@click="showAdvanced = !showAdvanced"
136+
>
137+
<span class="transition-transform" :class="showAdvanced ? 'rotate-90' : ''">&#9654;</span>
138+
Advanced parameters
139+
</button>
140+
141+
<div v-if="showAdvanced" class="space-y-4">
142+
<SegmentedControl v-model="params.clusteringMode" label="Clustering" :options="clusteringModes" :disabled="props.disabled" />
143+
144+
<SegmentedControl
145+
v-if="params.clusteringMode === 'color'"
146+
v-model="params.hierarchical"
147+
label="Hierarchy"
148+
:options="hierarchicalModes"
149+
:disabled="props.disabled"
150+
/>
151+
152+
<SegmentedControl v-model="params.mode" label="Curve fitting" :options="traceModes" :disabled="props.disabled" />
153+
154+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
155+
<ParameterSlider
156+
v-for="slider in sliders"
157+
:key="slider.key"
158+
v-model="params[slider.key]"
159+
:label="slider.label"
160+
:min="slider.min"
161+
:max="slider.max"
162+
:step="slider.step"
163+
:description="slider.description"
164+
:disabled="props.disabled"
165+
/>
166+
</div>
167+
</div>
168+
</div>
169+
</template>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
progress: number
4+
}>()
5+
</script>
6+
7+
<template>
8+
<div class="space-y-2">
9+
<div class="flex items-center justify-between text-xs text-text-muted">
10+
<span>Converting...</span>
11+
<span>{{ Math.round(props.progress * 100) }}%</span>
12+
</div>
13+
<div class="w-full h-1.5 bg-surface-overlay rounded-full overflow-hidden">
14+
<div
15+
class="h-full bg-accent rounded-full transition-all duration-200"
16+
:style="{ width: `${props.progress * 100}%` }"
17+
/>
18+
</div>
19+
</div>
20+
</template>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<script setup lang="ts">
2+
import { ref } from 'vue'
3+
4+
const emit = defineEmits<{
5+
file: [file: File]
6+
}>()
7+
8+
const dragging = ref(false)
9+
const fileInput = ref<HTMLInputElement | null>(null)
10+
11+
function onFileInput(event: Event) {
12+
const input = event.target as HTMLInputElement
13+
const file = input.files?.[0]
14+
15+
if (file) {
16+
emit('file', file)
17+
}
18+
19+
input.value = ''
20+
}
21+
22+
function onDrop(event: DragEvent) {
23+
dragging.value = false
24+
const file = event.dataTransfer?.files[0]
25+
26+
if (file) {
27+
emit('file', file)
28+
}
29+
}
30+
31+
function onDragOver() {
32+
dragging.value = true
33+
}
34+
35+
function onDragLeave() {
36+
dragging.value = false
37+
}
38+
</script>
39+
40+
<template>
41+
<div
42+
class="relative border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer"
43+
:class="dragging ? 'border-accent bg-accent/10' : 'border-border hover:border-border-focus'"
44+
@dragover.prevent="onDragOver"
45+
@dragleave="onDragLeave"
46+
@drop.prevent="onDrop"
47+
@click="fileInput?.click()"
48+
>
49+
<input
50+
ref="fileInput"
51+
type="file"
52+
accept="image/*"
53+
class="hidden"
54+
@change="onFileInput"
55+
>
56+
<div class="text-text-muted">
57+
<p class="text-sm font-medium">Drop an image here or click to browse</p>
58+
<p class="text-xs mt-1">Supports PNG, JPG, GIF, WebP, and BMP</p>
59+
</div>
60+
</div>
61+
</template>

0 commit comments

Comments
 (0)