index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
  1. <template>
  2. <view><canvas :canvas-id="id" :style="'width:' + boardWidth + '; height:' + boardHeight + ';' + customStyle"></canvas></view>
  3. </template>
  4. <script>
  5. /** 从 0x20 开始到 0x80 的字符宽度数据 */
  6. 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];
  7. const setStringPrototype = (screen) => {
  8. /* eslint-disable no-extend-native */
  9. /**
  10. * 是否支持负数
  11. * @param {Boolean} minus 是否支持负数
  12. * @param {Number} baseSize 当设置了 % 号时,设置的基准值
  13. */
  14. String.prototype.toPx = function (minus, baseSize) {
  15. const reg = minus ? (/^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px|%)$/g) : (/^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px|%)$/g)
  16. const results = reg.exec(this);
  17. if (!this || !results) {
  18. return 0;
  19. }
  20. const unit = results[2];
  21. const value = parseFloat(this);
  22. let res = 0;
  23. if (unit === 'rpx') {
  24. res = Math.round(value * (screen || 0.5) * 1);
  25. } else if (unit === 'px') {
  26. res = Math.round(value * 1);
  27. } else if (unit === '%') {
  28. res = Math.round(value * baseSize / 100);
  29. }
  30. return res;
  31. }
  32. }
  33. export default {
  34. props:{
  35. board: {
  36. type: Object,
  37. },
  38. isAsync: {
  39. type: Boolean,
  40. default: true
  41. },
  42. pixelRatio: Number,
  43. customStyle: String,
  44. isRenderImage: Boolean
  45. },
  46. data() {
  47. return {
  48. timer: null,
  49. // #ifdef H5 || APP-PLUS || MP-TOUTIAO
  50. id: `painter_${Math.random()}`
  51. // #endif
  52. // #ifndef H5 || APP-PLUS || MP-TOUTIAO
  53. id: `painter`
  54. // #endif
  55. }
  56. },
  57. watch:{
  58. board: {
  59. handler: 'drawAll',
  60. // immediate: true
  61. // deep: true
  62. }
  63. },
  64. computed:{
  65. dpr() {
  66. return this.pixelRatio || uni.getSystemInfoSync().pixelRatio
  67. },
  68. windowWidth() {
  69. return uni.getSystemInfoSync().windowWidth
  70. },
  71. boardWidth() {
  72. const {width = 200} = this.board || {}
  73. return width
  74. },
  75. boardHeight() {
  76. const {height = 200} = this.board || {}
  77. return height
  78. }
  79. },
  80. created() {
  81. this.init()
  82. },
  83. mounted() {
  84. if(this.context) {
  85. this.drawAll()
  86. }
  87. },
  88. methods: {
  89. async initBoard() {
  90. const { board } = this
  91. if(board?.views?.length) {
  92. let result = await Promise.all(board.views.map(async (item) => {
  93. if(item.type === 'image') {
  94. const {height, width, path} = await this.getImageInfo(item.url)
  95. return Object.assign({}, item, {height, width, url: path})
  96. }
  97. return item
  98. }))
  99. return result || []
  100. }
  101. return []
  102. },
  103. init() {
  104. this.context = uni.createCanvasContext(this.id, this)
  105. setStringPrototype(this.windowWidth / 750)
  106. },
  107. draw(view) {
  108. this.context.setFillStyle(view.background || 'white')
  109. this.context.fillRect(view.css.left.toPx(), view.css.top.toPx(), view.css.width.toPx(), view.css.height.toPx())
  110. this.context.clip()
  111. this.drawView(this.context, view)
  112. this.context.draw(true, () => {
  113. if(this.isRenderImage) {
  114. setTimeout(() => {
  115. this.saveImgToLocal();
  116. }, 100)
  117. }
  118. })
  119. },
  120. async drawAll() {
  121. let views = this.isAsync ? await this.initBoard() : this.board.views
  122. if(!this.context || !views.length) {return}
  123. 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}})
  124. const promises = views.map(item => this.drawView(this.context, item)) || [Promise.resolve()]
  125. Promise.all([board].concat(promises)).then((res) => {
  126. this.context.draw(true, () => {
  127. // 防止字节大量生成
  128. if(this.isRenderImage) {
  129. clearTimeout(this.timer)
  130. this.timer = setTimeout(() => {
  131. this.saveImgToLocal();
  132. }, 100)
  133. }
  134. })
  135. })
  136. },
  137. saveImgToLocal() {
  138. uni.canvasToTempFilePath({
  139. // x: 0,
  140. // y: 0,
  141. // width: this.boardWidth.toPx(),
  142. // height: this.boardWidth.toPx(),
  143. canvasId: this.id,
  144. destWidth: this.toNumber(this.boardWidth) * this.dpr,
  145. destHeight: this.toNumber(this.boardHeight) * this.dpr,
  146. success: async (res) => {
  147. const photo = await this.getImageInfo(res.tempFilePath)
  148. if(photo.path) {
  149. this.$emit('success', photo.path)
  150. }
  151. },
  152. fail: (error) => {
  153. console.error(`canvasToTempFilePath failed, ${JSON.stringify(error)}`);
  154. this.$emit('fail', {
  155. error: error
  156. })
  157. }
  158. }, this)
  159. },
  160. async drawView(context, view) {
  161. if(view.type == 'view') {
  162. return this.drawRect(context, view)
  163. } else if(view.type == 'image') {
  164. if(this.isAsync) {
  165. return this.drawRect(context, view)
  166. } else {
  167. const {height = 0, width = 0, path: url} = await this.getImageInfo(view.url)
  168. return this.drawRect(context, Object.assign(view, {height, width, url}))
  169. }
  170. } else if(view.type == 'text'){
  171. return this.drawText(context, view)
  172. }
  173. },
  174. toNumber(value, minus = 0, baseSize = 0) {
  175. if(typeof value === 'string') {
  176. return value.toPx(minus, baseSize)
  177. } else if(typeof value === 'number') {
  178. return value
  179. } else {
  180. return 0
  181. }
  182. },
  183. base64src(base64data) {
  184. return new Promise((resolve, reject) => {
  185. const fs = uni.getFileSystemManager()
  186. //自定义文件名
  187. const [, format, bodyData] = /data:image\/(\w+);base64,(.*)/.exec(base64data) || [];
  188. if (!format) {reject(new Error('ERROR_BASE64SRC_PARSE'))}
  189. const time = new Date().getTime();
  190. const filePath = `${wx.env.USER_DATA_PATH}/${time}.${format}`
  191. const buffer = uni.base64ToArrayBuffer(bodyData)
  192. fs.writeFile({
  193. filePath,
  194. data: buffer,
  195. encoding: 'binary',
  196. success() {
  197. resolve(filePath)
  198. },
  199. fail(err) {
  200. reject()
  201. this.$emit('fail', {
  202. error: err
  203. })
  204. console.log('获取base64图片失败', err)
  205. }
  206. })
  207. })
  208. },
  209. //获取图片
  210. getImageInfo(imgSrc){
  211. return new Promise(async (resolve, reject) => {
  212. // #ifndef H5 || APP-PLUS
  213. if(/^data:image\/(\w+);base64/.test(imgSrc)) {
  214. imgSrc = await this.base64src(imgSrc)
  215. }
  216. // #endif
  217. console.log(imgSrc,'imgSrc');
  218. uni.getImageInfo({
  219. src: imgSrc,
  220. success: (image) => {
  221. // 微信小程序会把相对路径转为不完整的绝对路径,要在前面加'/'
  222. // const res = await this.downloadImage(image.path)
  223. image.path = /^(http|\/\/|\/|wxfile|data:image\/(\w+);base64|file|bdfile)/.test(image.path) ? image.path : `/${image.path}`
  224. resolve(image)
  225. console.log('获取图片成功',image)
  226. },
  227. fail: (err) => {
  228. reject();
  229. this.$emit('fail', {
  230. error: err
  231. })
  232. console.log('获取图片失败', imgSrc)
  233. }
  234. });
  235. })
  236. },
  237. downloadImage(url) {
  238. return new Promise((resolve, reject) => {
  239. const downloadTask = uni.downloadFile({
  240. url,
  241. success: (res) => {
  242. if(res.statusCode !== 200) {
  243. console.error(`downloadFile ${url} failed res.statusCode is not 200`)
  244. reject();
  245. return;
  246. } else {
  247. resolve(res.tempFilePath)
  248. }
  249. },
  250. fail: (error) => {
  251. uni.showToast({
  252. title: error
  253. })
  254. console.error(`downloadFile ${url} failed ${JSON.stringify(error)}`);
  255. resolve(url);
  256. }
  257. })
  258. })
  259. },
  260. measureText(context, text, fontSize) {
  261. // #ifndef APP-PLUS
  262. return context.measureText(text).width
  263. // #endif
  264. // #ifdef APP-PLUS
  265. // app measureText为0需要累加计算
  266. return text.split("").reduce((widthScaleSum, char) => {
  267. let code = char.charCodeAt(0);
  268. let widthScale = CHAR_WIDTH_SCALE_MAP[code - 0x20] || 1;
  269. return widthScaleSum + widthScale;
  270. }, 0) * fontSize;
  271. // #endif
  272. },
  273. calcTextArrs(context, view) {
  274. // 拆分行
  275. const textArray = view.text.split('\n')
  276. // 设置属性
  277. // #ifndef MP-TOUTIAO
  278. const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal'
  279. const textStyle = view.css.textStyle === 'italic' ? 'italic' : 'normal'
  280. // #endif
  281. // #ifdef MP-TOUTIAO
  282. const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : ''
  283. const textStyle = view.css.textStyle === 'italic' ? 'italic' : ''
  284. // #endif
  285. const fontSize = view.css.fontSize ? this.toNumber(view.css.fontSize) : '20rpx'.toPx()
  286. const fontFamily = view.css.fontFamily || 'sans-serif'
  287. context.font = `${textStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
  288. let width = 0
  289. let height = 0
  290. let lines = 0
  291. const linesArray = []
  292. for (let index = 0; index < textArray.length; index++) {
  293. const text = textArray[index]
  294. const textLength = this.measureText(context, text, fontSize) // context.measureText(text).width
  295. const minWidth = fontSize
  296. let partWidth = view.css.width ? this.toNumber(view.css.width) : textLength
  297. if(partWidth < minWidth) {
  298. partWidth = minWidth
  299. }
  300. const calLines = Math.ceil(textLength / partWidth)
  301. width = partWidth > width ? partWidth : width;
  302. lines += calLines;
  303. linesArray[index] = calLines;
  304. }
  305. // 计算行数
  306. lines = view.css.maxLines < lines ? view.css.maxLines : lines
  307. // 计算行高
  308. const lineHeight = view.css.lineHeight ? (typeof view.css.lineHeight === 'number' ? this.toNumber(view.css.lineHeight) * fontSize : this.toNumber(view.css.lineHeight)) : fontSize * 1.2
  309. height = lineHeight * lines
  310. return {
  311. fontSize,
  312. width: width,
  313. height: height,
  314. lines: lines,
  315. lineHeight: lineHeight,
  316. textArray: textArray,
  317. linesArray: linesArray,
  318. }
  319. },
  320. drawText(context, view) {
  321. return new Promise( async (resolve, reject) => {
  322. const {width, height, lines, lineHeight, textArray, linesArray, fontSize} = this.calcTextArrs(context, view)
  323. context.fillStyle = (view.css?.color || 'black')
  324. // context.setTextBaseline('top')
  325. let lineIndex = 0
  326. for (let i = 0; i < textArray.length; i++) {
  327. const preLineLength = Math.ceil(textArray[i].length / linesArray[i])
  328. let start = 0
  329. let alreadyCount = 0
  330. for (let j = 0; j < linesArray[i]; j++) {
  331. context.save()
  332. // 绘制行数大于最大行数,则直接跳出循环
  333. if (lineIndex >= lines) {
  334. break;
  335. }
  336. alreadyCount = preLineLength
  337. let text = textArray[i].substr(start, alreadyCount)
  338. let measuredWith = this.measureText(context, text, fontSize)
  339. // 如果测量大小小于width一个字符的大小,则进行补齐,如果测量大小超出 width,则进行减除
  340. // 如果已经到文本末尾,也不要进行该循环
  341. while ((start + alreadyCount <= textArray[i].length) && (width - measuredWith > fontSize || measuredWith - width > fontSize)) {
  342. if (measuredWith < width) {
  343. text = textArray[i].substr(start, ++alreadyCount);
  344. } else {
  345. if (text.length <= 1) {
  346. // 如果只有一个字符时,直接跳出循环
  347. break;
  348. }
  349. text = textArray[i].substr(start, --alreadyCount);
  350. // break;
  351. }
  352. measuredWith = this.measureText(context, text, fontSize)
  353. }
  354. start += text.length
  355. // 如果是最后一行了,发现还有未绘制完的内容,则加...
  356. if (lineIndex === lines - 1 && (i < textArray.length - 1 || start < textArray[i].length)) {
  357. while (this.measureText(context, `${text}...`, fontSize) > width) {
  358. if (text.length <= 1) {
  359. // 如果只有一个字符时,直接跳出循环
  360. break;
  361. }
  362. text = text.substring(0, text.length - 1);
  363. }
  364. text += '...';
  365. measuredWith = this.measureText(context, text, fontSize)
  366. }
  367. context.setTextAlign(view.css.textAlign ? view.css.textAlign : 'left');
  368. let x = this.toNumber(view.css.left);
  369. let lineX;
  370. switch (view.css.textAlign) {
  371. case 'center':
  372. x = x + measuredWith / 2 + ((this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth)) - measuredWith) / 2;
  373. lineX = x - measuredWith / 2;
  374. break;
  375. case 'right':
  376. x = x + (this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth));
  377. lineX = x - measuredWith;
  378. break;
  379. default:
  380. lineX = x;
  381. break;
  382. }
  383. // top 等于字体高度加行高
  384. const y = this.toNumber(view.css.top) + (lineIndex === 0 ? fontSize : (fontSize + lineIndex * lineHeight))
  385. //const y = (view.css?.top?.toPx() || 0) + (this.toNumber(view.css.fontSize) + lineIndex * lineHeight) - this.toNumber(view.css.fontSize)
  386. lineIndex++;
  387. if (view.css.textStyle === 'stroke') {
  388. context.strokeText(text, x, y, measuredWith)
  389. } else {
  390. context.fillText(text, x, y, measuredWith * this.dpr)
  391. }
  392. if (view.css.textDecoration) {
  393. context.lineWidth = fontSize / 13;
  394. context.beginPath();
  395. if (/\bunderline\b/.test(view.css.textDecoration)) {
  396. context.moveTo(lineX, y);
  397. context.lineTo(lineX + measuredWith, y);
  398. }
  399. if (/\boverline\b/.test(view.css.textDecoration)) {
  400. context.moveTo(lineX, y - fontSize);
  401. context.lineTo(lineX + measuredWith, y - fontSize);
  402. }
  403. if (/\bline-through\b/.test(view.css.textDecoration)) {
  404. context.moveTo(lineX, y - fontSize / 2.5);
  405. context.lineTo(lineX + measuredWith, y - fontSize / 2.5);
  406. }
  407. context.closePath();
  408. context.strokeStyle = view.css.color;
  409. context.stroke();
  410. }
  411. context.restore()
  412. }
  413. }
  414. setTimeout(() => resolve('ok'), 100)
  415. })
  416. },
  417. drawRect(context, view) {
  418. return new Promise((resolve, reject) => {
  419. let left = view.css?.left?.toPx() || 0
  420. let top = view.css?.top?.toPx() || 0
  421. const width = view.css?.width.toPx() || 0
  422. const height = view.css?.height.toPx() || 0
  423. // 圆角
  424. let [topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius] = view.css?.radius?.split(' ').map((item) => /^\d/.test(item) && item.toPx(0, width), []) || [0]
  425. let radius = topLeftRadius
  426. topRightRadius = topRightRadius || topLeftRadius
  427. bottomRightRadius = bottomRightRadius || topLeftRadius
  428. bottomLeftRadius = bottomLeftRadius || topRightRadius
  429. // 字节不支持 transparent
  430. const color = view.css?.backgroundColor || view.css?.background || 'white' //'transparent'
  431. const border = view.css?.border?.split(' ').map(item => /^\d/.test(item) ? item.toPx() : item)
  432. const shadow = view.css?.shadow
  433. const angle = view.css?.rotate
  434. context.save()
  435. context.setFillStyle(color)
  436. // 旋转
  437. if(angle) {
  438. context.translate(left + width / 2, top + height / 2)
  439. context.rotate(angle * Math.PI / 180)
  440. context.translate(- left - width / 2 , - top - height / 2)
  441. }
  442. // 投影
  443. if(shadow) {
  444. const [x, y, b, c] = shadow.split(' ')
  445. context.shadowOffsetX = x.toPx()
  446. context.shadowOffsetY = y.toPx()
  447. context.shadowBlur = b.toPx()
  448. context.shadowColor = c
  449. }
  450. // 圆角
  451. if(radius) {
  452. context.beginPath()
  453. // 右下角
  454. context.arc(left + width - (bottomRightRadius || radius), top + height - (bottomRightRadius || radius), (bottomRightRadius || radius), 0, Math.PI * 0.5)
  455. context.lineTo(left + (bottomLeftRadius || radius), top + height)
  456. // 左下角
  457. context.arc(left + (bottomLeftRadius || radius), top + height - (bottomLeftRadius || radius), (bottomLeftRadius || radius), Math.PI * 0.5, Math.PI)
  458. context.lineTo(left, top + radius)
  459. // 左上角
  460. context.arc(left + radius, top + radius, radius, Math.PI, Math.PI * 1.5)
  461. context.lineTo(left + width - (topRightRadius || radius), top)
  462. // 右上角
  463. context.arc(left + width - (topRightRadius || radius), top + (topRightRadius || radius), (topRightRadius || radius), Math.PI * 1.5, Math.PI * 2)
  464. context.closePath()
  465. context.fill()
  466. } else {
  467. context.fillRect(left, top, width, height)
  468. }
  469. // 填充图片
  470. if(view?.type == 'image') {
  471. // 字节不支持 transparent
  472. context.fillStyle = 'white'
  473. radius && context.clip()
  474. // 获得缩放到图片大小级别的裁减框
  475. let rWidth = view.width
  476. let rHeight = view.height
  477. let startX = 0
  478. let startY = 0
  479. // 绘画区域比例
  480. const cp = width / height
  481. // 原图比例
  482. const op = rWidth / rHeight
  483. if (cp >= op) {
  484. rHeight = rWidth / cp;
  485. // startY = Math.round((view.height - rHeight) / 2)
  486. } else {
  487. rWidth = rHeight * cp;
  488. startX = Math.round((view.width - rWidth) / 2)
  489. }
  490. if (view.css && view.mode === 'scaleToFill') {
  491. context.drawImage(view.url, left, top, width, height);
  492. } else {
  493. context.drawImage(view.url, startX, startY, rWidth, rHeight, left, top, width, height)
  494. }
  495. }
  496. // 描边
  497. if(border) {
  498. const lineWidth = border[0]
  499. context.lineWidth = lineWidth
  500. if(border[1] == 'dashed') {
  501. context.setLineDash([Math.ceil(lineWidth * 4 / 3), Math.ceil(lineWidth * 4 / 3)])
  502. } else if(border[1] == 'dotted') {
  503. context.setLineDash([lineWidth, lineWidth])
  504. }
  505. // 字节不支持strokeStyle
  506. context.setStrokeStyle(border[2])
  507. // context.strokeStyle = border[2]
  508. if(radius) {
  509. context.stroke()
  510. } else {
  511. context.strokeRect(left, top, width, height)
  512. }
  513. }
  514. context.restore()
  515. setTimeout(() => resolve('ok'), 50)
  516. })
  517. },
  518. }
  519. }
  520. </script>
  521. <style></style>