Appearance
实现 SVG 工具箱 工具
分类:general | 标签:SVG、PNG、压缩 SVG 转 PNG/JPG、Base64/DataURI、SVG 压缩与预览
2.1 功能说明
SVG 转 PNG/JPG、Base64/DataURI、SVG 压缩与预览
2.1.1 使用指南
功能说明
面向前端日常场景的 SVG 处理工具,集预览、栅格化导出、Base64 编码与简易压缩于一体。全部处理在浏览器本地完成,图形内容不会上传到服务器,可放心处理内部素材。
预览
粘贴 SVG 源码或导入 .svg 文件,右侧会实时渲染。工具会解析根元素的 width、height 与 viewBox 推断画布尺寸;当宽高写成百分比时以 viewBox 为准,两者都缺失时按 SVG 规范默认的 300 × 150 处理。可切换棋盘格背景以观察透明区域,也可调整预览缩放比例查看细节。
转 PNG / JPG
选择导出倍率(1x / 2x / 3x 或自定义)与格式后点击导出,工具会把 SVG 按目标像素尺寸绘制到 canvas 再输出位图。PNG 保留透明通道;JPG 不支持透明,需要指定背景色,并可调节压缩质量。导出前会先生成预览图,确认无误再下载。
转 Base64 / DataURI / CSS
同时给出 Base64 字符串、data:image/svg+xml;base64, 形式的 DataURI、体积通常更小的 URL 编码形式,以及可直接粘贴使用的 CSS 背景片段与 img 标签片段,每项都可单独复制。适合把小图标内联进样式表,减少一次网络请求。
压缩
提供可勾选的简易优化项:去除注释、去除 XML 声明与 DOCTYPE、去除 metadata/title/desc 元素、去除 Inkscape/Sodipodi/Sketch/Figma 等编辑器专有属性与命名空间、折叠标签内多余空白、去除空属性与冗余的 version 属性。压缩结果会显示原始体积、压缩后体积与压缩率。
注意事项
压缩基于文本规则实现,追求安全与可预期,不会改写路径数值或合并图形,因此压缩率低于专业工具,但不易破坏图形。若 SVG 中引用了外部字体或外部图片,栅格化时这些资源可能无法加载,建议先将文字转为路径。去除 title/desc 会影响无障碍朗读,对外发布的图标请谨慎勾选。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
parseSvgSize()utf8ToBase64()buildSizedSvg()releaseRaster()rasterize()downloadRaster()formatBytes()minify()applyMinified()downloadMinified()copyText()handleFile()loadSample()clearAll()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const svgCode = ref('')
const activeTab = ref('preview')
const parseError = ref('')
const checkerBg = ref(true)
const previewZoom = ref(100)
const rasterFormat = ref('png')
const scalePreset = ref(2)
const customScale = ref(1.5)
const jpegBackground = ref('#ffffff')
const jpegQuality = ref(92)
const rasterUrl = ref('')
const rasterInfo = ref('')
const rasterError = ref('')
let rasterBlob = null
const optComments = ref(true)
const optDeclaration = ref(true)
const optMeta = ref(true)
const optEditorAttrs = ref(true)
const optWhitespace = ref(true)
const optUseless = ref(true)
const minified = ref('')
const minifyInfo = ref('')
const valid = computed(() => {
const code = svgCode.value.trim()
return code.indexOf('<svg') !== -1 && code.indexOf('</svg>') !== -1
})
function parseSvgSize(code) {
const fallback = { width: 300, height: 150 }
if (!code.trim()) return fallback
try {
const doc = new DOMParser().parseFromString(code, 'image/svg+xml')
if (doc.getElementsByTagName('parsererror').length) return fallback
const root = doc.documentElement
if (!root || root.nodeName.toLowerCase() !== 'svg') return fallback
let w = parseFloat(root.getAttribute('width') || '')
let h = parseFloat(root.getAttribute('height') || '')
const box = root.getAttribute('viewBox')
if ((!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) && box) {
const parts = box.trim().split(/[\s,]+/).map(Number)
if (parts.length === 4 && isFinite(parts[2]) && isFinite(parts[3])) {
if (!isFinite(w) || w <= 0) w = parts[2]
if (!isFinite(h) || h <= 0) h = parts[3]
}
}
if (!isFinite(w) || w <= 0) w = fallback.width
if (!isFinite(h) || h <= 0) h = fallback.height
return { width: w, height: h }
} catch (e) {
return fallback
}
}
const svgSize = computed(() => parseSvgSize(svgCode.value))
const baseWidth = computed(() => svgSize.value.width)
const baseHeight = computed(() => svgSize.value.height)
const sizeInfo = computed(() => (valid.value ? baseWidth.value + ' × ' + baseHeight.value : ''))
const previewWidth = computed(() => Math.max(1, Math.round(baseWidth.value * previewZoom.value / 100)))
const effectiveScale = computed(() => (scalePreset.value === 0 ? (Number(customScale.value) || 1) : scalePreset.value))
function utf8ToBase64(str) {
const bytes = new TextEncoder().encode(str)
let binary = ''
const chunk = 0x8000
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk))
}
return btoa(binary)
}
const base64Text = computed(() => {
if (!valid.value) return ''
try { return utf8ToBase64(svgCode.value.trim()) } catch (e) { return '' }
})
const dataUri = computed(() => (base64Text.value ? 'data:image/svg+xml;base64,' + base64Text.value : ''))
const encodedUri = computed(() => {
if (!valid.value) return ''
const compact = svgCode.value.trim().replace(/\s{2,}/g, ' ').replace(/>\s+</g, '><')
const encoded = encodeURIComponent(compact)
.replace(/'/g, '%27')
.replace(/%20/g, ' ')
.replace(/%3D/g, '=')
.replace(/%3A/g, ':')
.replace(/%2F/g, '/')
return "data:image/svg+xml;charset=utf-8," + encoded
})
const cssSnippet = computed(() => {
if (!dataUri.value) return ''
return [
'.svg-icon {',
' width: ' + Math.round(baseWidth.value) + 'px;',
' height: ' + Math.round(baseHeight.value) + 'px;',
' background-image: url("' + dataUri.value + '");',
' background-repeat: no-repeat;',
' background-position: center;',
' background-size: contain;',
'}'
].join('\n')
})
const imgSnippet = computed(() => {
if (!dataUri.value) return ''
return '<img src="' + dataUri.value + '" width="' + Math.round(baseWidth.value) + '" height="' + Math.round(baseHeight.value) + '" alt="icon" />'
})
function buildSizedSvg(width, height) {
const code = svgCode.value.trim()
try {
const doc = new DOMParser().parseFromString(code, 'image/svg+xml')
if (doc.getElementsByTagName('parsererror').length) return code
const root = doc.documentElement
if (!root.getAttribute('viewBox')) {
root.setAttribute('viewBox', '0 0 ' + baseWidth.value + ' ' + baseHeight.value)
}
root.setAttribute('width', String(width))
root.setAttribute('height', String(height))
if (!root.getAttribute('xmlns')) root.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
return new XMLSerializer().serializeToString(root)
} catch (e) {
return code
}
}
function releaseRaster() {
if (rasterUrl.value) URL.revokeObjectURL(rasterUrl.value)
rasterUrl.value = ''
rasterBlob = null
}
function rasterize() {
rasterError.value = ''
rasterInfo.value = ''
releaseRaster()
if (!valid.value) { rasterError.value = '请输入有效的 SVG 源码'; return }
const scale = effectiveScale.value
const targetW = Math.max(1, Math.round(baseWidth.value * scale))
const targetH = Math.max(1, Math.round(baseHeight.value * scale))
if (targetW * targetH > 40000000) {
rasterError.value = '输出尺寸过大,请降低倍率'
return
}
const sized = buildSizedSvg(targetW, targetH)
let uri
try {
uri = 'data:image/svg+xml;base64,' + utf8ToBase64(sized)
} catch (e) {
rasterError.value = '编码失败:' + e.message
return
}
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = targetW
canvas.height = targetH
const ctx = canvas.getContext('2d')
if (rasterFormat.value === 'jpeg') {
ctx.fillStyle = jpegBackground.value || '#ffffff'
ctx.fillRect(0, 0, targetW, targetH)
}
ctx.drawImage(img, 0, 0, targetW, targetH)
const mime = rasterFormat.value === 'jpeg' ? 'image/jpeg' : 'image/png'
const quality = rasterFormat.value === 'jpeg' ? jpegQuality.value / 100 : undefined
canvas.toBlob((blob) => {
if (!blob) { rasterError.value = '生成位图失败'; return }
rasterBlob = blob
rasterUrl.value = URL.createObjectURL(blob)
rasterInfo.value = '已生成 ' + targetW + ' × ' + targetH + ' 的 ' +
(rasterFormat.value === 'jpeg' ? 'JPG' : 'PNG') + ',文件大小 ' + formatBytes(blob.size)
}, mime, quality)
}
img.onerror = () => { rasterError.value = 'SVG 渲染失败,请检查源码是否合法或是否引用了外部资源' }
img.src = uri
}
function downloadRaster() {
if (!rasterBlob) return
const url = URL.createObjectURL(rasterBlob)
const a = document.createElement('a')
a.href = url
a.download = 'image.' + (rasterFormat.value === 'jpeg' ? 'jpg' : 'png')
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function formatBytes(size) {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
return (size / 1024 / 1024).toFixed(2) + ' MB'
}
function minify() {
const source = svgCode.value
if (!source.trim()) return
let out = source
if (optComments.value) out = out.replace(/<!--[\s\S]*?-->/g, '')
if (optDeclaration.value) {
out = out.replace(/<\?xml[\s\S]*?\?>/g, '')
out = out.replace(/<!DOCTYPE[^>]*>/gi, '')
}
if (optMeta.value) {
out = out.replace(/<metadata[\s\S]*?<\/metadata>/gi, '')
out = out.replace(/<title[\s\S]*?<\/title>/gi, '')
out = out.replace(/<desc[\s\S]*?<\/desc>/gi, '')
out = out.replace(/<(metadata|title|desc)\b[^>]*\/>/gi, '')
}
if (optEditorAttrs.value) {
out = out.replace(/\s(?:inkscape|sodipodi|sketch|figma|illustrator|serif|krita):[\w.-]+\s*=\s*("[^"]*"|'[^']*')/gi, '')
out = out.replace(/\sxmlns:(?:inkscape|sodipodi|sketch|figma|illustrator|serif|krita)\s*=\s*("[^"]*"|'[^']*')/gi, '')
out = out.replace(/<sodipodi:[\s\S]*?\/>/gi, '')
}
if (optUseless.value) {
out = out.replace(/\s[\w:.-]+\s*=\s*(""|'')/g, '')
out = out.replace(/\sversion\s*=\s*("1\.[01]"|'1\.[01]')/gi, '')
out = out.replace(/\sxml:space\s*=\s*("preserve"|'preserve')/gi, '')
if (!/xlink:[\w-]+\s*=/.test(out)) {
out = out.replace(/\sxmlns:xlink\s*=\s*("[^"]*"|'[^']*')/gi, '')
}
}
if (optWhitespace.value) {
out = out.replace(/>\s+</g, '><')
out = out.replace(/<([^>]+)>/g, (match, inner) => {
const collapsed = inner.replace(/\s+/g, ' ').trim().replace(/\s*\/$/, '/')
return '<' + collapsed + '>'
})
}
out = out.trim()
minified.value = out
const before = new TextEncoder().encode(source).length
const after = new TextEncoder().encode(out).length
const rate = before > 0 ? ((1 - after / before) * 100).toFixed(1) : '0.0'
minifyInfo.value = '原始 ' + formatBytes(before) + ',压缩后 ' + formatBytes(after) + ',减少 ' + rate + '%'
}
function applyMinified() {
if (!minified.value) return
svgCode.value = minified.value
}
function downloadMinified() {
if (!minified.value) return
const blob = new Blob([minified.value], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'image.min.svg'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function copyText(text) {
if (text && navigator.clipboard) navigator.clipboard.writeText(text)
}
function handleFile(uploadFile) {
const file = uploadFile && uploadFile.raw ? uploadFile.raw : uploadFile
if (!file) return
const reader = new FileReader()
reader.onload = () => {
svgCode.value = String(reader.result || '')
parseError.value = valid.value ? '' : '文件内容不是有效的 SVG'
}
reader.onerror = () => { parseError.value = '文件读取失败' }
reader.readAsText(file, 'utf-8')
}
function loadSample() {
svgCode.value = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="120" height="120">',
' <!-- 示例图形 -->',
' <title>示例</title>',
' <rect x="10" y="10" width="100" height="100" rx="16" fill="#409eff" />',
' <circle cx="60" cy="52" r="22" fill="#ffffff" />',
' <path d="M28 96 Q60 66 92 96 Z" fill="#ffffff" opacity="0.85" />',
'</svg>'
].join('\n')
parseError.value = ''
}
function clearAll() {
svgCode.value = ''
minified.value = ''
minifyInfo.value = ''
rasterInfo.value = ''
rasterError.value = ''
parseError.value = ''
releaseRaster()
}
onBeforeUnmount(() => { releaseRaster() })2.3 效果截图
