Files

648 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const QR = require('./qrcode.js')
const GD = require('./gradient.js')
export default class Painter {
constructor(ctx, data) {
this.ctx = ctx
this.data = data
this.globalWidth = {}
this.globalHeight = {}
}
paint(callback) {
this.style = {
width: this.data.width.toPx(),
height: this.data.height.toPx()
}
this._background()
if (this.data.views && this.data.views.length > 0) {
for (const view of this.data.views) {
this._drawAbsolute(view)
}
}
callback && callback()
}
_background() {
this.ctx.save()
const { width, height } = this.style
const bg = this.data.background
this.ctx.translate(width / 2, height / 2)
this._doClip(this.data.borderRadius, width, height)
if (!bg) {
// 如果未设置背景,则默认使用透明色
this.ctx.fillStyle = 'transparent'
this.ctx.fillRect(-(width / 2), -(height / 2), width, height)
} else if (bg.src) {
// 背景填充图片
this.ctx.drawImage(bg, -(width / 2), -(height / 2), width, height)
}else if (bg.startsWith('#') || bg.startsWith('rgba') || bg.toLowerCase() === 'transparent') {
// 背景填充颜色
this.ctx.fillStyle = bg
this.ctx.fillRect(-(width / 2), -(height / 2), width, height)
} else if (GD.api.isGradient(bg)) {
GD.api.doGradient(bg, width, height, this.ctx)
this.ctx.fillRect(-(width / 2), -(height / 2), width, height)
}
this.ctx.restore()
}
_drawAbsolute(view) {
if (!(view && view.type)) {
// 过滤无效 view
return
}
// 证明 css 为数组形式,需要合并
if (view.css && view.css.length) {
/* eslint-disable no-param-reassign */
view.css = Object.assign(...view.css)
}
switch (view.type) {
case 'image':
this._drawAbsImage(view)
break
case 'text':
this._fillAbsText(view)
break
case 'rect':
this._drawAbsRect(view)
break
case 'qrcode':
this._drawQRCode(view)
break
default:
break
}
}
_border({ borderRadius = 0, width, height, borderWidth = 0, borderStyle = 'solid' }) {
let r1 = 0,
r2 = 0,
r3 = 0,
r4 = 0
const minSize = Math.min(width, height)
if (borderRadius) {
const border = borderRadius.split(/\s+/)
if (border.length === 4) {
r1 = Math.min(border[0].toPx(false, minSize), width / 2, height / 2)
r2 = Math.min(border[1].toPx(false, minSize), width / 2, height / 2)
r3 = Math.min(border[2].toPx(false, minSize), width / 2, height / 2)
r4 = Math.min(border[3].toPx(false, minSize), width / 2, height / 2)
} else {
r1 = r2 = r3 = r4 = Math.min(borderRadius && borderRadius.toPx(false, minSize), width / 2, height / 2)
}
}
const lineWidth = borderWidth && borderWidth.toPx(false, minSize)
this.ctx.lineWidth = lineWidth
if (borderStyle === 'dashed') {
this.ctx.setLineDash([(lineWidth * 4) / 3, (lineWidth * 4) / 3])
// this.ctx.lineDashOffset = 2 * lineWidth
} else if (borderStyle === 'dotted') {
this.ctx.setLineDash([lineWidth, lineWidth])
}
const notSolid = borderStyle !== 'solid'
this.ctx.beginPath()
notSolid && r1 === 0 && this.ctx.moveTo(-width / 2 - lineWidth, -height / 2 - lineWidth / 2) // 顶边虚线规避重叠规则
r1 !== 0 && this.ctx.arc(-width / 2 + r1, -height / 2 + r1, r1 + lineWidth / 2, 1 * Math.PI, 1.5 * Math.PI) //左上角圆弧
this.ctx.lineTo(r2 === 0 ? (notSolid ? width / 2 : width / 2 + lineWidth / 2) : width / 2 - r2, -height / 2 - lineWidth / 2) // 顶边线
notSolid && r2 === 0 && this.ctx.moveTo(width / 2 + lineWidth / 2, -height / 2 - lineWidth) // 右边虚线规避重叠规则
r2 !== 0 && this.ctx.arc(width / 2 - r2, -height / 2 + r2, r2 + lineWidth / 2, 1.5 * Math.PI, 2 * Math.PI) // 右上角圆弧
this.ctx.lineTo(width / 2 + lineWidth / 2, r3 === 0 ? (notSolid ? height / 2 : height / 2 + lineWidth / 2) : height / 2 - r3) // 右边线
notSolid && r3 === 0 && this.ctx.moveTo(width / 2 + lineWidth, height / 2 + lineWidth / 2) // 底边虚线规避重叠规则
r3 !== 0 && this.ctx.arc(width / 2 - r3, height / 2 - r3, r3 + lineWidth / 2, 0, 0.5 * Math.PI) // 右下角圆弧
this.ctx.lineTo(r4 === 0 ? (notSolid ? -width / 2 : -width / 2 - lineWidth / 2) : -width / 2 + r4, height / 2 + lineWidth / 2) // 底边线
notSolid && r4 === 0 && this.ctx.moveTo(-width / 2 - lineWidth / 2, height / 2 + lineWidth) // 左边虚线规避重叠规则
r4 !== 0 && this.ctx.arc(-width / 2 + r4, height / 2 - r4, r4 + lineWidth / 2, 0.5 * Math.PI, 1 * Math.PI) // 左下角圆弧
this.ctx.lineTo(-width / 2 - lineWidth / 2, r1 === 0 ? (notSolid ? -height / 2 : -height / 2 - lineWidth / 2) : -height / 2 + r1) // 左边线
notSolid && r1 === 0 && this.ctx.moveTo(-width / 2 - lineWidth, -height / 2 - lineWidth / 2) // 顶边虚线规避重叠规则
if (!notSolid) {
this.ctx.closePath()
}
}
/**
* 根据 borderRadius 进行裁减
*/
_doClip(borderRadius, width, height, borderStyle) {
if (borderRadius && width && height) {
// 防止在某些机型上周边有黑框现象,此处如果直接设置 fillStyle 为透明,在 Android 机型上会导致被裁减的图片也变为透明, iOS 和 IDE 上不会
// globalAlpha 在 1.9.90 起支持,低版本下无效,但把 fillStyle 设为了 white相对默认的 black 要好点
this.ctx.globalAlpha = 0
this.ctx.fillStyle = 'white'
this._border({
borderRadius,
width,
height,
borderStyle
})
this.ctx.fill()
// 在 ios 的 6.6.6 版本上 clip 有 bug禁掉此类型上的 clip也就意味着在此版本微信的 ios 设备下无法使用 border 属性
this.ctx.clip()
this.ctx.globalAlpha = 1
}
}
/**
* 画边框
*/
_doBorder(view, width, height) {
if (!view.css) {
return
}
const { borderRadius, borderWidth, borderColor, borderStyle } = view.css
if (!borderWidth) {
return
}
this.ctx.save()
this._preProcess(view, true)
this.ctx.strokeStyle = borderColor || 'black'
this._border({
borderRadius,
width,
height,
borderWidth,
borderStyle
})
this.ctx.stroke()
this.ctx.restore()
}
_preProcess(view, notClip) {
let width = 0
let height
let extra
const paddings = this._doPaddings(view)
switch (view.type) {
case 'text': {
const textArray = view.text.split('\n')
// 处理多个连续的'\n'
for (let i = 0; i < textArray.length; ++i) {
if (textArray[i] === '') {
textArray[i] = ' '
}
}
// const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal'
const fontWeightArr = ['normal', 'bold', 'bolder', 'lighter', '100', '200', '300', '400', '500', '600', '700', '800', '900']
const fontWeight = fontWeightArr.includes(view.css.fontWeight) ? view.css.fontWeight : 'normal'
const textStyle = view.css.textStyle === 'italic' ? 'italic' : 'normal'
let textIndent = view.css.textIndent ? view.css.textIndent.toPx() : 0
if (!view.css.fontSize) {
view.css.fontSize = '20rpx'
}
this.ctx.font = `${textStyle} ${fontWeight} ${view.css.fontSize.toPx()}px "${view.css.fontFamily || 'sans-serif'}"`
// 计算行数
let lines = 0
const linesArray = []
for (let i = 0; i < textArray.length; ++i) {
const textLength = this.ctx.measureText(textArray[i]).width
// 最小长度
const minWidth = view.css.fontSize.toPx() + paddings[1] + paddings[3]
let partWidth = view.css.width ? view.css.width.toPx(false, this.style.width) - paddings[1] - paddings[3] : textLength
if (partWidth < minWidth) {
partWidth = minWidth
}
// textIndent 最大为一行
textIndent = textIndent > partWidth ? partWidth : textIndent
const calLines = Math.ceil((textLength + textIndent) / partWidth)
// 取最长的作为 width
width = partWidth > width ? partWidth : width
lines += calLines
linesArray[i] = calLines
}
lines = view.css.maxLines < lines ? view.css.maxLines : lines
const lineHeight = view.css.lineHeight ? view.css.lineHeight.toPx() : view.css.fontSize.toPx()
height = lineHeight * lines
extra = {
textIndent,
lines: lines,
lineHeight: lineHeight,
textArray: textArray,
linesArray: linesArray
}
break
}
case 'image': {
// 有css却未设置width或height则默认为auto
if (view.css) {
if (!view.css.width) {
view.css.width = 'auto'
}
if (!view.css.height) {
view.css.height = 'auto'
}
}
if (!view.css || (view.css.width === 'auto' && view.css.height === 'auto')) {
width = Math.round(view.sWidth)
height = Math.round(view.sHeight)
} else if (view.css.width === 'auto') {
height = view.css.height.toPx(false, this.style.height)
width = (view.sWidth / view.sHeight) * height
} else if (view.css.height === 'auto') {
width = view.css.width.toPx(false, this.style.width)
height = (view.sHeight / view.sWidth) * width
} else {
width = view.css.width.toPx(false, this.style.width)
height = view.css.height.toPx(false, this.style.height)
}
break
}
default:
if (!(view.css.width && view.css.height)) {
console.error('You should set width and height')
return
}
width = view.css.width.toPx(false, this.style.width)
height = view.css.height.toPx(false, this.style.height)
break
}
let x
if (view.css && view.css.right) {
if (typeof view.css.right === 'string') {
x = this.style.width - view.css.right.toPx(true, this.style.width)
} else {
// 可以用数组方式,把文字长度计算进去
// [right, 文字id, 乘数(默认 1]
const rights = view.css.right
x = this.style.width - rights[0].toPx(true, this.style.width) - this.globalWidth[rights[1]] * (rights[2] || 1)
}
} else if (view.css && view.css.left) {
if (typeof view.css.left === 'string') {
x = view.css.left.toPx(true, this.style.width)
} else {
const lefts = view.css.left
x = lefts[0].toPx(true, this.style.width) + this.globalWidth[lefts[1]] * (lefts[2] || 1)
}
} else {
x = 0
}
//const y = view.css && view.css.bottom ? this.style.height - height - view.css.bottom.toPx(true) : (view.css && view.css.top ? view.css.top.toPx(true) : 0);
let y
if (view.css && view.css.bottom) {
y = this.style.height - height - view.css.bottom.toPx(true, this.style.height)
} else {
if (view.css && view.css.top) {
if (typeof view.css.top === 'string') {
y = view.css.top.toPx(true, this.style.height)
} else {
const tops = view.css.top
y = tops[0].toPx(true, this.style.height) + this.globalHeight[tops[1]] * (tops[2] || 1)
}
} else {
y = 0
}
}
const angle = view.css && view.css.rotate ? this._getAngle(view.css.rotate) : 0
// 当设置了 right 时,默认 align 用 right反之用 left
const align = view.css && view.css.align ? view.css.align : view.css && view.css.right ? 'right' : 'left'
const verticalAlign = view.css && view.css.verticalAlign ? view.css.verticalAlign : 'top'
// 记录绘制时的画布
let xa = 0
switch (align) {
case 'center':
xa = x
break
case 'right':
xa = x - width / 2
break
default:
xa = x + width / 2
break
}
let ya = 0
switch (verticalAlign) {
case 'center':
ya = y
break
case 'bottom':
ya = y - height / 2
break
default:
ya = y + height / 2
break
}
this.ctx.translate(xa, ya)
// 记录该 view 的有效点击区域
// TODO ,旋转和裁剪的判断
// 记录在真实画布上的左侧
let left = x
if (align === 'center') {
left = x - width / 2
} else if (align === 'right') {
left = x - width
}
var top = y
if (verticalAlign === 'center') {
top = y - height / 2
} else if (verticalAlign === 'bottom') {
top = y - height
}
if (view.rect) {
view.rect.left = left
view.rect.top = top
view.rect.right = left + width
view.rect.bottom = top + height
view.rect.x = view.css && view.css.right ? x - width : x
view.rect.y = y
} else {
view.rect = {
left: left,
top: top,
right: left + width,
bottom: top + height,
x: view.css && view.css.right ? x - width : x,
y: y
}
}
view.rect.left = view.rect.left - paddings[3]
view.rect.top = view.rect.top - paddings[0]
view.rect.right = view.rect.right + paddings[1]
view.rect.bottom = view.rect.bottom + paddings[2]
if (view.type === 'text') {
view.rect.minWidth = view.css.fontSize.toPx() + paddings[1] + paddings[3]
}
this.ctx.rotate(angle)
if (!notClip && view.css && view.css.borderRadius && view.type !== 'rect') {
this._doClip(view.css.borderRadius, width, height, view.css.borderStyle)
}
this._doShadow(view)
if (view.id) {
this.globalWidth[view.id] = width
this.globalHeight[view.id] = height
}
return {
width: width,
height: height,
x: x,
y: y,
extra: extra
}
}
_doPaddings(view) {
const { padding } = view.css ? view.css : {}
let pd = [0, 0, 0, 0]
if (padding) {
const pdg = padding.split(/\s+/)
if (pdg.length === 1) {
const x = pdg[0].toPx()
pd = [x, x, x, x]
}
if (pdg.length === 2) {
const x = pdg[0].toPx()
const y = pdg[1].toPx()
pd = [x, y, x, y]
}
if (pdg.length === 3) {
const x = pdg[0].toPx()
const y = pdg[1].toPx()
const z = pdg[2].toPx()
pd = [x, y, z, y]
}
if (pdg.length === 4) {
const x = pdg[0].toPx()
const y = pdg[1].toPx()
const z = pdg[2].toPx()
const a = pdg[3].toPx()
pd = [x, y, z, a]
}
}
return pd
}
// 画文字的背景图片
_doBackground(view) {
this.ctx.save()
const { width: rawWidth, height: rawHeight } = this._preProcess(view, true)
const { background } = view.css
let pd = this._doPaddings(view)
const width = rawWidth + pd[1] + pd[3]
const height = rawHeight + pd[0] + pd[2]
this._doClip(view.css.borderRadius, width, height, view.css.borderStyle)
if (GD.api.isGradient(background)) {
GD.api.doGradient(background, width, height, this.ctx)
} else {
this.ctx.fillStyle = background
}
this.ctx.fillRect(-(width / 2), -(height / 2), width, height)
this.ctx.restore()
}
_drawQRCode(view) {
this.ctx.save()
const { width, height } = this._preProcess(view)
QR.api.draw(view.content, this.ctx, -width / 2, -height / 2, width, height, view.css.background, view.css.color)
this.ctx.restore()
this._doBorder(view, width, height)
}
_drawAbsImage(view) {
if (!view.url) {
return
}
this.ctx.save()
const { width, height } = this._preProcess(view)
// 图片失败
if (typeof view.url === 'string') {
this.ctx.fillStyle = '#ddd'
this.ctx.fillRect(-(width / 2), -(height / 2), width, height)
this.ctx.restore()
return
}
// 获得缩放到图片大小级别的裁减框
let rWidth = view.sWidth
let rHeight = view.sHeight
let startX = 0
let startY = 0
// 绘画区域比例
const cp = width / height
// 原图比例
const op = view.sWidth / view.sHeight
if (cp >= op) {
rHeight = rWidth / cp
startY = Math.round((view.sHeight - rHeight) / 2)
} else {
rWidth = rHeight * cp
startX = Math.round((view.sWidth - rWidth) / 2)
}
if (view.css && view.css.mode === 'scaleToFill') {
this.ctx.drawImage(view.url, -(width / 2), -(height / 2), width, height)
} else {
this.ctx.drawImage(view.url, startX, startY, rWidth, rHeight, -(width / 2), -(height / 2), width, height)
view.rect.startX = startX / view.sWidth
view.rect.startY = startY / view.sHeight
view.rect.endX = (startX + rWidth) / view.sWidth
view.rect.endY = (startY + rHeight) / view.sHeight
}
this.ctx.restore()
this._doBorder(view, width, height)
}
_fillAbsText(view) {
if (!view.text) {
return
}
if (view.css.background) {
// 生成背景
this._doBackground(view)
}
this.ctx.save()
const { width, height, extra } = this._preProcess(view, view.css.background && view.css.borderRadius)
this.ctx.fillStyle = view.css.color || 'black'
const { lines, lineHeight, textArray, linesArray, textIndent } = extra
// 如果设置了id则保留 text 的长度
if (view.id) {
let textWidth = 0
for (let i = 0; i < textArray.length; ++i) {
const _w = this.ctx.measureText(textArray[i]).width
textWidth = _w > textWidth ? _w : textWidth
}
this.globalWidth[view.id] = width ? (textWidth < width ? textWidth : width) : textWidth
}
let lineIndex = 0
let tabWidth = 0
for (let j = 0; j < textArray.length; ++j) {
const preLineLength = Math.ceil(textArray[j].length / linesArray[j])
const firstLineLength = Math.ceil(((width - textIndent) / width) * (textArray[j].length / linesArray[j]))
let start = 0
let alreadyCount = 0
for (let i = 0; i < linesArray[j]; ++i) {
// 绘制行数大于最大行数,则直接跳出循环
if (lineIndex >= lines) {
break
}
tabWidth = i == 0 ? textIndent : 0
alreadyCount = i == 0 ? firstLineLength : preLineLength
let text = textArray[j].substr(start, alreadyCount)
let measuredWith = this.ctx.measureText(text).width
// 如果测量大小小于width一个字符的大小则进行补齐如果测量大小超出 width则进行减除
// 如果已经到文本末尾,也不要进行该循环
while (start + alreadyCount <= textArray[j].length && (width - measuredWith - tabWidth > view.css.fontSize.toPx() || measuredWith - width > view.css.fontSize.toPx())) {
if (measuredWith < width) {
text = textArray[j].substr(start, ++alreadyCount)
} else {
if (text.length <= 1) {
// 如果只有一个字符时,直接跳出循环
break
}
text = textArray[j].substr(start, --alreadyCount)
// break
}
measuredWith = this.ctx.measureText(text).width
}
start += text.length
// 如果是最后一行了,发现还有未绘制完的内容,则加...
if (lineIndex === lines - 1 && (j < textArray.length - 1 || start < textArray[j].length)) {
while (this.ctx.measureText(`${text}...`).width > width) {
if (text.length <= 1) {
// 如果只有一个字符时,直接跳出循环
break
}
text = text.substring(0, text.length - 1)
}
text += '...'
measuredWith = this.ctx.measureText(text).width
}
this.ctx.textAlign = view.css.textAlign ? view.css.textAlign : 'left'
let x
let lineX
switch (view.css.textAlign) {
case 'center':
x = 0
lineX = x - measuredWith / 2 + tabWidth
break
case 'right':
x = width / 2
lineX = x - measuredWith + tabWidth
break
default:
x = -(width / 2) + tabWidth
lineX = x
break
}
const y = -(height / 2) + (lineIndex === 0 ? view.css.fontSize.toPx() : view.css.fontSize.toPx() + lineIndex * lineHeight)
lineIndex++
if (view.css.textStyle === 'stroke') {
this.ctx.strokeText(text, x, y, measuredWith)
} else {
this.ctx.fillText(text, x, y, measuredWith)
}
const fontSize = view.css.fontSize.toPx()
if (view.css.textDecoration) {
this.ctx.lineWidth = fontSize / 13
this.ctx.beginPath()
if (/\bunderline\b/.test(view.css.textDecoration)) {
this.ctx.moveTo(lineX, y)
this.ctx.lineTo(lineX + measuredWith, y)
}
if (/\boverline\b/.test(view.css.textDecoration)) {
this.ctx.moveTo(lineX, y - fontSize)
this.ctx.lineTo(lineX + measuredWith, y - fontSize)
}
if (/\bline-through\b/.test(view.css.textDecoration)) {
this.ctx.moveTo(lineX, y - fontSize / 3)
this.ctx.lineTo(lineX + measuredWith, y - fontSize / 3)
}
this.ctx.closePath()
this.ctx.strokeStyle = view.css.color
this.ctx.stroke()
}
}
}
this.ctx.restore()
this._doBorder(view, width, height)
}
_drawAbsRect(view) {
this.ctx.save()
const { width, height } = this._preProcess(view)
if (GD.api.isGradient(view.css.color)) {
GD.api.doGradient(view.css.color, width, height, this.ctx)
} else {
this.ctx.fillStyle = view.css.color
}
const { borderRadius, borderStyle, borderWidth } = view.css
this._border({
borderRadius,
width,
height,
borderWidth,
borderStyle
})
this.ctx.fill()
this.ctx.restore()
this._doBorder(view, width, height)
}
// shadow 支持 (x, y, blur, color), 不支持 spread
// shadow:0px 0px 10px rgba(0,0,0,0.1);
_doShadow(view) {
if (!view.css || !view.css.shadow) {
return
}
const box = view.css.shadow.replace(/,\s+/g, ',').split(/\s+/)
if (box.length > 4) {
console.error("shadow don't spread option")
return
}
this.ctx.shadowOffsetX = parseInt(box[0], 10)
this.ctx.shadowOffsetY = parseInt(box[1], 10)
this.ctx.shadowBlur = parseInt(box[2], 10)
this.ctx.shadowColor = box[3]
}
_getAngle(angle) {
return (Number(angle) * Math.PI) / 180
}
}