mcanvas
Version:
the image-composer or image-croper that can draw image/text/watermark or crop the image.
780 lines (704 loc) • 26.5 kB
text/typescript
import {
include,
extend,
throwError,
getSize,
is,
throwWarn,
belowIOS8,
TGetSizeImage,
forin,
transValue,
getLength,
splitWords,
} from '@Src/utils'
import { Canvas } from '@Src/canvas'
import { crop as cropFn } from '@Src/utils/crop'
export class MCanvas {
private ops: Required<TCanvas.options>
private cvs: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
// 绘制函数队列;
private queue: TCanvas.queue = []
// 回调函数池;
private fn = {
// 最后执行的函数;
success() {},
// 错误回调;
error(err) {},
}
private data: TCanvas.data = {
// 文字id;
textId: 0,
// 文字绘制数据;
text : {},
// 背景图数据;
bgConfig: null,
}
constructor(options: TCanvas.options = { }) {
// 配置canvas初始大小;
// width:画布宽度,Number,选填,默认为 500;
// height: 画布高度,Number,选填,默认与宽度一致;
this.ops = extend({
width: 500,
height: 500,
backgroundColor: '',
}, options)
this._init()
}
private _init() {
const { width, height, backgroundColor } = this.ops;
[this.cvs, this.ctx] = Canvas.create(width, height)
backgroundColor && this._setBgColor(backgroundColor)
}
// --------------------------------------------------------
// 绘制背景部分;
// --------------------------------------------------------
public background(image?: TCommon.image, bg: TCanvas.backgroundOptions = { type : 'origin' }) {
if (!image && !this.data.bgConfig) {
throwError('the init background must has a image.')
return this
}
// 缓存bg options, 用于重置;
if (image) {
bg.image = image
this.data.bgConfig = bg
} else if (this.data.bgConfig) {
bg = this.data.bgConfig
}
this.queue.push(() => {
if (bg.color) this._setBgColor(bg.color)
Canvas.getImage(bg.image)
.then(img => this._background(img, bg))
.catch(this.fn.error)
})
return this
}
// 设置画布颜色;
private _setBgColor(color: string) {
this.ctx.fillStyle = color
this.ctx.fillRect(0, 0, this.cvs.width, this.cvs.height)
}
private _getBgAlign(
left: TCanvas.backgroundOptions['left'],
iw: number,
cw: number,
cropScale: number
) {
let rv
if (is.str(left)) {
if (left === '50%' || left === 'center') {
rv = Math.abs((iw - cw / cropScale) / 2)
} else if (left === '100%') {
rv = Math.abs(iw - cw / cropScale)
} else if (left === '0%') {
rv = 0
}
} else if (is.num(left)) {
rv = left
} else {
rv = 0
}
return rv
}
private _background(img: HTMLImageElement, bg: TCanvas.backgroundOptions) {
const { iw, ih } = getSize(img)
// 图片与canvas的长宽比;
const iRatio = iw / ih
const cRatio = this.cvs.width / this.cvs.height
// 背景绘制参数;
let sx, sy, swidth, sheight, dx, dy, dwidth, dheight
let cropScale
switch (bg.type) {
case 'crop':
// 裁剪模式,固定canvas大小,原图铺满,超出的部分裁剪;
if (iRatio > cRatio) {
swidth = ih * cRatio
sheight = ih
cropScale = this.cvs.height / ih
} else {
swidth = iw
sheight = swidth / cRatio
cropScale = this.cvs.width / iw
}
sx = this._getBgAlign(bg.left, iw, this.cvs.width, cropScale)
sy = this._getBgAlign(bg.top, ih, this.cvs.height, cropScale)
dy = dx = 0
dheight = this.cvs.height
dwidth = this.cvs.width
break
case 'contain':
// 包含模式,固定canvas大小,包含背景图;
sy = sx = 0
swidth = iw
sheight = ih
if (iRatio > cRatio) {
dwidth = this.cvs.width
dheight = dwidth / iRatio
dx = bg.left || 0
dy = (bg.top || bg.top === 0) ? bg.top : (this.cvs.height - dheight) / 2
} else {
dheight = this.cvs.height
dwidth = dheight * iRatio
dy = bg.top || 0
dx = (bg.left || bg.left === 0) ? bg.left : (this.cvs.width - dwidth) / 2
}
break
case 'origin':
// 原图模式:canvas与原图大小一致,忽略初始化 传入的宽高参数;
// 同时,background 传入的 left/top 均被忽略;
this.cvs.width = iw
this.cvs.height = ih
sx = sy = 0
swidth = iw
sheight = ih
dx = dy = 0
dwidth = this.cvs.width
dheight = this.cvs.height
break
default:
throwError('background type error!')
return
}
this.ctx.drawImage(img, sx, sy, swidth, sheight, dx, dy, dwidth, dheight)
this._next()
}
// --------------------------------------------------------
// 绘制图层部分;
// --------------------------------------------------------
// 绘制矩形层;
public rect(ops: TCanvas.rectOptions = {}) {
this.queue.push(() => {
const { width: cw, height: ch } = this.cvs
const {
fillColor = '#fff',
strokeColor = fillColor,
strokeWidth = 0,
radius = 0,
} = ops
let { width = 100, height = 100, x = 0, y = 0 } = ops
width = transValue(cw, 0, width, 'pos') - 2 * strokeWidth,
height = transValue(ch, 0, height, 'pos') - 2 * strokeWidth
// 计算尾值时,与边框的关系则为相反
x = transValue(cw, width, x, 'pos') + (include(x, 'right') ? -strokeWidth : strokeWidth)
y = transValue(ch, height, y, 'pos') + (include(y, 'bottom') ? -strokeWidth : strokeWidth)
Canvas.drawRoundRect(
this.ctx,
x, y, width, height, radius,
fillColor, strokeWidth, strokeColor,
)
this._resetCtx()._next()
})
return this
}
// 绘制圆形层;
public circle(ops: TCanvas.circleOptions = {}) {
this.queue.push(() => {
const { fillColor = '#fff', strokeColor = fillColor, strokeWidth = 0 } = ops
const { width: cw, height: ch } = this.cvs
let { x = 0, y = 0, radius = 100 } = ops
const r = transValue(cw, 0, radius, 'pos') - 2 * strokeWidth
x = transValue(cw, 2 * r, x, 'pos') + r + (include(x, 'right') ? -strokeWidth : strokeWidth)
y = transValue(ch, 2 * r, y, 'pos') + r + (include(y, 'bottom') ? -strokeWidth : strokeWidth)
this.ctx.beginPath()
this.ctx.arc(x, y, r, 0, Math.PI * 2, false)
this.ctx.fillStyle = fillColor
this.ctx.fill()
this.ctx.strokeStyle = strokeColor
this.ctx.lineWidth = strokeWidth
this.ctx.stroke()
this.ctx.closePath()
this._resetCtx()._next()
})
return this
}
// 重置ctx属性;
private _resetCtx() {
this.ctx.setTransform(1, 0, 0, 1, 0, 0)
return this
}
// 绘制水印;基于 add 函数封装;
public watermark(
image: TCommon.image,
ops: TCanvas.watermarkOptions = {},
) {
if (!image) {
throwError('there is not image of watermark.')
return this
}
// 参数默认值;
const { width = '40%', pos = 'rightbottom', margin = 20 } = ops
const position: Required<TCanvas.position> = {
x: 0,
y: 0,
scale: 1,
rotate: 0,
}
switch (pos) {
case 'leftTop':
position.x = `left:${margin}`
position.y = `top:${margin}`
break
case 'leftBottom':
position.x = `left:${margin}`
position.y = `bottom:${margin}`
break
case 'rightTop':
position.x = `right:${margin}`
position.y = `top:${margin}`
break
case 'rightBottom':
position.x = `right:${margin}`
position.y = `bottom:${margin}`
break
default:
}
this.add(image, {
width,
pos: position,
})
return this
}
// 通用绘制图层函数;
// 使用方式:
// 多张图: add([{image:'',options:{}},{image:'',options:{}}]);
// 单张图: add(image,options);
public add(
image: TCanvas.addData[] | TCommon.image,
options?: TCanvas.addOptions
) {
// 默认参数;
const def = {
width: '100%',
crop: {
x: 0,
y: 0,
width: '100%',
height: '100%',
radius: 0,
},
pos: {
x: 0,
y: 0,
scale: 1,
rotate: 0,
},
}
const images = is.arr(image) ? image : [{ image, options }]
images.map(({ image, options }) => {
// 将封装好的 add函数 推入队列中待执行;
// 参数经过 _handleOps 加工;
this.queue.push(() => {
Canvas.getImage(image).then(img => {
this._add(
img,
this._handleOps(img, extend(true, def, options))
)
}).catch(this.fn.error)
})
})
return this
}
private _add(img, ops: Required<TCanvas.addOptions>) {
const crop = ops.crop as {
x: number,
y: number,
width: number,
height: number,
radius: number,
}
const pos = ops.pos as {
x: number,
y: number,
scale: number,
rotate: number,
}
const width = ops.width as number
if (width === 0) throwWarn(`the width of mc-element is zero`)
const { iw, ih } = getSize(img)
// 画布canvas参数;
let cdx, cdy, cdw, cdh
// 素材canvas参数;
const { width: lsw, height: lsh, radius } = crop
// 图片需要裁剪
if (lsw !== iw || lsh !== ih || radius > 0) {
// 此时 img 已加载,且直接导出 canvas
// 因此 success 为同步代码
img = cropFn(img, crop).cvs
}
const cratio = lsw / lsh
let ldx, ldy, ldw, ldh
// 由于 canvas 的特性,旋转只是 ctx 的旋转,并不是 canvas,
// 因此如果 canvas 与 ctx 完全相等时,旋转就会出现被裁剪的问题
// 这里通过将 canvas 放大的方式来解决该问题;
// 图片宽高比 * 1.4 是一个最安全的宽度,旋转任意角度都不会被裁剪;
// 没有旋转却长宽比很高大的图,会导致放大倍数太大,因此设置最高倍数为5;
// _ratio 为 较大边 / 较小边 的比例;
const _ratio = iw > ih ? iw / ih : ih / iw
const lctxScale = _ratio * 1.4 > 5 ? 5 : _ratio * 1.4
let spaceX, spaceY
// 素材canvas的绘制;
const [lcvs, lctx] = Canvas.create(
Math.round(lsw * lctxScale),
Math.round(lsh * lctxScale)
)
// 限制canvas的大小,ios8以下为 2096, 其余平台均限制为 4096;
const limitLength = belowIOS8() && (lcvs.width > 2096 || lcvs.height > 2096) ? 2096 : 4096
const shrink = cratio > 1 ? limitLength / lcvs.width : limitLength / lcvs.height
// 从素材canvas的中心点开始绘制;
ldx = - Math.round(lsw / 2)
ldy = - Math.round(lsh / 2)
ldw = lsw
ldh = Math.round(lsw / cratio)
// 当素材缩放后超出限制时,缩放为限制值,避免绘制失败;
// 获取素材最终的宽高;
if ((lcvs.width > limitLength || lcvs.height > limitLength) && shrink) {
[lcvs.width, lcvs.height, ldx, ldy, ldw, ldh] = [lcvs.width, lcvs.height, ldx, ldy, ldw, ldh].map(v => Math.round(v * shrink))
}
lctx.translate(lcvs.width / 2, lcvs.height / 2)
lctx.rotate(pos.rotate)
lctx.drawImage(img, ldx, ldy, ldw, ldh)
cdw = Math.round(width * lctxScale)
cdh = Math.round(cdw / cratio)
spaceX = (lctxScale - 1) * width / 2
spaceY = spaceX / cratio
// 获取素材的位置;
// 配置的位置 - 缩放的影响 - 绘制成正方形的影响;
cdx = Math.round(pos.x + cdw * (1 - pos.scale) / 2 - spaceX)
cdy = Math.round(pos.y + cdh * (1 - pos.scale) / 2 - spaceY)
cdw *= pos.scale
cdh *= pos.scale
this.ctx.drawImage(lcvs, cdx, cdy, cdw, cdh)
this._next()
}
private _getRotate(r?: string | number) {
if (is.str(r)) {
return parseFloat(r) * Math.PI / 180
} else if (is.num(r)) {
return r * Math.PI / 180
} else {
return 0
}
}
// 参数加工函数;
private _handleOps(img: TGetSizeImage, ops: Required<TCanvas.addOptions>) {
const { width: cw, height: ch } = this.cvs
const { iw, ih } = getSize(img)
// 图片宽高比;
const ratio = iw / ih
// 根据参数计算后的绘制宽度;
const width = transValue(cw, iw, ops.width, 'pos')
// 裁剪参数;
const cropw = transValue(cw, iw, ops.crop.width!, 'crop')
const croph = transValue(ch, ih, ops.crop.height!, 'crop')
const crop = {
width: cropw,
height: croph,
x: transValue(iw, cropw, ops.crop.x!, 'crop'),
y: transValue(ih, croph, ops.crop.y!, 'crop'),
radius: getLength(cropw, ops.crop.radius!),
}
// 裁剪的最大宽高;
let maxLsw, maxLsh
// 最大值判定;
if (crop.x > iw) crop.x = iw
if (crop.y > ih) crop.y = ih
maxLsw = iw - crop.x
maxLsh = ih - crop.y
if (crop.width > maxLsw) crop.width = maxLsw
if (crop.height > maxLsh) crop.height = maxLsh
// 位置参数;
const { x: px, y: py, rotate: pr, scale: ps = 1 } = ops.pos
const pos = {
x: transValue(cw, width, px!, 'pos'),
y: transValue(ch, width / ratio, py!, 'pos'),
scale: ps,
rotate: this._getRotate(pr),
}
return { width, crop, pos }
}
// --------------------------------------------------------
// 绘制文字部分;
// --------------------------------------------------------
private _defaultFontFamily = 'helvetica neue,hiragino sans gb,Microsoft YaHei,arial,tahoma,sans-serif'
private _createStyle(fontSize: number, lineHeight: number) {
return {
font: `${fontSize}px ${this._defaultFontFamily}`,
lineHeight,
color: '#000',
type: 'fill',
lineWidth: 1,
wordBreak: true,
shadow: {
color: null,
blur: 0,
offsetX: 0,
offsetY: 0,
},
}
}
public text(context: string, ops: TCanvas.textOptions = {}) {
// 默认的字体大小;
const dfs = this.cvs.width / 20
this.queue.push(() => {
const option = extend(true, {
width: 300,
align: 'left',
smallStyle: this._createStyle(dfs * 0.8, dfs * 0.9),
normalStyle: this._createStyle(dfs, dfs * 1.1),
largeStyle: this._createStyle(dfs * 1.3, dfs * 1.4),
pos: {
x: 0,
y: 0,
rotate: 0,
},
}, ops) as Required<TCanvas.textOptions>
// 解析字符串模板后,调用字体绘制函数;
const parseContext = this._parse(String(context))
let max = 0, maxFont
parseContext.map(v => {
if (v.size > max) {
max = v.size
maxFont = v.type
}
})
// 当设置的宽度小于字体宽度时,强行将设置宽度设为与字体一致;
const maxFontSize = parseInt(option[`${maxFont}Style`].font)
if (maxFontSize && option.width < maxFontSize) option.width = maxFontSize
this._text(parseContext, option)
this._resetCtx()._next()
})
return this
}
// 字符串模板解析函数
// 解析 <s></s> <b></b>
private _parse(context: string) {
const arr = context.split(/<s>|<b>/)
const result: {
type: 'small' | 'normal' | 'large',
text: string,
// 用于字体的大小比较;
size: 0 | 1 | 2,
}[] = []
for (let i = 0; i < arr.length; i++) {
const value = arr[i]
if (/<\/s>|<\/b>/.test(value)) {
const splitTag = /<\/s>/.test(value) ? '</s>' : '</b>',
type = /<\/s>/.test(value) ? 'small' : 'large',
tmp = arr[i].split(splitTag)
result.push({
type,
text: tmp[0],
// 用于字体的大小比较;
size: type === 'small' ? 0 : 2,
})
tmp[1] && result.push({
type: 'normal',
text: tmp[1],
size: 1,
})
continue
}
arr[i] && result.push({
text: arr[i],
type: 'normal',
size: 1,
})
}
return result
}
private _text(
textArr: {
type: "small" | "normal" | "large";
text: string;
size: 0 | 1 | 2;
}[],
option: Required<TCanvas.textOptions>
) {
this.data.textId++
this.data.text[this.data.textId] = {}
// 处理宽度参数;
const opsWidth = option.width = transValue(this.cvs.width, 0, option.width, 'pos')
let style,
line = 1,
lineWidth = 0,
lineHeight = this._getLineHeight(textArr, option),
x = transValue(this.cvs.width, opsWidth, 0, 'pos'),
y = (transValue(this.cvs.height, 0, 0, 'pos')) + lineHeight
// data:字体数据;
// lineWidth:行宽;
this.data.text[this.data.textId][line] = {
data: [],
lineWidth: 0,
}
// 生成字体数据;
textArr.map(v => {
style = option[`${v.type}Style`]
this.ctx.font = style.font
// 先获取整个字体块的宽度
// 用于判断是否会再当前字体块产生换行
let width = this.ctx.measureText(v.text).width
// 处理 <br> 换行,先替换成 '|',便于单字绘图时进行判断;
let context: string | string[] = v.text.replace(/<br>/g, '|')
// 先进行字体块超出判断,超出宽度 或 包含换行符 时采用单字绘制;
if ((lineWidth + width) > opsWidth || include(context, '|')) {
// 重新分词
if (!style.wordBreak) context = splitWords(context)
for (let i = 0, fontLength = context.length; i < fontLength; i++) {
const _context = context[i]
width = this.ctx.measureText(_context).width
// 当字体的计算宽度 > 设置的宽度 || 内容中包含换行时,进入换行逻辑;
if ((lineWidth + width) > opsWidth || _context === '|') {
x = lineWidth = 0
y += lineHeight
line += 1
this.data.text[this.data.textId][line] = {
data: [],
lineWidth: 0,
}
// 不绘制换行符
if (_context === '|') continue
}
// 生成绘制数据
const lineData = this.data.text[this.data.textId][line]
lineData.data.push({ context: _context, x, y, style, width })
x += width
lineData.lineWidth = lineWidth += width
}
} else {
// 当前字体块不会换行,则整块绘制;
const lineData = this.data.text[this.data.textId][line]
lineData.data.push({ context, x, y, style, width })
x += width
lineData.lineWidth = lineWidth += width
}
})
// 创建文字画布;
const [tcvs, tctx] = Canvas.create(opsWidth, this._getTextRectHeight(line))
const tdh = tcvs.height
const tdw = tcvs.width
const tdx = transValue(this.cvs.width, tdw, option.pos.x!, 'pos')
const tdy = transValue(this.cvs.height, tdh, option.pos.y!, 'pos')
// 通过字体数据进行文字的绘制;
forin(this.data.text[this.data.textId], (k, v) => {
// 增加 align 的功能;
let add = 0
if (v.lineWidth < opsWidth) {
if (option.align === 'center') {
add = (opsWidth - v.lineWidth) / 2
}else if (option.align === 'right') {
add = opsWidth - v.lineWidth
}
}
v.data.map(text => {
text.x += add
this._fillText(tctx, text)
})
})
// tcvs.style.width = '300px'
// document.body.appendChild(tcvs)
// 绘制文字画布;
this.ctx.translate(tdx + tdw / 2, tdy + tdh / 2)
this.ctx.rotate(this._getRotate(option.pos.rotate))
this.ctx.drawImage(tcvs, -tdw / 2, -tdh / 2, tdw, tdh)
}
private _getLineHeight(textArr, option) {
let lh = 0, vlh
textArr.map(v => {
vlh = option[`${v.type}Style`].lineHeight
if (vlh > lh) lh = vlh
})
return lh
}
private _fillText(ctx, text) {
const { context, x, y, style } = text
const { align, lineWidth, shadow, font, gradient, lineHeight } = style
const { color, blur, offsetX, offsetY } = shadow
ctx.font = font
ctx.textAlign = align
ctx.textBaseline = 'alphabetic'
ctx.lineWidth = lineWidth
ctx.shadowColor = color
ctx.shadowBlur = blur
ctx.shadowOffsetX = offsetX
ctx.shadowOffsetY = offsetY
if (gradient) {
const { type, colorStop } = gradient
let x1, y1, x2, y2
if (type === 1) {
x1 = x
y1 = y
x2 = x + text.width
y2 = y
} else {
x1 = x
y1 = y - lineHeight
x2 = x
y2 = y
}
const grad = ctx.createLinearGradient(x1, y1, x2, y2)
const colorNum = colorStop.length || 0
forin(colorStop, (i, v) => {
grad.addColorStop(1 / colorNum * (+i + 1), v)
})
ctx[`${style.type}Style`] = grad
}else {
ctx[`${style.type}Style`] = style.color
}
ctx[`${style.type}Text`](context, x, y)
this._resetCtx()
}
private _getTextRectHeight (lastLine) {
const lastLineData = this.data.text[this.data.textId][lastLine].data[0]
return lastLineData.y + lastLineData.style.lineHeight
}
// 绘制函数;
public draw(ops: TCommon.drawOptions | ((b64: string) => void) = {}) {
return new Promise((resolve, reject) => {
let config = {
type: 'jpeg',
quality: .9,
exportType: 'base64',
success(b64) { },
error(err) { },
}
if (is.fn(ops)) {
config.success = ops
}else {
config = extend(true, config, ops)
if (config.type === 'jpg') config.type = 'jpeg'
}
this.fn.error = (err) => {
config.error(err)
reject(err)
}
this.fn.success = () => {
if (config.exportType === 'canvas') {
config.success(this.cvs)
resolve(this.cvs)
} else {
setTimeout(() => {
const b64 = this.cvs.toDataURL(`image/${config.type}`, config.quality)
config.success(b64)
resolve(b64)
}, 0)
}
}
this._next()
})
}
private _next() {
if (this.queue.length > 0) {
this.ctx.save()
const next = this.queue.shift()
next && next()
this.ctx.restore()
}else {
this.fn.success()
}
}
public clear() {
this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height)
return this
}
}