打开后可断网处理,图片不上传,安全第一;无广告,完全免费
扫码先体验,需要处理证件照、资料图片、手机照片时,打开就能用。
手机里的图片越来越多,日常要处理图片的场景也越来越多:
- 上传证件照,尺寸不合适;
- 发送身份证明、材料截图,担心被别人拿去乱用;
- 手机空间不够,照片太大又舍不得删;
- 平台只支持 JPG 或 PNG,图片格式不符合要求;
- 想简单旋转、翻转、加个文字说明,又不想下载复杂修图软件。
平时做图片处理,很多人第一反应是把图片传到服务端处理:上传、排队、转码、下载。
但如果处理的是证件照、身份证明、合同截图、报名材料这类图片,上传就会带来两个问题:
- 用户不放心:图片会不会被保存、会不会被二次使用;
- 链路变复杂:需要服务端、存储、清理任务、内容安全、失败重试。
所以我做这个小程序时,思路反过来了:能不能把图片处理都放在手机本地完成?
最终实现的核心能力包括:
- 给证件照、资料图添加文字水印;
- 单个水印支持拖动、双指缩放、双指旋转;
- 图片压缩;
- JPG / PNG 格式转换;
- 一寸照、二寸照、头像等尺寸裁剪;
- 5 寸、6 寸、A4 照片打印排版;
- 基础编辑:旋转、翻转、文字、边框;
- 导出后保存到手机相册。
这篇文章不写产品介绍,主要拆一下小程序端怎么用 Canvas 把这些功能做出来。
一、整体方案:不走业务服务端,图片在本地处理
整个流程可以概括成这几步:
wx.chooseImage从相册或相机拿到本地临时文件;wx.getImageInfo获取图片宽高;- 使用 Canvas 在本地绘制处理后的图片;
wx.canvasToTempFilePath导出临时图片;wx.saveImageToPhotosAlbum保存到相册。
对应到页面功能,大致是这样的:
| 功能 | 主要 API | 说明 |
|---|---|---|
| 添加水印 | Canvas 2D、canvasToTempFilePath | 自定义文字、颜色、透明度、位置、缩放、旋转 |
| 图片压缩 | wx.compressImage | 按质量参数压缩成本地临时图 |
| 尺寸裁剪 | Canvas 2D | 按目标比例 cover 绘制 |
| 打印排版 | Canvas 2D | 计算纸张内可放多少张照片 |
| 格式转换 | Canvas、fileType | 导出 JPG / PNG |
| 保存相册 | wx.saveImageToPhotosAlbum | 保存到系统相册 |
这里说的“断网可用”有一个边界:小程序首次加载本身可能需要微信运行环境加载页面资源;但进入功能页、选择本地图片后,核心图片处理不依赖业务服务端,不需要把原图上传到自己的服务器。
二、先把图片变成可处理的数据
选图这一步要拿到三类信息:
- 图片本地路径;
- 图片原始宽高;
- 图片大小,用来展示给用户。
我把小程序回调 API 先简单 Promise 化,后面写异步流程会清楚很多。
function getFileSize(filePath) {
return new Promise((resolve) => {
wx.getFileSystemManager().getFileInfo({
filePath,
success: (res) => resolve(res.size || 0),
fail: () => resolve(0)
})
})
}
function getImageInfo(src) {
return new Promise((resolve, reject) => {
wx.getImageInfo({
src,
success: resolve,
fail: reject
})
})
}
function formatBytes(size) {
if (!size) return '--'
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / 1024 / 1024).toFixed(2)} MB`
}
选图后,把这些信息写入页面状态:
chooseImage() {
wx.chooseImage({
count: 1,
sizeType: ['original'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths[0]
const pickedSize = res.tempFiles && res.tempFiles[0] ? res.tempFiles[0].size : 0
try {
wx.showLoading({ title: '正在打开图片' })
const info = await getImageInfo(filePath)
const fileSize = pickedSize || await getFileSize(filePath)
this.setData({
imagePath: info.path || filePath,
originalSize: formatBytes(fileSize),
originalWidth: info.width,
originalHeight: info.height,
resultPath: '',
resultSize: '--'
})
await this.renderPreview()
} finally {
wx.hideLoading()
}
}
})
}
这一步完成后,后面的水印、裁剪、排版、转换都可以基于 imagePath + width + height 做本地绘制。
三、水印为什么要用“双 Canvas”
水印页用了两个 Canvas:
- 一个是用户看到的预览 Canvas;
- 一个是隐藏的导出 Canvas。
预览 Canvas 要适配手机屏幕,通常会把图片缩小后完整展示;导出 Canvas 则要使用原图尺寸,保证保存后的图片足够清晰。
WXML 结构大致是这样:
<canvas
type="2d"
id="watermarkPreviewCanvas"
class="preview-canvas"
style="height: {{previewHeightPx}}px;"
catchtouchstart="onPreviewTouchStart"
catchtouchmove="onPreviewTouchMove"
catchtouchend="onPreviewTouchEnd"
catchtouchcancel="onPreviewTouchEnd"
></canvas>
<canvas
type="2d"
id="watermarkOutputCanvas"
class="hidden-canvas"
></canvas>
这里有两个细节:
第一,触摸事件用的是 catchtouch*,不是 bindtouch*。因为水印拖动时如果事件继续冒泡,下面的页面滚动区域也会跟着动,用户会感觉“事件穿透”。
第二,预览 Canvas 上会画辅助边框,提示用户水印可以拖动;但导出 Canvas 不画辅助边框。这个区别靠一个参数控制,避免“看到的拖动框被保存到相册里”。
四、Canvas 2D 节点获取与图片加载
小程序新版 Canvas 2D 推荐通过节点方式获取:
function queryCanvas(page, selector) {
return new Promise((resolve, reject) => {
wx.createSelectorQuery()
.in(page)
.select(selector)
.fields({ node: true, size: true })
.exec((res) => {
const item = res && res[0]
if (!item || !item.node) {
reject(new Error(`Canvas not found: ${selector}`))
return
}
resolve(item)
})
})
}
function loadCanvasImage(canvas, src) {
return new Promise((resolve, reject) => {
const image = canvas.createImage()
image.onload = () => resolve(image)
image.onerror = reject
image.src = src
})
}
预览时要处理 DPR,不然文字和图片容易发糊:
async renderPreview() {
if (!this.data.imagePath) return
const item = await queryCanvas(this, '#watermarkPreviewCanvas')
const canvas = item.node
const dpr = wx.getSystemInfoSync().pixelRatio || 1
const width = item.width
const height = item.height
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
const ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const image = await loadCanvasImage(canvas, this.data.imagePath)
this.drawWatermarkCanvas(ctx, image, width, height, true)
this.setData({ isPreviewReady: true })
}
五、预览图和导出图如何保持一致
预览图是“图片完整放进一个固定区域”,类似 object-fit: contain。
这里用一个 getContainRect 计算图片在预览 Canvas 里的实际位置:
function getContainRect(boxWidth, boxHeight, imageWidth, imageHeight) {
const scale = Math.min(boxWidth / imageWidth, boxHeight / imageHeight)
const width = imageWidth * scale
const height = imageHeight * scale
return {
x: (boxWidth - width) / 2,
y: (boxHeight - height) / 2,
width,
height
}
}
预览时用 contain 区域,导出时用原图区域:
drawWatermarkCanvas(ctx, image, canvasWidth, canvasHeight, containPreview) {
const rect = containPreview
? getContainRect(canvasWidth, canvasHeight, this.data.originalWidth, this.data.originalHeight)
: { x: 0, y: 0, width: canvasWidth, height: canvasHeight }
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
ctx.drawImage(image, rect.x, rect.y, rect.width, rect.height)
const text = this.getWatermarkText()
if (!text) return
const fontSize = this.getFontSize(rect.width, rect.height)
const family = this.getFontFamily()
ctx.save()
ctx.globalAlpha = this.data.watermarkOpacity
ctx.fillStyle = this.data.watermarkColor
ctx.font = `600 ${fontSize}px ${family}`
ctx.textBaseline = 'middle'
if (this.data.watermarkPosition === 'tile') {
this.drawTileText(ctx, text, rect, fontSize)
} else {
this.drawSingleText(ctx, text, rect, fontSize, containPreview)
}
ctx.restore()
}
这里我没有直接存水印的像素坐标,而是存了比例坐标:
watermarkXRatio: 0.78,
watermarkYRatio: 0.84,
watermarkScale: 1,
watermarkRotate: 0
这样预览图和原图尺寸不一样时,也能映射到同一相对位置。
六、单个水印:拖动、缩放、旋转怎么做
单个水印的绘制靠四步:
- 移动到水印中心点;
- 旋转;
- 缩放;
- 绘制文字。
drawSingleText(ctx, text, rect, fontSize, showSelection) {
ctx.textAlign = 'center'
ctx.save()
ctx.translate(
rect.x + rect.width * this.data.watermarkXRatio,
rect.y + rect.height * this.data.watermarkYRatio
)
ctx.rotate(this.data.watermarkRotate * Math.PI / 180)
ctx.scale(this.data.watermarkScale, this.data.watermarkScale)
this.drawTextWithShadow(ctx, text, 0, 0)
if (showSelection) {
this.drawSelectionBox(ctx, text, fontSize)
}
ctx.restore()
}
预览里的虚线框和四角圆点只在 showSelection 为 true 时画:
drawSelectionBox(ctx, text, fontSize) {
const metrics = ctx.measureText(text)
const paddingX = Math.max(12, fontSize * 0.45)
const paddingY = Math.max(8, fontSize * 0.28)
const width = metrics.width + paddingX * 2
const height = fontSize * 1.4 + paddingY * 2
const x = -width / 2
const y = -height / 2
const handle = Math.max(8, fontSize * 0.26)
ctx.save()
ctx.globalAlpha = 0.95
ctx.strokeStyle = '#9333ea'
ctx.lineWidth = Math.max(2, fontSize * 0.06)
ctx.setLineDash && ctx.setLineDash([8, 6])
ctx.strokeRect(x, y, width, height)
ctx.setLineDash && ctx.setLineDash([])
ctx.fillStyle = '#ffffff'
;[
[x, y],
[x + width, y],
[x, y + height],
[x + width, y + height]
].forEach(([cx, cy]) => {
ctx.beginPath()
ctx.arc(cx, cy, handle, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
})
ctx.restore()
}
效果如下:
七、手势计算:一指移动,双指缩放和旋转
触摸开始时,记录初始状态:
onPreviewTouchStart(e) {
if (!this.data.imagePath || this.data.watermarkPosition === 'tile') return
const touches = e.touches || []
if (touches.length === 1) {
this.touchState = {
mode: 'move',
startX: touches[0].clientX,
startY: touches[0].clientY,
startRatioX: this.data.watermarkXRatio,
startRatioY: this.data.watermarkYRatio
}
return
}
if (touches.length >= 2) {
const gesture = this.getGesture(touches)
this.touchState = {
mode: 'gesture',
startDistance: gesture.distance,
startAngle: gesture.angle,
startScale: this.data.watermarkScale,
startRotate: this.data.watermarkRotate
}
}
}
一指移动时,把手指位移换算成图片区域里的比例变化:
if (this.touchState.mode === 'move' && touches.length === 1) {
const dx = touches[0].clientX - this.touchState.startX
const dy = touches[0].clientY - this.touchState.startY
const rect = this.getPreviewImageRect()
this.setData({
watermarkXRatio: clamp(this.touchState.startRatioX + dx / rect.width, 0.05, 0.95),
watermarkYRatio: clamp(this.touchState.startRatioY + dy / rect.height, 0.08, 0.92),
showGestureGuide: false,
resultPath: ''
}, () => this.schedulePreview())
}
双指时计算两根手指之间的距离和角度:
getGesture(touches) {
const dx = touches[1].clientX - touches[0].clientX
const dy = touches[1].clientY - touches[0].clientY
return {
distance: Math.sqrt(dx * dx + dy * dy) || 1,
angle: Math.atan2(dy, dx) * 180 / Math.PI
}
}
再通过“当前距离 / 初始距离”得到缩放比例,通过角度差得到旋转角度:
if (this.touchState.mode === 'gesture' && touches.length >= 2) {
const gesture = this.getGesture(touches)
const scale = gesture.distance / this.touchState.startDistance
const rotate = gesture.angle - this.touchState.startAngle
this.setData({
watermarkScale: clamp(this.touchState.startScale * scale, 0.45, 2.8),
watermarkRotate: this.touchState.startRotate + rotate,
showGestureGuide: false,
resultPath: ''
}, () => this.schedulePreview())
}
这里加 clamp 是为了避免用户把水印缩到看不见,或者放大到完全挡住图片。
八、平铺水印:适合防二次使用
除了单个水印,还可以做平铺水印。平铺水印通常用于证件照、合同截图这类“不希望被二次使用”的场景。
核心就是按照固定间距循环绘制,并给文字一个倾斜角度:
drawTileText(ctx, text, rect, fontSize) {
const stepX = clamp(fontSize * Math.max(text.length, 6) * 1.65, 160, rect.width * 0.75)
const stepY = clamp(fontSize * 4.2, 110, rect.height * 0.42)
const startX = rect.x - stepX
const endX = rect.x + rect.width + stepX
const startY = rect.y - stepY
const endY = rect.y + rect.height + stepY
ctx.textAlign = 'center'
for (let y = startY; y < endY; y += stepY) {
for (let x = startX; x < endX; x += stepX) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(-25 * Math.PI / 180)
this.drawTextWithShadow(ctx, text, 0, 0)
ctx.restore()
}
}
}
水印效果:
九、导出图片并保存到相册
导出时使用隐藏 Canvas,而且直接使用原图尺寸:
async addWatermark({ autoSave = false } = {}) {
if (!this.data.imagePath || this.data.isProcessing) return
try {
this.setData({ isProcessing: true })
wx.showLoading({ title: autoSave ? '正在保存' : '正在添加水印' })
const item = await queryCanvas(this, '#watermarkOutputCanvas')
const canvas = item.node
canvas.width = this.data.originalWidth
canvas.height = this.data.originalHeight
const ctx = canvas.getContext('2d')
const image = await loadCanvasImage(canvas, this.data.imagePath)
this.drawWatermarkCanvas(ctx, image, canvas.width, canvas.height, false)
const resultPath = await this.exportCanvas(canvas)
if (autoSave) {
await this.savePathToAlbum(resultPath)
}
} finally {
wx.hideLoading()
this.setData({ isProcessing: false })
}
}
canvasToTempFilePath 可以控制导出格式和质量:
exportCanvas(canvas) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath({
canvas,
width: this.data.originalWidth,
height: this.data.originalHeight,
fileType: 'jpg',
quality: 0.92,
destWidth: this.data.originalWidth,
destHeight: this.data.originalHeight,
success: (res) => resolve(res.tempFilePath),
fail: reject
}, this)
})
}
保存到相册:
savePathToAlbum(filePath) {
return new Promise((resolve) => {
wx.saveImageToPhotosAlbum({
filePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
resolve()
},
fail: () => {
wx.showModal({
title: '需要相册权限',
content: '打开相册权限后,才能保存图片。',
confirmText: '去打开',
success: (res) => {
if (res.confirm) wx.openSetting()
resolve()
}
})
}
})
})
}
这里有个上线时常见的坑:开发者工具里能保存,不代表生产环境一定能保存。生产环境还要关注相册权限、隐私协议授权、失败回调里的 errMsg。排查时不要只看“相册权限开没开”,最好把失败原因完整打出来。
十、图片压缩:能用系统 API 就不手写
压缩图片可以直接使用 wx.compressImage。质量参数是 0 到 100:
const qualityMap = {
low: 0.3,
medium: 0.6,
high: 0.9
}
wx.compressImage({
src: this.data.imagePath,
quality: qualityMap[this.data.selectedQuality] * 100,
success: (res) => {
this.setData({
processedUrl: res.tempFilePath
})
}
})
压缩完成后再读取文件大小,就可以给用户展示“压缩后大小”和“节省比例”:
const fs = wx.getFileSystemManager()
fs.getFileInfo({
filePath: res.tempFilePath,
success: (fileInfo) => {
const original = parseFloat(this.data.originalSize) || 1000
const compressed = fileInfo.size / 1024
const savePercent = Math.round((1 - compressed / original) * 100)
this.setData({
compressedSize: compressed.toFixed(1) + ' KB',
savePercent: Math.max(0, savePercent),
processedUrl: res.tempFilePath
})
}
})
十一、照片打印排版:先算格子,再逐个绘制
打印排版的需求是:用户选择一寸照、二寸照等尺寸后,把照片自动铺到 5 寸、6 寸或 A4 纸上。
这个问题其实就是一个简单的排版算法:
- 确定纸张宽高;
- 确定单张照片宽高;
- 扣掉边距和间距;
- 计算行列数;
- 居中铺满。
getPrintLayout(photoWidth, photoHeight, paperWidth, paperHeight) {
const margin = Math.max(36, Math.round(Math.min(paperWidth, paperHeight) * 0.04))
const gap = Math.max(18, Math.round(Math.min(paperWidth, paperHeight) * 0.016))
const cols = Math.floor((paperWidth - margin * 2 + gap) / (photoWidth + gap))
const rows = Math.floor((paperHeight - margin * 2 + gap) / (photoHeight + gap))
if (cols < 1 || rows < 1) {
return { count: 0, slots: [] }
}
const usedWidth = cols * photoWidth + (cols - 1) * gap
const usedHeight = rows * photoHeight + (rows - 1) * gap
const startX = Math.round((paperWidth - usedWidth) / 2)
const startY = Math.round((paperHeight - usedHeight) / 2)
const slots = []
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
slots.push({
x: startX + col * (photoWidth + gap),
y: startY + row * (photoHeight + gap),
width: photoWidth,
height: photoHeight
})
}
}
return { count: slots.length, slots }
}
绘制时每个格子都做一次裁剪,保证图片不会画出照片框:
layout.slots.forEach((slot) => {
ctx.save()
ctx.beginPath()
ctx.rect(slot.x, slot.y, slot.width, slot.height)
ctx.clip()
this.drawCoverImage(ctx, image, slot.x, slot.y, slot.width, slot.height)
ctx.restore()
ctx.strokeStyle = '#ddd6fe'
ctx.lineWidth = 2
ctx.strokeRect(slot.x, slot.y, slot.width, slot.height)
})
其中 drawCoverImage 模拟的是 CSS 里的 object-fit: cover:
drawCoverImage(ctx, image, x, y, width, height) {
const scale = Math.max(width / this.data.imageWidth, height / this.data.imageHeight)
const drawWidth = this.data.imageWidth * scale
const drawHeight = this.data.imageHeight * scale
const drawX = x + (width - drawWidth) / 2
const drawY = y + (height - drawHeight) / 2
ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight)
}
打印排版页面:
十二、格式转换:JPG 要先铺白底
PNG 可能有透明通道,但 JPG 没有透明通道。如果直接把透明 PNG 导出成 JPG,透明区域可能变黑。
所以导出 JPG 前先铺一层白底:
drawToCanvas(width, height) {
return new Promise((resolve) => {
const ctx = wx.createCanvasContext('formatCanvas', this)
ctx.clearRect(0, 0, width, height)
if (this.data.selectedFormat === 'jpg') {
ctx.setFillStyle('#ffffff')
ctx.fillRect(0, 0, width, height)
}
ctx.drawImage(this.data.imagePath, 0, 0, width, height)
ctx.draw(false, () => setTimeout(resolve, 80))
})
}
导出时根据用户选择设置 fileType:
wx.canvasToTempFilePath({
canvasId: 'formatCanvas',
fileType: this.data.selectedFormat,
width,
height,
destWidth: width,
destHeight: height,
quality: 0.92,
success: (res) => resolve(res.tempFilePath),
fail: reject
}, this)
十三、基础编辑:旋转、翻转、边框、文字
基础编辑本质上还是 Canvas 变换。
旋转和翻转的关键是先把坐标移动到画布中心:
ctx.save()
ctx.translate(outputWidth / 2, outputHeight / 2)
ctx.rotate(this.data.rotate * Math.PI / 180)
ctx.scale(this.data.flip ? -1 : 1, 1)
ctx.drawImage(
image,
-this.data.imageWidth / 2,
-this.data.imageHeight / 2,
this.data.imageWidth,
this.data.imageHeight
)
ctx.restore()
如果图片旋转 90 度或 270 度,输出画布宽高要互换:
const rotated = this.data.rotate % 180 !== 0
const outputWidth = rotated ? this.data.imageHeight : this.data.imageWidth
const outputHeight = rotated ? this.data.imageWidth : this.data.imageHeight
文字标注和边框也都可以直接画在导出 Canvas 上:
if (this.data.border) {
const lineWidth = Math.max(8, Math.round(Math.min(outputWidth, outputHeight) * 0.018))
ctx.lineWidth = lineWidth
ctx.strokeStyle = '#9333ea'
ctx.strokeRect(lineWidth / 2, lineWidth / 2, outputWidth - lineWidth, outputHeight - lineWidth)
}
if (this.data.textMark) {
const fontSize = Math.max(28, Math.round(Math.min(outputWidth, outputHeight) * 0.055))
const margin = Math.max(24, fontSize)
ctx.font = `600 ${fontSize}px "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.textBaseline = 'bottom'
ctx.textAlign = 'left'
ctx.fillStyle = '#ffffff'
ctx.shadowColor = 'rgba(0,0,0,0.45)'
ctx.shadowBlur = 8
ctx.fillText(this.data.textMark, margin, outputHeight - margin)
ctx.shadowColor = 'transparent'
}
十四、几个实践中的坑
1. 预览和导出不能共用同一个尺寸
预览 Canvas 为了适配手机屏幕,尺寸通常很小;导出图要用原图尺寸。否则用户保存出来的图会明显变糊。
2. 辅助框不要画进导出图
拖动水印时需要虚线框和四角控制点帮助用户理解“这里可以拖”,但这些只是交互辅助,不应该进入最终图片。
解决方式是把绘制函数做成同一套逻辑,但传入 showSelection 或 containPreview 参数区分预览和导出。
3. 长图会盖住下面操作区
如果图片是长图,预览区不能无限长,否则下面的配置项和保存按钮会被挤没。更好的做法是固定预览高度,让图片 contain 到固定区域内,配置区保持可操作。
4. 触摸事件要阻止穿透
水印拖动时,Canvas 下方页面也在滚动,是因为触摸事件冒泡。这里用 catchtouchstart / catchtouchmove 可以让拖动更稳定。
5. 内容安全接口和本地处理的边界
如果图片或文案要上传到平台、进入社区内容流,应该接入微信内容安全能力,比如文本检测、图片检测。
但如果功能只是“用户选择本地图片 -> 本地 Canvas 处理 -> 保存回自己的相册”,这条链路没有业务服务端存储,也不把图片发布给其他用户。是否接内容安全,要看产品是否存在上传、发布、分享生成内容到公共场景的链路。
十五、总结
用小程序做本地图片处理,核心并不复杂:
- 用
wx.chooseImage拿本地图片; - 用
wx.getImageInfo获取宽高; - 用 Canvas 2D 做绘制、变换和排版;
- 用
wx.canvasToTempFilePath导出结果; - 用
wx.saveImageToPhotosAlbum保存到相册; - 压缩场景优先使用
wx.compressImage。
真正需要花时间打磨的是这些细节:
- 预览和导出保持一致;
- 长图场景不遮挡操作;
- 手势拖动不穿透;
- 辅助框不进入最终图片;
- JPG 转换时处理透明背景;
- 保存相册时处理权限和生产环境失败原因;
- 用尽量少的按钮完成完整操作。
这类工具最适合放在小程序端做:打开即用,不依赖复杂后端,用户处理敏感图片时也更安心。