123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535 |
- <template>
- <view><canvas :canvas-id="id" :style="'width:' + boardWidth + '; height:' + boardHeight + ';' + customStyle"></canvas></view>
- </template>
- <script>
- /** 从 0x20 开始到 0x80 的字符宽度数据 */
- const CHAR_WIDTH_SCALE_MAP = [0.296, 0.313, 0.436, 0.638, 0.586, 0.89, 0.87, 0.256, 0.334, 0.334, 0.455, 0.742, 0.241, 0.433, 0.241, 0.427, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.586, 0.241, 0.241, 0.742, 0.742, 0.742, 0.483, 1.031, 0.704, 0.627, 0.669, 0.762, 0.55, 0.531, 0.744, 0.773, 0.294, 0.396, 0.635, 0.513, 0.977, 0.813, 0.815, 0.612, 0.815, 0.653, 0.577, 0.573, 0.747, 0.676, 1.018, 0.645, 0.604, 0.62, 0.334, 0.416, 0.334, 0.742, 0.448, 0.295, 0.553, 0.639, 0.501, 0.64, 0.567, 0.347, 0.64, 0.616, 0.266, 0.267, 0.544, 0.266, 0.937, 0.616, 0.636, 0.639, 0.64, 0.382, 0.463, 0.373, 0.616, 0.525, 0.79, 0.507, 0.529, 0.492, 0.334, 0.269, 0.334, 0.742, 0.296];
- const setStringPrototype = (screen) => {
- /* eslint-disable no-extend-native */
- /**
- * 是否支持负数
- * @param {Boolean} minus 是否支持负数
- * @param {Number} baseSize 当设置了 % 号时,设置的基准值
- */
- String.prototype.toPx = function (minus, baseSize) {
- const reg = minus ? (/^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px|%)$/g) : (/^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px|%)$/g)
- const results = reg.exec(this);
- if (!this || !results) {
- return 0;
- }
- const unit = results[2];
- const value = parseFloat(this);
- let res = 0;
- if (unit === 'rpx') {
- res = Math.round(value * (screen || 0.5) * 1);
- } else if (unit === 'px') {
- res = Math.round(value * 1);
- } else if (unit === '%') {
- res = Math.round(value * baseSize / 100);
- }
- return res;
- }
- }
- export default {
- props:{
- board: {
- type: Object,
- },
- isAsync: {
- type: Boolean,
- default: true
- },
- pixelRatio: Number,
- customStyle: String,
- isRenderImage: Boolean
- },
- data() {
- return {
- timer: null,
- // #ifdef H5 || APP-PLUS || MP-TOUTIAO
- id: `painter_${Math.random()}`
- // #endif
- // #ifndef H5 || APP-PLUS || MP-TOUTIAO
- id: `painter`
- // #endif
- }
- },
- watch:{
- board: {
- handler: 'drawAll',
- // immediate: true
- // deep: true
- }
- },
- computed:{
- dpr() {
- return this.pixelRatio || uni.getSystemInfoSync().pixelRatio
- },
- windowWidth() {
- return uni.getSystemInfoSync().windowWidth
- },
- boardWidth() {
- const {width = 200} = this.board || {}
- return width
- },
- boardHeight() {
- const {height = 200} = this.board || {}
- return height
- }
- },
- created() {
- this.init()
- },
- mounted() {
- if(this.context) {
- this.drawAll()
- }
- },
- methods: {
- async initBoard() {
- const { board } = this
- if(board?.views?.length) {
- let result = await Promise.all(board.views.map(async (item) => {
- if(item.type === 'image') {
- const {height, width, path} = await this.getImageInfo(item.url)
- return Object.assign({}, item, {height, width, url: path})
- }
- return item
- }))
- return result || []
- }
- return []
- },
- init() {
- this.context = uni.createCanvasContext(this.id, this)
- setStringPrototype(this.windowWidth / 750)
- },
- draw(view) {
- this.context.setFillStyle(view.background || 'white')
- this.context.fillRect(view.css.left.toPx(), view.css.top.toPx(), view.css.width.toPx(), view.css.height.toPx())
- this.context.clip()
- this.drawView(this.context, view)
- this.context.draw(true, () => {
- if(this.isRenderImage) {
- setTimeout(() => {
- this.saveImgToLocal();
- }, 100)
- }
- })
- },
- async drawAll() {
- let views = this.isAsync ? await this.initBoard() : this.board.views
- if(!this.context || !views.length) {return}
- const board = this.drawRect(this.context, {type: 'view', css: {left: `${this.board?.left || 0}`, top: `${this.board?.top || 0}`, width: `${this.boardWidth}`, height: `${this.boardHeight}`, background: this.board?.background}})
- const promises = views.map(item => this.drawView(this.context, item)) || [Promise.resolve()]
- Promise.all([board].concat(promises)).then((res) => {
- this.context.draw(true, () => {
- // 防止字节大量生成
- if(this.isRenderImage) {
- clearTimeout(this.timer)
- this.timer = setTimeout(() => {
- this.saveImgToLocal();
- }, 100)
- }
- })
- })
- },
- saveImgToLocal() {
- uni.canvasToTempFilePath({
- // x: 0,
- // y: 0,
- // width: this.boardWidth.toPx(),
- // height: this.boardWidth.toPx(),
- canvasId: this.id,
- destWidth: this.toNumber(this.boardWidth) * this.dpr,
- destHeight: this.toNumber(this.boardHeight) * this.dpr,
- success: async (res) => {
- const photo = await this.getImageInfo(res.tempFilePath)
- if(photo.path) {
- this.$emit('success', photo.path)
- }
- },
- fail: (error) => {
- console.error(`canvasToTempFilePath failed, ${JSON.stringify(error)}`);
- this.$emit('fail', {
- error: error
- })
- }
- }, this)
- },
- async drawView(context, view) {
- if(view.type == 'view') {
- return this.drawRect(context, view)
- } else if(view.type == 'image') {
- if(this.isAsync) {
- return this.drawRect(context, view)
- } else {
- const {height = 0, width = 0, path: url} = await this.getImageInfo(view.url)
- return this.drawRect(context, Object.assign(view, {height, width, url}))
- }
- } else if(view.type == 'text'){
- return this.drawText(context, view)
- }
- },
- toNumber(value, minus = 0, baseSize = 0) {
- if(typeof value === 'string') {
- return value.toPx(minus, baseSize)
- } else if(typeof value === 'number') {
- return value
- } else {
- return 0
- }
- },
- base64src(base64data) {
- return new Promise((resolve, reject) => {
- const fs = uni.getFileSystemManager()
- //自定义文件名
- const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64data) || [];
- if (!format) {reject(new Error('ERROR_BASE64SRC_PARSE'))}
- const time = new Date().getTime();
- const filePath = `${wx.env.USER_DATA_PATH}/${time}.${format}`
- const buffer = uni.base64ToArrayBuffer(bodyData)
- fs.writeFile({
- filePath,
- data: buffer,
- encoding: 'binary',
- success() {
- resolve(filePath)
- },
- fail(err) {
- reject()
- this.$emit('fail', {
- error: err
- })
- console.log('获取base64图片失败', err)
- }
- })
- })
- },
- //获取图片
- getImageInfo(imgSrc){
- return new Promise(async (resolve, reject) => {
- // #ifndef H5 || APP-PLUS
- if(/^data:image\/(\w+);base64/.test(imgSrc)) {
- imgSrc = await this.base64src(imgSrc)
- }
- // #endif
- console.log(imgSrc,'imgSrc');
- uni.getImageInfo({
- src: imgSrc,
- success: (image) => {
- // 微信小程序会把相对路径转为不完整的绝对路径,要在前面加'/'
- // const res = await this.downloadImage(image.path)
- image.path = /^(http|\/\/|\/|wxfile|data:image\/(\w+);base64|file|bdfile)/.test(image.path) ? image.path : `/${image.path}`
- resolve(image)
- console.log('获取图片成功',image)
- },
- fail: (err) => {
- reject();
- this.$emit('fail', {
- error: err
- })
- console.log('获取图片失败', imgSrc)
- }
- });
- })
- },
- downloadImage(url) {
- return new Promise((resolve, reject) => {
- const downloadTask = uni.downloadFile({
- url,
- success: (res) => {
- if(res.statusCode !== 200) {
- console.error(`downloadFile ${url} failed res.statusCode is not 200`)
- reject();
- return;
- } else {
- resolve(res.tempFilePath)
- }
- },
- fail: (error) => {
- uni.showToast({
- title: error
- })
- console.error(`downloadFile ${url} failed ${JSON.stringify(error)}`);
- resolve(url);
- }
- })
- })
- },
- measureText(context, text, fontSize) {
- // #ifndef APP-PLUS
- return context.measureText(text).width
- // #endif
- // #ifdef APP-PLUS
- // app measureText为0需要累加计算
- return text.split("").reduce((widthScaleSum, char) => {
- let code = char.charCodeAt(0);
- let widthScale = CHAR_WIDTH_SCALE_MAP[code - 0x20] || 1;
- return widthScaleSum + widthScale;
- }, 0) * fontSize;
- // #endif
- },
- calcTextArrs(context, view) {
- // 拆分行
- const textArray = view.text.split('\n')
- // 设置属性
- // #ifndef MP-TOUTIAO
- const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal'
- const textStyle = view.css.textStyle === 'italic' ? 'italic' : 'normal'
- // #endif
- // #ifdef MP-TOUTIAO
- const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : ''
- const textStyle = view.css.textStyle === 'italic' ? 'italic' : ''
- // #endif
- const fontSize = view.css.fontSize ? this.toNumber(view.css.fontSize) : '20rpx'.toPx()
- const fontFamily = view.css.fontFamily || 'sans-serif'
- context.font = `${textStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
-
- let width = 0
- let height = 0
- let lines = 0
- const linesArray = []
- for (let index = 0; index < textArray.length; index++) {
- const text = textArray[index]
- const textLength = this.measureText(context, text, fontSize) // context.measureText(text).width
- const minWidth = fontSize
- let partWidth = view.css.width ? this.toNumber(view.css.width) : textLength
-
- if(partWidth < minWidth) {
- partWidth = minWidth
- }
- const calLines = Math.ceil(textLength / partWidth)
- width = partWidth > width ? partWidth : width;
- lines += calLines;
- linesArray[index] = calLines;
- }
- // 计算行数
- lines = view.css.maxLines < lines ? view.css.maxLines : lines
- // 计算行高
- const lineHeight = view.css.lineHeight ? (typeof view.css.lineHeight === 'number' ? this.toNumber(view.css.lineHeight) * fontSize : this.toNumber(view.css.lineHeight)) : fontSize * 1.2
- height = lineHeight * lines
-
- return {
- fontSize,
- width: width,
- height: height,
- lines: lines,
- lineHeight: lineHeight,
- textArray: textArray,
- linesArray: linesArray,
- }
-
- },
- drawText(context, view) {
- return new Promise( async (resolve, reject) => {
- const {width, height, lines, lineHeight, textArray, linesArray, fontSize} = this.calcTextArrs(context, view)
- context.fillStyle = (view.css?.color || 'black')
- // context.setTextBaseline('top')
- let lineIndex = 0
- for (let i = 0; i < textArray.length; i++) {
- const preLineLength = Math.ceil(textArray[i].length / linesArray[i])
- let start = 0
- let alreadyCount = 0
- for (let j = 0; j < linesArray[i]; j++) {
- context.save()
- // 绘制行数大于最大行数,则直接跳出循环
- if (lineIndex >= lines) {
- break;
- }
- alreadyCount = preLineLength
- let text = textArray[i].substr(start, alreadyCount)
- let measuredWith = this.measureText(context, text, fontSize)
- // 如果测量大小小于width一个字符的大小,则进行补齐,如果测量大小超出 width,则进行减除
- // 如果已经到文本末尾,也不要进行该循环
- while ((start + alreadyCount <= textArray[i].length) && (width - measuredWith > fontSize || measuredWith - width > fontSize)) {
- if (measuredWith < width) {
- text = textArray[i].substr(start, ++alreadyCount);
- } else {
- if (text.length <= 1) {
- // 如果只有一个字符时,直接跳出循环
- break;
- }
- text = textArray[i].substr(start, --alreadyCount);
- // break;
- }
- measuredWith = this.measureText(context, text, fontSize)
- }
- start += text.length
- // 如果是最后一行了,发现还有未绘制完的内容,则加...
- if (lineIndex === lines - 1 && (i < textArray.length - 1 || start < textArray[i].length)) {
- while (this.measureText(context, `${text}...`, fontSize) > width) {
- if (text.length <= 1) {
- // 如果只有一个字符时,直接跳出循环
- break;
- }
- text = text.substring(0, text.length - 1);
- }
- text += '...';
- measuredWith = this.measureText(context, text, fontSize)
- }
- context.setTextAlign(view.css.textAlign ? view.css.textAlign : 'left');
- let x = this.toNumber(view.css.left);
- let lineX;
- switch (view.css.textAlign) {
- case 'center':
- x = x + measuredWith / 2 + ((this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth)) - measuredWith) / 2;
- lineX = x - measuredWith / 2;
- break;
- case 'right':
- x = x + (this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth));
- lineX = x - measuredWith;
- break;
- default:
- lineX = x;
- break;
- }
- // top 等于字体高度加行高
- const y = this.toNumber(view.css.top) + (lineIndex === 0 ? fontSize : (fontSize + lineIndex * lineHeight))
- //const y = (view.css?.top?.toPx() || 0) + (this.toNumber(view.css.fontSize) + lineIndex * lineHeight) - this.toNumber(view.css.fontSize)
- lineIndex++;
- if (view.css.textStyle === 'stroke') {
- context.strokeText(text, x, y, measuredWith)
- } else {
- context.fillText(text, x, y, measuredWith * this.dpr)
- }
- if (view.css.textDecoration) {
- context.lineWidth = fontSize / 13;
- context.beginPath();
- if (/\bunderline\b/.test(view.css.textDecoration)) {
- context.moveTo(lineX, y);
- context.lineTo(lineX + measuredWith, y);
- }
- if (/\boverline\b/.test(view.css.textDecoration)) {
- context.moveTo(lineX, y - fontSize);
- context.lineTo(lineX + measuredWith, y - fontSize);
- }
- if (/\bline-through\b/.test(view.css.textDecoration)) {
- context.moveTo(lineX, y - fontSize / 2.5);
- context.lineTo(lineX + measuredWith, y - fontSize / 2.5);
- }
- context.closePath();
- context.strokeStyle = view.css.color;
- context.stroke();
- }
- context.restore()
- }
- }
- setTimeout(() => resolve('ok'), 100)
- })
- },
- drawRect(context, view) {
- return new Promise((resolve, reject) => {
- let left = view.css?.left?.toPx() || 0
- let top = view.css?.top?.toPx() || 0
- const width = view.css?.width.toPx() || 0
- const height = view.css?.height.toPx() || 0
-
- // 圆角
- let [topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius] = view.css?.radius?.split(' ').map((item) => /^\d/.test(item) && item.toPx(0, width), []) || [0]
- let radius = topLeftRadius
- topRightRadius = topRightRadius || topLeftRadius
- bottomRightRadius = bottomRightRadius || topLeftRadius
- bottomLeftRadius = bottomLeftRadius || topRightRadius
-
-
- // 字节不支持 transparent
- const color = view.css?.backgroundColor || view.css?.background || 'white' //'transparent'
- const border = view.css?.border?.split(' ').map(item => /^\d/.test(item) ? item.toPx() : item)
- const shadow = view.css?.shadow
- const angle = view.css?.rotate
-
- context.save()
- context.setFillStyle(color)
- // 旋转
- if(angle) {
- context.translate(left + width / 2, top + height / 2)
- context.rotate(angle * Math.PI / 180)
- context.translate(- left - width / 2 , - top - height / 2)
- }
- // 投影
- if(shadow) {
- const [x, y, b, c] = shadow.split(' ')
- context.shadowOffsetX = x.toPx()
- context.shadowOffsetY = y.toPx()
- context.shadowBlur = b.toPx()
- context.shadowColor = c
- }
- // 圆角
- if(radius) {
- context.beginPath()
- // 右下角
- context.arc(left + width - (bottomRightRadius || radius), top + height - (bottomRightRadius || radius), (bottomRightRadius || radius), 0, Math.PI * 0.5)
- context.lineTo(left + (bottomLeftRadius || radius), top + height)
- // 左下角
- context.arc(left + (bottomLeftRadius || radius), top + height - (bottomLeftRadius || radius), (bottomLeftRadius || radius), Math.PI * 0.5, Math.PI)
- context.lineTo(left, top + radius)
- // 左上角
- context.arc(left + radius, top + radius, radius, Math.PI, Math.PI * 1.5)
- context.lineTo(left + width - (topRightRadius || radius), top)
- // 右上角
- context.arc(left + width - (topRightRadius || radius), top + (topRightRadius || radius), (topRightRadius || radius), Math.PI * 1.5, Math.PI * 2)
- context.closePath()
- context.fill()
- } else {
- context.fillRect(left, top, width, height)
- }
- // 填充图片
- if(view?.type == 'image') {
- // 字节不支持 transparent
- context.fillStyle = 'white'
- radius && context.clip()
- // 获得缩放到图片大小级别的裁减框
- let rWidth = view.width
- let rHeight = view.height
- let startX = 0
- let startY = 0
- // 绘画区域比例
- const cp = width / height
- // 原图比例
- const op = rWidth / rHeight
- if (cp >= op) {
- rHeight = rWidth / cp;
- // startY = Math.round((view.height - rHeight) / 2)
- } else {
- rWidth = rHeight * cp;
- startX = Math.round((view.width - rWidth) / 2)
- }
- if (view.css && view.mode === 'scaleToFill') {
- context.drawImage(view.url, left, top, width, height);
- } else {
- context.drawImage(view.url, startX, startY, rWidth, rHeight, left, top, width, height)
- }
- }
- // 描边
- if(border) {
- const lineWidth = border[0]
- context.lineWidth = lineWidth
- if(border[1] == 'dashed') {
- context.setLineDash([Math.ceil(lineWidth * 4 / 3), Math.ceil(lineWidth * 4 / 3)])
- } else if(border[1] == 'dotted') {
- context.setLineDash([lineWidth, lineWidth])
- }
- // 字节不支持strokeStyle
- context.setStrokeStyle(border[2])
- // context.strokeStyle = border[2]
- if(radius) {
- context.stroke()
- } else {
- context.strokeRect(left, top, width, height)
- }
- }
- context.restore()
- setTimeout(() => resolve('ok'), 50)
- })
- },
- }
- }
- </script>
- <style></style>
|