Skip to content

实现 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 文件,右侧会实时渲染。工具会解析根元素的 widthheightviewBox 推断画布尺寸;当宽高写成百分比时以 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-inputel-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 效果截图

SVG 工具箱 效果截图

工具访问地址:https://www.i91tools.com/tools/svg-tool