Skip to content

实现 图片拼接与切图 工具

分类:developer | 标签:拼接、切图、九宫格 多图拼接与长图切图、九宫格导出

2.1 功能说明

多图拼接与长图切图、九宫格导出

2.1.1 使用指南

功能说明

本工具提供两大能力:把多张图片合并成一张长图/网格图,以及把一张图片切成多个小块。所有处理都在浏览器本地用 Canvas 完成,图片不会上传到任何服务器。

拼接模式

横向拼接:多张图片从左到右排列。选择「等比缩放」时,所有图片会按最高的一张统一高度后再拼接,保证接缝处高度一致;选择「保持原图」时,画布高度取最高的一张,其余图片按对齐方式放置,空白处用背景色填充。

纵向拼接:多张图片从上到下排列,规则与横向对称,「等比缩放」以最宽的一张为准。

网格拼接:按指定列数排布,行数自动计算。单元格尺寸取所有图片中最大的宽和高,每张图在单元格内等比居中显示,适合做九宫格、对比图。

还可以设置图片间距和背景颜色(可勾选透明背景,导出带透明通道的 PNG)。列表中可以上下调整顺序或删除单张图片。

切图模式

行列网格:按指定的行数和列数把图片均分成若干块,例如 2 行 3 列得到 6 张图。

九宫格:固定 3 行 3 列,常用于社交平台的九宫格图片发布。

长图分段:把一张长图沿纵向平均切成 N 段,适合把超长截图拆成多张便于阅读或上传。

切图结果会逐张预览,每张都有独立的 PNG 下载按钮;也可以点击「依次下载全部」批量保存。由于不引入任何第三方库,工具不提供打包 ZIP 功能,批量下载会连续触发多个下载请求,浏览器可能会询问是否允许多文件下载,请选择允许。

使用提示

支持 JPG、PNG、WebP、GIF(取首帧)、BMP 等浏览器可解码的图片格式。输出统一为 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> 中定义的主要函数/方法:

  • nextId()
  • loadImageFile()
  • canvasToObjectURL()
  • downloadCanvas()
  • baseName()
  • sleep()
  • pickMergeFiles()
  • addMergeFiles()
  • onPickMerge()
  • onDropMerge()
  • moveItem()
  • removeItem()
  • clearMergedPreview()
  • resetMerge()
  • offsetFor()
  • doMerge()
  • downloadMerged()
  • pickSplitFile()
  • setSplitSource()
  • onPickSplit()
  • onDropSplit()
  • clearPieces()
  • resetSplit()
  • doSplit()
  • downloadPiece()
  • downloadAllPieces()

2.2.3 关键实现代码

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

vue
import { ref, markRaw, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const mode = ref('merge')

/* ---------------- 通用工具函数 ---------------- */

let seq = 0
const nextId = () => 'p' + (++seq)

function loadImageFile(file) {
  return new Promise((resolve, reject) => {
    const objectURL = URL.createObjectURL(file)
    const img = new Image()
    img.onload = () => {
      resolve({
        id: nextId(),
        name: file.name || '未命名图片',
        url: objectURL,
        img: markRaw(img),
        width: img.naturalWidth || img.width,
        height: img.naturalHeight || img.height
      })
    }
    img.onerror = () => {
      URL.revokeObjectURL(objectURL)
      reject(new Error('无法解析图片:' + (file.name || '未命名')))
    }
    img.src = objectURL
  })
}

function canvasToObjectURL(canvas) {
  return new Promise((resolve) => {
    canvas.toBlob((blob) => {
      resolve(blob ? URL.createObjectURL(blob) : '')
    }, 'image/png')
  })
}

function downloadCanvas(canvas, filename) {
  canvas.toBlob((blob) => {
    if (!blob) 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), 2000)
  }, 'image/png')
}

function baseName(name) {
  const dot = name.lastIndexOf('.')
  return dot > 0 ? name.slice(0, dot) : name
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

/* ---------------- 拼接 ---------------- */

const mergeInput = ref(null)
const items = ref([])
const mergeError = ref('')
const merging = ref(false)
const mergeMode = ref('horizontal')
const sizeMode = ref('scale')
const align = ref('center')
const gridCols = ref(3)
const gapPx = ref(0)
const bgColor = ref('#ffffff')
const transparentBg = ref(false)
const mergedUrl = ref('')
const mergedSize = ref('')
let mergedCanvas = null

const pickMergeFiles = () => mergeInput.value && mergeInput.value.click()

async function addMergeFiles(fileList) {
  mergeError.value = ''
  const files = Array.from(fileList || []).filter((f) => f.type.indexOf('image/') === 0)
  if (!files.length) {
    mergeError.value = '没有检测到可用的图片文件'
    return
  }
  for (const file of files) {
    try {
      const item = await loadImageFile(file)
      items.value.push(item)
    } catch (e) {
      mergeError.value = e.message
    }
  }
}

async function onPickMerge(evt) {
  await addMergeFiles(evt.target.files)
  evt.target.value = ''
}

async function onDropMerge(evt) {
  await addMergeFiles(evt.dataTransfer && evt.dataTransfer.files)
}

function moveItem(index, delta) {
  const target = index + delta
  if (target < 0 || target >= items.value.length) return
  const list = items.value
  const tmp = list[index]
  list[index] = list[target]
  list[target] = tmp
}

function removeItem(index) {
  const [removed] = items.value.splice(index, 1)
  if (removed) URL.revokeObjectURL(removed.url)
}

function clearMergedPreview() {
  if (mergedUrl.value) URL.revokeObjectURL(mergedUrl.value)
  mergedUrl.value = ''
  mergedSize.value = ''
  mergedCanvas = null
}

function resetMerge() {
  items.value.forEach((it) => URL.revokeObjectURL(it.url))
  items.value = []
  mergeError.value = ''
  clearMergedPreview()
}

function offsetFor(available, actual) {
  if (align.value === 'start') return 0
  if (align.value === 'end') return available - actual
  return Math.round((available - actual) / 2)
}

async function doMerge() {
  if (!items.value.length) {
    mergeError.value = '请先选择至少一张图片'
    return
  }
  mergeError.value = ''
  merging.value = true
  try {
    const list = items.value
    const gap = Number(gapPx.value) || 0
    const canvas = document.createElement('canvas')
    const draws = []
    let totalW = 0
    let totalH = 0

    if (mergeMode.value === 'horizontal') {
      if (sizeMode.value === 'scale') {
        const targetH = Math.max.apply(null, list.map((i) => i.height))
        let x = 0
        list.forEach((it, idx) => {
          const w = Math.round(it.width * (targetH / it.height))
          draws.push({ img: it.img, x, y: 0, w, h: targetH })
          x += w + (idx < list.length - 1 ? gap : 0)
        })
        totalW = x
        totalH = targetH
      } else {
        totalH = Math.max.apply(null, list.map((i) => i.height))
        let x = 0
        list.forEach((it, idx) => {
          draws.push({ img: it.img, x, y: offsetFor(totalH, it.height), w: it.width, h: it.height })
          x += it.width + (idx < list.length - 1 ? gap : 0)
        })
        totalW = x
      }
    } else if (mergeMode.value === 'vertical') {
      if (sizeMode.value === 'scale') {
        const targetW = Math.max.apply(null, list.map((i) => i.width))
        let y = 0
        list.forEach((it, idx) => {
          const h = Math.round(it.height * (targetW / it.width))
          draws.push({ img: it.img, x: 0, y, w: targetW, h })
          y += h + (idx < list.length - 1 ? gap : 0)
        })
        totalW = targetW
        totalH = y
      } else {
        totalW = Math.max.apply(null, list.map((i) => i.width))
        let y = 0
        list.forEach((it, idx) => {
          draws.push({ img: it.img, x: offsetFor(totalW, it.width), y, w: it.width, h: it.height })
          y += it.height + (idx < list.length - 1 ? gap : 0)
        })
        totalH = y
      }
    } else {
      const cols = Math.max(1, Number(gridCols.value) || 1)
      const rows = Math.ceil(list.length / cols)
      const cellW = Math.max.apply(null, list.map((i) => i.width))
      const cellH = Math.max.apply(null, list.map((i) => i.height))
      totalW = cols * cellW + (cols - 1) * gap
      totalH = rows * cellH + (rows - 1) * gap
      list.forEach((it, idx) => {
        const r = Math.floor(idx / cols)
        const c = idx % cols
        const scale = Math.min(cellW / it.width, cellH / it.height)
        const w = Math.round(it.width * scale)
        const h = Math.round(it.height * scale)
        const cellX = c * (cellW + gap)
        const cellY = r * (cellH + gap)
        draws.push({
          img: it.img,
          x: cellX + Math.round((cellW - w) / 2),
          y: cellY + Math.round((cellH - h) / 2),
          w,
          h
        })
      })
    }

    canvas.width = Math.max(1, totalW)
    canvas.height = Math.max(1, totalH)
    const ctx = canvas.getContext('2d')
    if (!transparentBg.value) {
      ctx.fillStyle = bgColor.value || '#ffffff'
      ctx.fillRect(0, 0, canvas.width, canvas.height)
    }
    ctx.imageSmoothingEnabled = true
    ctx.imageSmoothingQuality = 'high'
    draws.forEach((d) => ctx.drawImage(d.img, d.x, d.y, d.w, d.h))

    clearMergedPreview()
    mergedCanvas = canvas
    mergedUrl.value = await canvasToObjectURL(canvas)
    mergedSize.value = canvas.width + ' × ' + canvas.height
  } catch (e) {
    mergeError.value = '拼接失败:' + e.message
  } finally {
    merging.value = false
  }
}

function downloadMerged() {
  if (mergedCanvas) downloadCanvas(mergedCanvas, 'merged-' + Date.now() + '.png')
}

/* ---------------- 切图 ---------------- */

const splitInput = ref(null)
const source = ref(null)
const splitError = ref('')
const splitting = ref(false)
const splitMode = ref('grid')
const splitRows = ref(3)
const splitCols = ref(3)
const stripCount = ref(3)
const pieces = ref([])

const pickSplitFile = () => splitInput.value && splitInput.value.click()

async function setSplitSource(file) {
  splitError.value = ''
  if (!file || file.type.indexOf('image/') !== 0) {
    splitError.value = '请选择一个有效的图片文件'
    return
  }
  try {
    const item = await loadImageFile(file)
    if (source.value) URL.revokeObjectURL(source.value.url)
    clearPieces()
    source.value = item
  } catch (e) {
    splitError.value = e.message
  }
}

async function onPickSplit(evt) {
  await setSplitSource(evt.target.files && evt.target.files[0])
  evt.target.value = ''
}

async function onDropSplit(evt) {
  const files = evt.dataTransfer && evt.dataTransfer.files
  await setSplitSource(files && files[0])
}

function clearPieces() {
  pieces.value.forEach((p) => URL.revokeObjectURL(p.url))
  pieces.value = []
}

function resetSplit() {
  if (source.value) URL.revokeObjectURL(source.value.url)
  source.value = null
  splitError.value = ''
  clearPieces()
}

async function doSplit() {
  if (!source.value) {
    splitError.value = '请先选择一张图片'
    return
  }
  splitError.value = ''
  splitting.value = true
  try {
    let rows
    let cols
    if (splitMode.value === 'nine') {
      rows = 3
      cols = 3
    } else if (splitMode.value === 'strip') {
      rows = Math.max(2, Number(stripCount.value) || 2)
      cols = 1
    } else {
      rows = Math.max(1, Number(splitRows.value) || 1)
      cols = Math.max(1, Number(splitCols.value) || 1)
    }

    const src = source.value
    const pieceW = Math.floor(src.width / cols)
    const pieceH = Math.floor(src.height / rows)
    if (pieceW < 1 || pieceH < 1) {
      splitError.value = '切分份数过多,单块尺寸不足 1 像素'
      return
    }

    clearPieces()
    const prefix = baseName(src.name)
    const result = []
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        // 最后一行/列吃掉整除产生的余数,保证不丢像素
        const w = c === cols - 1 ? src.width - pieceW * c : pieceW
        const h = r === rows - 1 ? src.height - pieceH * r : pieceH
        const canvas = document.createElement('canvas')
        canvas.width = w
        canvas.height = h
        const ctx = canvas.getContext('2d')
        ctx.drawImage(src.img, pieceW * c, pieceH * r, w, h, 0, 0, w, h)
        const url = await canvasToObjectURL(canvas)
        const label = cols === 1 ? String(r + 1) : (r + 1) + '-' + (c + 1)
        result.push({
          id: nextId(),
          name: prefix + '_' + label + '.png',
          url,
          width: w,
          height: h,
          canvas: markRaw(canvas)
        })
      }
    }
    pieces.value = result
  } catch (e) {
    splitError.value = '切图失败:' + e.message
  } finally {
    splitting.value = false
  }
}

function downloadPiece(p) {
  downloadCanvas(p.canvas, p.name)
}

async function downloadAllPieces() {
  for (const p of pieces.value) {
    downloadCanvas(p.canvas, p.name)
    await sleep(320)
  }
}

onBeforeUnmount(() => {
  resetMerge()
  resetSplit()
})

2.3 效果截图

图片拼接与切图 效果截图

工具访问地址:https://www.i91tools.com/tools/image-splice