Skip to content

实现 电子签名生成 工具

分类:general | 标签:签名、手写、签名板 手写电子签名,支持导出透明 PNG

2.1 功能说明

手写电子签名,支持导出透明 PNG

2.1.1 使用指南

功能说明

用鼠标、触控板、手写笔或手机触屏在画板上手写签名,实时生成透明背景的 PNG 图片, 可以直接插入合同、审批单、PDF 或 Word 文档。整个过程在浏览器本地完成,签名笔迹不会上传到任何服务器。

怎么签得好看

用触屏或手写笔书写效果最自然;使用鼠标时建议放慢速度、把笔画粗细调到 3~5,并适当打开「笔锋效果」。

笔锋效果:开启后会根据书写速度自动调节线条粗细,快速划过的地方变细、慢速停顿的地方变粗,接近真实毛笔与钢笔的手感。使用支持压感的手写笔时,会直接采用笔的压力值。

撤销:每一笔都会记录一次快照,最多可回退 20 步。清空操作同样可以撤销。

关于透明背景

画板本身就是透明的,页面上看到的浅灰方格只是提示背景,不会被画进图片。 因此导出的 PNG 天然带透明通道,盖在任何底色的文件上都不会出现白色方块。 如果你需要白底签名(部分老系统不支持透明 PNG),可以在导出前勾选「导出白色背景」。

裁剪空白边缘

开启后导出时会自动扫描像素,找到笔迹的最小外接矩形并只保留这一块(四周留少量安全边距)。 这样得到的图片没有多余空白,插入文档后更容易对齐和缩放。关闭则导出整块画板。

横排与竖排

竖排会把签名整体旋转 90 度导出,适合中式公文、书画落款等需要竖向签批的场景。画板中仍按正常横向书写即可。

本地暂存

「暂存到本地」会把当前笔迹保存在浏览器的 localStorage 中,关闭页面后再回来可以点「恢复暂存」继续修改。 数据仅存在你自己的这台设备的这个浏览器里,清理浏览器数据会一并清除。涉及正式法律效力的签名,请以对方要求的签署方式为准。

法律提示

本工具生成的仅是签名图片,不含时间戳、数字证书等可信要素,不等同于符合电子签名法的可靠电子签名,请勿用于需要严格法律效力的场景。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • notify()
  • initCanvas()
  • getPoint()
  • pushHistory()
  • computeWidth()
  • onDown()
  • onMove()
  • onUp()
  • undo()
  • clearBoard()
  • checkEmpty()
  • getBounds()
  • downloadBlob()
  • exportPng()
  • checkDraft()
  • saveDraft()
  • loadDraft()
  • restore()
  • removeDraft()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const STORAGE_KEY = 'tool_e_signature_draft'
const MAX_HISTORY = 20

const canvasEl = ref(null)
const lineWidth = ref(4)
const penColor = ref('#1a1a1a')
const boardSize = ref('900x300')
const useTaper = ref(true)
const smooth = ref(true)
const orientation = ref('h')
const trimEdges = ref(true)
const whiteBg = ref(false)
const scale = ref(2)

const isEmpty = ref(true)
const strokeCount = ref(0)
const history = ref([])
const hasDraft = ref(false)
const message = ref('')
const messageType = ref('success')

const swatches = ['#1a1a1a', '#0b3d91', '#c0392b', '#1e6f42', '#5b4636']

let ctx = null
let drawing = false
let lastPoint = null
let lastWidth = 0

const boardW = computed(() => Number(boardSize.value.split('x')[0]))
const boardH = computed(() => Number(boardSize.value.split('x')[1]))

function notify(text, type) {
  message.value = text
  messageType.value = type || 'success'
  setTimeout(() => {
    message.value = ''
  }, 2600)
}

function initCanvas() {
  const cv = canvasEl.value
  if (!cv) return
  cv.width = boardW.value
  cv.height = boardH.value
  ctx = cv.getContext('2d', { willReadFrequently: true })
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  ctx.clearRect(0, 0, cv.width, cv.height)
  history.value = []
  strokeCount.value = 0
  isEmpty.value = true
}

// 把指针坐标从 CSS 像素映射到画布内部像素
function getPoint(e) {
  const cv = canvasEl.value
  const rect = cv.getBoundingClientRect()
  const sx = cv.width / rect.width
  const sy = cv.height / rect.height
  let pressure = 0
  if (e.pointerType !== 'mouse' && typeof e.pressure === 'number' && e.pressure > 0 && e.pressure < 1) {
    pressure = e.pressure
  }
  return {
    x: (e.clientX - rect.left) * sx,
    y: (e.clientY - rect.top) * sy,
    p: pressure,
    t: performance.now()
  }
}

function pushHistory() {
  if (!ctx) return
  try {
    const snap = ctx.getImageData(0, 0, canvasEl.value.width, canvasEl.value.height)
    history.value.push(snap)
    if (history.value.length > MAX_HISTORY) history.value.shift()
  } catch (err) {
    // 快照失败不影响继续书写
  }
}

function computeWidth(from, to) {
  const base = lineWidth.value
  if (!useTaper.value) return base
  if (to.p > 0) {
    // 有压感设备:直接用压力值,范围 40% ~ 160%
    return base * (0.4 + to.p * 1.2)
  }
  const dx = to.x - from.x
  const dy = to.y - from.y
  const dist = Math.sqrt(dx * dx + dy * dy)
  const dt = Math.max(1, to.t - from.t)
  const speed = dist / dt
  // 速度越快线条越细
  const factor = Math.max(0.35, Math.min(1.35, 1.35 - speed * 0.85))
  return base * factor
}

function onDown(e) {
  if (!ctx) return
  e.preventDefault()
  const cv = canvasEl.value
  if (cv.setPointerCapture) {
    try {
      cv.setPointerCapture(e.pointerId)
    } catch (err) {
      // 某些浏览器不支持时忽略
    }
  }
  pushHistory()
  drawing = true
  lastPoint = getPoint(e)
  lastWidth = lineWidth.value

  // 单击点一个圆点,避免点按无痕迹
  ctx.beginPath()
  ctx.fillStyle = penColor.value
  ctx.arc(lastPoint.x, lastPoint.y, lineWidth.value / 2, 0, Math.PI * 2)
  ctx.fill()

  isEmpty.value = false
  strokeCount.value++
}

function onMove(e) {
  if (!drawing || !ctx) return
  e.preventDefault()
  const point = getPoint(e)
  const target = computeWidth(lastPoint, point)
  // 与上一段宽度插值,避免粗细突变
  const w = lastWidth + (target - lastWidth) * 0.35

  ctx.strokeStyle = penColor.value
  ctx.lineWidth = Math.max(0.5, w)
  ctx.beginPath()
  ctx.moveTo(lastPoint.x, lastPoint.y)
  if (smooth.value) {
    const mx = (lastPoint.x + point.x) / 2
    const my = (lastPoint.y + point.y) / 2
    ctx.quadraticCurveTo(lastPoint.x, lastPoint.y, mx, my)
    ctx.lineTo(point.x, point.y)
  } else {
    ctx.lineTo(point.x, point.y)
  }
  ctx.stroke()

  lastPoint = point
  lastWidth = w
}

function onUp(e) {
  if (!drawing) return
  drawing = false
  lastPoint = null
  const cv = canvasEl.value
  if (cv && cv.releasePointerCapture && e && e.pointerId !== undefined) {
    try {
      cv.releasePointerCapture(e.pointerId)
    } catch (err) {
      // 忽略
    }
  }
}

function undo() {
  if (!ctx || history.value.length === 0) return
  const snap = history.value.pop()
  ctx.putImageData(snap, 0, 0)
  strokeCount.value = Math.max(0, strokeCount.value - 1)
  isEmpty.value = checkEmpty()
}

function clearBoard() {
  if (!ctx) return
  pushHistory()
  ctx.clearRect(0, 0, canvasEl.value.width, canvasEl.value.height)
  strokeCount.value = 0
  isEmpty.value = true
}

function checkEmpty() {
  if (!ctx) return true
  try {
    const d = ctx.getImageData(0, 0, canvasEl.value.width, canvasEl.value.height).data
    for (let i = 3; i < d.length; i += 4) {
      if (d[i] !== 0) return false
    }
    return true
  } catch (err) {
    return false
  }
}

// 找到笔迹的最小外接矩形
function getBounds() {
  const cv = canvasEl.value
  const d = ctx.getImageData(0, 0, cv.width, cv.height).data
  let minX = cv.width
  let minY = cv.height
  let maxX = -1
  let maxY = -1
  for (let y = 0; y < cv.height; y++) {
    for (let x = 0; x < cv.width; x++) {
      if (d[(y * cv.width + x) * 4 + 3] !== 0) {
        if (x < minX) minX = x
        if (x > maxX) maxX = x
        if (y < minY) minY = y
        if (y > maxY) maxY = y
      }
    }
  }
  if (maxX < 0) return null
  return { x: minX, y: minY, w: maxX - minX + 1, h: maxY - minY + 1 }
}

function downloadBlob(blob, name) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = name
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

function exportPng() {
  const cv = canvasEl.value
  if (!cv || isEmpty.value) return

  let sx = 0
  let sy = 0
  let sw = cv.width
  let sh = cv.height

  if (trimEdges.value) {
    const b = getBounds()
    if (!b) {
      notify('画板内没有笔迹', 'warning')
      return
    }
    const pad = Math.round(lineWidth.value * 2 + 6)
    sx = Math.max(0, b.x - pad)
    sy = Math.max(0, b.y - pad)
    sw = Math.min(cv.width - sx, b.w + pad * 2)
    sh = Math.min(cv.height - sy, b.h + pad * 2)
  }

  const s = scale.value
  const vertical = orientation.value === 'v'

  const out = document.createElement('canvas')
  out.width = Math.max(1, Math.round((vertical ? sh : sw) * s))
  out.height = Math.max(1, Math.round((vertical ? sw : sh) * s))
  const octx = out.getContext('2d')

  if (whiteBg.value) {
    octx.fillStyle = '#ffffff'
    octx.fillRect(0, 0, out.width, out.height)
  }

  octx.imageSmoothingEnabled = true
  octx.imageSmoothingQuality = 'high'

  if (vertical) {
    // 顺时针旋转 90 度
    octx.translate(out.width, 0)
    octx.rotate(Math.PI / 2)
    octx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw * s, sh * s)
  } else {
    octx.drawImage(cv, sx, sy, sw, sh, 0, 0, sw * s, sh * s)
  }

  out.toBlob((blob) => {
    if (blob) {
      downloadBlob(blob, 'signature-' + Date.now() + '.png')
      notify('签名已导出,尺寸 ' + out.width + ' × ' + out.height, 'success')
    }
  }, 'image/png')
}

/* ---------------- 本地暂存 ---------------- */

function checkDraft() {
  try {
    hasDraft.value = !!window.localStorage.getItem(STORAGE_KEY)
  } catch (err) {
    hasDraft.value = false
  }
}

function saveDraft() {
  const cv = canvasEl.value
  if (!cv) return
  try {
    const payload = JSON.stringify({
      size: boardSize.value,
      data: cv.toDataURL('image/png'),
      time: Date.now()
    })
    window.localStorage.setItem(STORAGE_KEY, payload)
    checkDraft()
    notify('已暂存到本地浏览器', 'success')
  } catch (err) {
    notify('暂存失败,可能是浏览器存储空间不足', 'error')
  }
}

function loadDraft() {
  let payload = null
  try {
    payload = JSON.parse(window.localStorage.getItem(STORAGE_KEY))
  } catch (err) {
    payload = null
  }
  if (!payload || !payload.data) {
    notify('没有找到可恢复的暂存', 'warning')
    return
  }

  const restore = () => {
    const img = new Image()
    img.onload = () => {
      pushHistory()
      ctx.clearRect(0, 0, canvasEl.value.width, canvasEl.value.height)
      ctx.drawImage(img, 0, 0)
      isEmpty.value = checkEmpty()
      strokeCount.value = isEmpty.value ? 0 : 1
      notify('已恢复暂存的签名', 'success')
    }
    img.onerror = () => notify('暂存数据已损坏', 'error')
    img.src = payload.data
  }

  if (payload.size && payload.size !== boardSize.value) {
    boardSize.value = payload.size
    nextTick(() => {
      initCanvas()
      restore()
    })
  } else {
    restore()
  }
}

function removeDraft() {
  try {
    window.localStorage.removeItem(STORAGE_KEY)
  } catch (err) {
    // 忽略
  }
  checkDraft()
  notify('已删除本地暂存', 'success')
}

watch(boardSize, () => {
  nextTick(initCanvas)
})

onMounted(() => {
  initCanvas()
  checkDraft()
})

onBeforeUnmount(() => {
  history.value = []
})

2.3 效果截图

电子签名生成 效果截图

工具访问地址:https://www.i91tools.com/tools/e-signature