Skip to content

实现 证件照制作 工具

分类:developer | 标签:证件照、一寸、二寸 标准证件照尺寸裁剪、换背景色、六寸排版

2.1 功能说明

标准证件照尺寸裁剪、换背景色、六寸排版

2.1.1 使用指南

功能说明

上传一张人像照片,自动抠除背景并换成证件照标准底色,裁剪为一寸、二寸等规格后下载; 还能把多张照片排布到六寸相纸上,拿到照相馆或自助机一次冲印、剪开即用。 全部处理都在浏览器本地完成,照片不会上传到服务器。

拍摄建议

正面免冠、光线均匀、五官清晰,人物与背景颜色差异越大,抠图边缘越干净。避免佩戴反光眼镜,头发蓬松或与背景同色时边缘可能出现毛刺。

操作步骤

1. 点击「选择照片」上传人像;
2. 若照片里人像只占一部分(如上半身范围偏大),先在「裁剪原图」拖拽选框框住要保留的区域,点「应用裁剪」裁掉多余部分;
3. 保持「自动去背景」开启,首次使用会下载约 200KB 的人像分割模型;
4. 选择底色与规格,用缩放和位移滑块把头部放到画面中上部;
5. 点击「下载证件照」,或切到排版区下载六寸拼版。

底色怎么选

白底适用于身份证、驾驶证、护照等大多数证件;蓝底常用于毕业证、简历、工作证;红底多用于结婚证、部分单位证件。具体以受理单位要求为准。

常见规格

一寸 295×413 像素(25×35mm)最常用;二寸 413×579 像素(35×49mm)多用于学历与职称材料;签证照 354×472 像素(30×40mm)用于出入境材料。以上均为 300dpi 下的像素尺寸。

六寸排版

六寸相纸按 300dpi 折算为 1748×2480 像素。系统会根据所选规格自动计算每行每列能放几张,并绘制浅灰色剪裁参考线。冲印时请选择「原图/无边框」模式,避免相纸被二次缩放导致尺寸偏差。

抠图失败怎么办

若分割模型加载失败(例如网络受限),可关闭「自动去背景」。此时若上传的是带透明通道的 PNG,依然能正常合成底色;不透明照片则只做裁剪与排版,底色不会生效。

2.2 代码实现

2.2.1 组件结构

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

2.2.2 核心逻辑概览

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

  • getDispScale()
  • toSource()
  • onPickFile()
  • buildSegmentInput()
  • runSegment()
  • onToggleRemoveBg()
  • compose()
  • hexToRgb()
  • resetCompose()
  • renderCropCanvas()
  • cloneCanvas()
  • getHandle()
  • insideCrop()
  • onCropDown()
  • onCropMove()
  • onCropUp()
  • onCropTouchStart()
  • onCropTouchMove()
  • onCropTouchEnd()
  • frameCrop()
  • resetCrop()
  • applyCrop()
  • undoCrop()
  • drawPhotoTo()
  • renderPhotoCanvas()
  • drawLayout()
  • repaint()
  • downloadCanvas()
  • currentSizeLabel()
  • downloadPhoto()
  • downloadLayout()

2.2.3 关键实现代码

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

vue
import {computed, nextTick, reactive, ref, watch} from 'vue'
import {ElMessage} from 'element-plus'
import ToolPageShell from '@/components/ToolPageShell.vue'
// 复用图片处理工具里的人像分割能力(MediaPipe Selfie Segmenter)
import {applyBgColor, ensureSession, segmentPerson} from '@/tools/ImageProcessor/bgSegmenter.js'

const BG_COLORS = [
  {label: '白底', value: '#FFFFFF'},
  {label: '蓝底', value: '#438EDB'},
  {label: '红底', value: '#FF0000'}
]

const SIZES = [
  {key: 'one', label: '一寸', w: 295, h: 413},
  {key: 'two', label: '二寸', w: 413, h: 579},
  {key: 'small-one', label: '小一寸', w: 260, h: 378},
  {key: 'big-one', label: '大一寸', w: 390, h: 567},
  {key: 'small-two', label: '小二寸', w: 413, h: 531},
  {key: 'big-two', label: '大二寸', w: 413, h: 626},
  {key: 'visa', label: '签证照', w: 354, h: 472}
]

// 六寸相纸 @300dpi
const PAPER_W = 1748
const PAPER_H = 2480
const PAPER_MARGIN = 40
const PHOTO_GAP = 20

const fileInput = ref(null)
const photoCanvas = ref(null)
const layoutCanvas = ref(null)

const hasImage = ref(false)
const sourceInfo = ref('')
const bgColor = ref('#FFFFFF')
const sizeKey = ref('one')
const customW = ref(295)
const customH = ref(413)
const autoRemoveBg = ref(true)
const feather = ref(2)
const zoom = ref(1)
const offsetX = ref(0)
const offsetY = ref(0)
const layoutCount = ref(6)
const processing = ref(false)
const statusMsg = ref('')
const statusType = ref('info')

// 原图画布 / 已换底画布 / 分割掩码缓存
let sourceCanvas = null
let composedCanvas = null
let maskCache = null

// 裁剪原图相关
const cropStageRef = ref(null)
const cropCanvas = ref(null)
const cropRect = reactive({x: 0, y: 0, w: 0, h: 0}) // 源图像素坐标下的选区
const viewScale = ref(1) // 源图 → 编辑画布 backing 的缩放
const cropLockRatio = ref(true)
const cropApplied = ref(false)
let originalCanvas = null // 首次载入的原图快照,用于撤销裁剪
const cropDrag = reactive({
  active: false,
  mode: '', // 'new' | 'move' | 'resize'
  handle: '',
  anchorX: 0,
  anchorY: 0,
  startMouseX: 0,
  startMouseY: 0,
  startX: 0,
  startY: 0,
  startW: 0,
  startH: 0
})

// 选区与当前原图整图不一致时才允许「应用裁剪」
const cropDirty = computed(() => {
  if (!sourceCanvas) return false
  const W = sourceCanvas.width
  const H = sourceCanvas.height
  return Math.round(cropRect.x) !== 0 || Math.round(cropRect.y) !== 0 ||
      Math.round(cropRect.w) !== W || Math.round(cropRect.h) !== H
})

// 编辑画布实际显示缩放(含 CSS max-width 缩放),用于选框与命中检测对齐
function getDispScale() {
  const cv = cropCanvas.value
  if (!cv || !sourceCanvas) return 1
  const rect = cv.getBoundingClientRect()
  if (!rect.width) return 1
  return rect.width / sourceCanvas.width
}

const cropBoxStyle = computed(() => {
  const s = getDispScale()
  return {
    left: cropRect.x * s + 'px',
    top: cropRect.y * s + 'px',
    width: cropRect.w * s + 'px',
    height: cropRect.h * s + 'px'
  }
})

// 屏幕坐标 → 源图像素坐标
function toSource(e) {
  const cv = cropCanvas.value
  const rect = cv.getBoundingClientRect()
  const sx = sourceCanvas.width / rect.width
  const sy = sourceCanvas.height / rect.height
  return {
    x: (e.clientX - rect.left) * sx,
    y: (e.clientY - rect.top) * sy
  }
}

const targetSize = computed(() => {
  if (sizeKey.value === 'custom') return {w: customW.value, h: customH.value}
  const s = SIZES.find(i => i.key === sizeKey.value)
  return {w: s.w, h: s.h}
})

const layoutGrid = computed(() => {
  const {w, h} = targetSize.value
  const cols = Math.max(1, Math.floor((PAPER_W - PAPER_MARGIN * 2 + PHOTO_GAP) / (w + PHOTO_GAP)))
  const rows = Math.max(1, Math.floor((PAPER_H - PAPER_MARGIN * 2 + PHOTO_GAP) / (h + PHOTO_GAP)))
  return {cols, rows}
})

const maxLayoutCount = computed(() => layoutGrid.value.cols * layoutGrid.value.rows)

watch(maxLayoutCount, max => {
  if (layoutCount.value > max) layoutCount.value = max
})

watch([bgColor, feather], () => compose())
watch(targetSize, () => repaint())
watch(layoutCount, () => drawLayout())

/* ------------------------- 上传 ------------------------- */

function onPickFile(e) {
  const file = (e.target.files || [])[0]
  e.target.value = ''
  if (!file) return
  if (!file.type.startsWith('image/')) {
    ElMessage.error('请选择图片文件')
    return
  }

  const url = URL.createObjectURL(file)
  const img = new Image()
  img.onload = async () => {
    URL.revokeObjectURL(url)
    sourceCanvas = document.createElement('canvas')
    sourceCanvas.width = img.naturalWidth
    sourceCanvas.height = img.naturalHeight
    sourceCanvas.getContext('2d').drawImage(img, 0, 0)

    maskCache = null
    hasImage.value = true
    sourceInfo.value = `${file.name} · ${img.naturalWidth}×${img.naturalHeight}`
    resetCompose()
    cropApplied.value = false
    originalCanvas = null

    await nextTick()
    renderCropCanvas()
    resetCrop()
    if (autoRemoveBg.value) await runSegment()
    compose()
  }
  img.onerror = () => {
    URL.revokeObjectURL(url)
    ElMessage.error('图片解析失败,请更换文件')
  }
  img.src = url
}

/* ------------------------- 抠图与合成 ------------------------- */

// 分割在缩放后的画布上进行,掩码会在合成时按比例映射回原图
function buildSegmentInput() {
  const maxSide = 1024
  const {width, height} = sourceCanvas
  const scale = Math.min(1, maxSide / Math.max(width, height))
  if (scale === 1) return sourceCanvas
  const c = document.createElement('canvas')
  c.width = Math.round(width * scale)
  c.height = Math.round(height * scale)
  c.getContext('2d').drawImage(sourceCanvas, 0, 0, c.width, c.height)
  return c
}

async function runSegment() {
  if (!sourceCanvas) return false
  processing.value = true
  statusType.value = 'info'
  statusMsg.value = '正在加载人像分割模型并抠图…'
  try {
    await ensureSession()
    maskCache = await segmentPerson(buildSegmentInput())
    statusType.value = 'success'
    statusMsg.value = '已完成人像抠图,可直接切换底色'
    return true
  } catch (err) {
    console.error('[IdPhoto] segment failed:', err)
    maskCache = null
    autoRemoveBg.value = false
    statusType.value = 'warning'
    statusMsg.value = '人像分割模型加载失败,已切换为「仅裁剪」模式(透明 PNG 仍可合成底色)'
    return false
  } finally {
    processing.value = false
  }
}

async function onToggleRemoveBg(val) {
  if (val && !maskCache) {
    await runSegment()
  }
  compose()
}

// 生成"已换底"的整图,后续裁剪与排版都基于它
function compose() {
  if (!sourceCanvas) return

  composedCanvas = document.createElement('canvas')
  composedCanvas.width = sourceCanvas.width
  composedCanvas.height = sourceCanvas.height
  const ctx = composedCanvas.getContext('2d')

  if (autoRemoveBg.value && maskCache) {
    const {r, g, b} = hexToRgb(bgColor.value)
    ctx.drawImage(sourceCanvas, 0, 0)
    const imageData = ctx.getImageData(0, 0, composedCanvas.width, composedCanvas.height)
    applyBgColor(imageData, maskCache.maskData, maskCache.maskWidth, maskCache.maskHeight, r, g, b, feather.value)
    ctx.putImageData(imageData, 0, 0)
  } else {
    // 未抠图:先铺底色再叠加原图,带透明通道的 PNG 同样能换底
    ctx.fillStyle = bgColor.value
    ctx.fillRect(0, 0, composedCanvas.width, composedCanvas.height)
    ctx.drawImage(sourceCanvas, 0, 0)
  }

  repaint()
}

function hexToRgb(hex) {
  let h = (hex || '#FFFFFF').replace('#', '')
  if (h.length === 3) h = h.split('').map(c => c + c).join('')
  return {
    r: parseInt(h.substring(0, 2), 16) || 0,
    g: parseInt(h.substring(2, 4), 16) || 0,
    b: parseInt(h.substring(4, 6), 16) || 0
  }
}

function resetCompose() {
  zoom.value = 1
  offsetX.value = 0
  offsetY.value = 0
}

/* ------------------------- 裁剪原图 ------------------------- */

function renderCropCanvas() {
  const cv = cropCanvas.value
  if (!cv || !sourceCanvas) return
  const W = sourceCanvas.width
  const H = sourceCanvas.height
  const maxW = 480
  const maxH = 460
  const s = Math.min(maxW / W, maxH / H, 1)
  const w = Math.max(1, Math.round(W * s))
  const h = Math.max(1, Math.round(H * s))
  viewScale.value = s
  cv.width = w
  cv.height = h
  const ctx = cv.getContext('2d')
  ctx.clearRect(0, 0, w, h)
  ctx.drawImage(sourceCanvas, 0, 0, w, h)
}

function cloneCanvas(src) {
  const c = document.createElement('canvas')
  c.width = src.width
  c.height = src.height
  c.getContext('2d').drawImage(src, 0, 0)
  return c
}

function getHandle(px, py) {
  const t = 14 / getDispScale()
  const corners = {
    tl: [cropRect.x, cropRect.y],
    tr: [cropRect.x + cropRect.w, cropRect.y],
    bl: [cropRect.x, cropRect.y + cropRect.h],
    br: [cropRect.x + cropRect.w, cropRect.y + cropRect.h]
  }
  for (const k in corners) {
    if (Math.abs(px - corners[k][0]) <= t && Math.abs(py - corners[k][1]) <= t) return k
  }
  return null
}

function insideCrop(px, py) {
  return px >= cropRect.x && px <= cropRect.x + cropRect.w &&
      py >= cropRect.y && py <= cropRect.y + cropRect.h
}

function onCropDown(e) {
  if (!sourceCanvas) return
  const {x, y} = toSource(e)
  const handle = getHandle(x, y)
  cropDrag.active = true
  cropDrag.startMouseX = x
  cropDrag.startMouseY = y
  cropDrag.startX = cropRect.x
  cropDrag.startY = cropRect.y
  cropDrag.startW = cropRect.w
  cropDrag.startH = cropRect.h

  if (handle) {
    cropDrag.mode = 'resize'
    cropDrag.handle = handle
    const anchors = {
      tl: [cropRect.x + cropRect.w, cropRect.y + cropRect.h],
      tr: [cropRect.x, cropRect.y + cropRect.h],
      bl: [cropRect.x + cropRect.w, cropRect.y],
      br: [cropRect.x, cropRect.y]
    }
    cropDrag.anchorX = anchors[handle][0]
    cropDrag.anchorY = anchors[handle][1]
  } else if (insideCrop(x, y)) {
    cropDrag.mode = 'move'
  } else {
    cropDrag.mode = 'new'
    cropRect.x = x
    cropRect.y = y
    cropRect.w = 0
    cropRect.h = 0
  }
  window.addEventListener('mousemove', onCropMove)
  window.addEventListener('mouseup', onCropUp)
}

function onCropMove(e) {
  if (!cropDrag.active || !sourceCanvas) return
  const {x, y} = toSource(e)
  const W = sourceCanvas.width
  const H = sourceCanvas.height

  if (cropDrag.mode === 'move') {
    const dx = x - cropDrag.startMouseX
    const dy = y - cropDrag.startMouseY
    const nx = Math.max(0, Math.min(cropDrag.startX + dx, W - cropDrag.startW))
    const ny = Math.max(0, Math.min(cropDrag.startY + dy, H - cropDrag.startH))
    cropRect.x = nx
    cropRect.y = ny
    cropRect.w = cropDrag.startW
    cropRect.h = cropDrag.startH
    return
  }

  if (cropDrag.mode === 'resize') {
    const x2 = Math.max(0, Math.min(x, W))
    const y2 = Math.max(0, Math.min(y, H))
    let nx = Math.min(cropDrag.anchorX, x2)
    let ny = Math.min(cropDrag.anchorY, y2)
    let nw = Math.abs(x2 - cropDrag.anchorX)
    let nh = Math.abs(y2 - cropDrag.anchorY)
    if (cropLockRatio.value) {
      const r = targetSize.value.w / targetSize.value.h
      nh = nw / r
      if (cropDrag.handle.includes('t')) ny = cropDrag.anchorY - nh
      else ny = cropDrag.anchorY
    }
    nx = Math.max(0, Math.min(nx, W))
    ny = Math.max(0, Math.min(ny, H))
    nw = Math.min(nw, W - nx)
    nh = Math.min(nh, H - ny)
    cropRect.x = nx
    cropRect.y = ny
    cropRect.w = nw
    cropRect.h = nh
    return
  }

  // 新选区
  let nx = Math.min(cropDrag.startMouseX, x)
  let ny = Math.min(cropDrag.startMouseY, y)
  let nw = Math.abs(x - cropDrag.startMouseX)
  let nh = Math.abs(y - cropDrag.startMouseY)
  if (cropLockRatio.value) {
    const r = targetSize.value.w / targetSize.value.h
    if (nh * r > nw) nw = nh * r
    else nh = nw / r
    if (x < cropDrag.startMouseX) nx = cropDrag.startMouseX - nw
    if (y < cropDrag.startMouseY) ny = cropDrag.startMouseY - nh
  }
  nx = Math.max(0, Math.min(nx, W))
  ny = Math.max(0, Math.min(ny, H))
  nw = Math.min(nw, W - nx)
  nh = Math.min(nh, H - ny)
  cropRect.x = nx
  cropRect.y = ny
  cropRect.w = nw
  cropRect.h = nh
}

function onCropUp() {
  cropDrag.active = false
  window.removeEventListener('mousemove', onCropMove)
  window.removeEventListener('mouseup', onCropUp)
}

function onCropTouchStart(e) {
  if (!e.touches.length) return
  onCropDown({clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})
}

function onCropTouchMove(e) {
  if (!e.touches.length) return
  onCropMove({clientX: e.touches[0].clientX, clientY: e.touches[0].clientY})
}

function onCropTouchEnd() {
  onCropUp()
}

// 按当前规格比例(或整图)居中框选
function frameCrop() {
  if (!sourceCanvas) return
  const W = sourceCanvas.width
  const H = sourceCanvas.height
  if (cropLockRatio.value) {
    const r = targetSize.value.w / targetSize.value.h
    let cw, ch
    if (W / H > r) {
      ch = H
      cw = ch * r
    } else {
      cw = W
      ch = cw / r
    }
    cropRect.x = (W - cw) / 2
    cropRect.y = (H - ch) / 2
    cropRect.w = cw
    cropRect.h = ch
  } else {
    resetCrop()
  }
}

// 重置为整图(无裁剪)
function resetCrop() {
  if (!sourceCanvas) return
  cropRect.x = 0
  cropRect.y = 0
  cropRect.w = sourceCanvas.width
  cropRect.h = sourceCanvas.height
}

// 应用裁剪:裁出区域成为新的 sourceCanvas,并重跑流水线
function applyCrop() {
  if (!sourceCanvas) return
  if (!cropDirty.value) {
    ElMessage.info('选区与图片一致,无需裁剪')
    return
  }
  const W = sourceCanvas.width
  const H = sourceCanvas.height
  const x = Math.round(cropRect.x)
  const y = Math.round(cropRect.y)
  const w = Math.round(cropRect.w)
  const h = Math.round(cropRect.h)
  // 铁律:对输入做有效性校验,不静默兜底
  if (!(w > 0 && h > 0) || x < 0 || y < 0 || x + w > W + 1 || y + h > H + 1) {
    ElMessage.error('裁剪区域无效,请重新框选')
    return
  }
  if (!originalCanvas) originalCanvas = cloneCanvas(sourceCanvas)
  const c = document.createElement('canvas')
  c.width = w
  c.height = h
  c.getContext('2d').drawImage(sourceCanvas, x, y, w, h, 0, 0, w, h)
  sourceCanvas = c
  cropApplied.value = true
  const name = sourceInfo.value.split(' · ')[0] || ''
  sourceInfo.value = `${name} · 裁剪后 ${w}×${h}`
  maskCache = null
  resetCompose()
  renderCropCanvas()
  cropRect.x = 0
  cropRect.y = 0
  cropRect.w = w
  cropRect.h = h
  nextTick(async () => {
    if (autoRemoveBg.value) await runSegment()
    compose()
  })
}

// 撤销裁剪,恢复原图
function undoCrop() {
  if (!originalCanvas) return
  sourceCanvas = originalCanvas
  originalCanvas = null
  cropApplied.value = false
  maskCache = null
  const name = sourceInfo.value.split(' · ')[0] || ''
  sourceInfo.value = `${name} · ${sourceCanvas.width}×${sourceCanvas.height}`
  resetCompose()
  renderCropCanvas()
  resetCrop()
  nextTick(async () => {
    if (autoRemoveBg.value) await runSegment()
    compose()
  })
}

/* ------------------------- 绘制 ------------------------- */

// 按"填满目标框"的方式绘制,再叠加缩放与位移
function drawPhotoTo(ctx, w, h) {
  ctx.fillStyle = bgColor.value
  ctx.fillRect(0, 0, w, h)
  if (!composedCanvas) return

  const sw = composedCanvas.width
  const sh = composedCanvas.height
  const scale = Math.max(w / sw, h / sh) * zoom.value
  const dw = sw * scale
  const dh = sh * scale
  const slackX = Math.max(0, (dw - w) / 2)
  const slackY = Math.max(0, (dh - h) / 2)
  const dx = (w - dw) / 2 + (offsetX.value / 100) * slackX
  const dy = (h - dh) / 2 + (offsetY.value / 100) * slackY

  ctx.drawImage(composedCanvas, dx, dy, dw, dh)
}

function renderPhotoCanvas() {
  const canvas = photoCanvas.value
  if (!canvas) return
  const {w, h} = targetSize.value
  canvas.width = w
  canvas.height = h
  drawPhotoTo(canvas.getContext('2d'), w, h)
}

function drawLayout() {
  const canvas = layoutCanvas.value
  if (!canvas || !composedCanvas) return

  canvas.width = PAPER_W
  canvas.height = PAPER_H
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = '#FFFFFF'
  ctx.fillRect(0, 0, PAPER_W, PAPER_H)

  const {w, h} = targetSize.value
  const {cols, rows} = layoutGrid.value

  // 单张照片只绘制一次,之后重复贴到相纸上
  const cell = document.createElement('canvas')
  cell.width = w
  cell.height = h
  drawPhotoTo(cell.getContext('2d'), w, h)

  const gridW = cols * w + (cols - 1) * PHOTO_GAP
  const gridH = rows * h + (rows - 1) * PHOTO_GAP
  const startX = Math.round((PAPER_W - gridW) / 2)
  const startY = Math.round((PAPER_H - gridH) / 2)

  const total = Math.min(layoutCount.value, cols * rows)
  for (let i = 0; i < total; i++) {
    const x = startX + (i % cols) * (w + PHOTO_GAP)
    const y = startY + Math.floor(i / cols) * (h + PHOTO_GAP)
    ctx.drawImage(cell, x, y)
    // 剪裁参考线
    ctx.strokeStyle = '#DCDFE6'
    ctx.lineWidth = 1
    ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1)
  }
}

function repaint() {
  renderPhotoCanvas()
  drawLayout()
}

/* ------------------------- 下载 ------------------------- */

function downloadCanvas(canvas, filename) {
  canvas.toBlob(blob => {
    if (!blob) {
      ElMessage.error('生成图片失败')
      return
    }
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    setTimeout(() => URL.revokeObjectURL(url), 1000)
  }, 'image/png')
}

function currentSizeLabel() {
  if (sizeKey.value === 'custom') return `${customW.value}x${customH.value}`
  return SIZES.find(i => i.key === sizeKey.value).label
}

function downloadPhoto() {
  if (!photoCanvas.value || !composedCanvas) return
  renderPhotoCanvas()
  downloadCanvas(photoCanvas.value, `证件照-${currentSizeLabel()}.png`)
  ElMessage.success('证件照已开始下载')
}

function downloadLayout() {
  if (!layoutCanvas.value || !composedCanvas) return
  drawLayout()
  downloadCanvas(layoutCanvas.value, `六寸排版-${currentSizeLabel()}-${layoutCount.value}张.png`)
  ElMessage.success('排版图已开始下载')
}

2.3 效果截图

证件照制作 效果截图

工具访问地址:https://www.i91tools.com/tools/id-photo