Appearance
实现 图片拼接与切图 工具
分类: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-input、el-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 效果截图
