poster.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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. uni.getImageInfo({
  218. src: imgSrc,
  219. success: (image) => {
  220. // 微信小程序会把相对路径转为不完整的绝对路径,要在前面加'/'
  221. // const res = await this.downloadImage(image.path)
  222. image.path = /^(http|\/\/|\/|wxfile|data:image\/(\w+);base64|file|bdfile)/.test(image.path) ? image.path : `/${image.path}`
  223. resolve(image)
  224. // console.log('获取图片成功',image)
  225. },
  226. fail: (err) => {
  227. reject();
  228. this.$emit('fail', {
  229. error: err
  230. })
  231. console.log('获取图片失败', err, imgSrc)
  232. }
  233. });
  234. })
  235. },
  236. downloadImage(url) {
  237. return new Promise((resolve, reject) => {
  238. const downloadTask = uni.downloadFile({
  239. url,
  240. success: (res) => {
  241. if(res.statusCode !== 200) {
  242. console.error(`downloadFile ${url} failed res.statusCode is not 200`)
  243. reject();
  244. return;
  245. } else {
  246. resolve(res.tempFilePath)
  247. }
  248. },
  249. fail: (error) => {
  250. uni.showToast({
  251. title: error
  252. })
  253. console.error(`downloadFile ${url} failed ${JSON.stringify(error)}`);
  254. resolve(url);
  255. }
  256. })
  257. })
  258. },
  259. measureText(context, text, fontSize) {
  260. // #ifndef APP-PLUS
  261. return context.measureText(text).width
  262. // #endif
  263. // #ifdef APP-PLUS
  264. // app measureText为0需要累加计算
  265. return text.split("").reduce((widthScaleSum, char) => {
  266. let code = char.charCodeAt(0);
  267. let widthScale = CHAR_WIDTH_SCALE_MAP[code - 0x20] || 1;
  268. return widthScaleSum + widthScale;
  269. }, 0) * fontSize;
  270. // #endif
  271. },
  272. calcTextArrs(context, view) {
  273. // 拆分行
  274. const textArray = view.text.split('\n')
  275. // 设置属性
  276. // #ifndef MP-TOUTIAO
  277. const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal'
  278. const textStyle = view.css.textStyle === 'italic' ? 'italic' : 'normal'
  279. // #endif
  280. // #ifdef MP-TOUTIAO
  281. const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : ''
  282. const textStyle = view.css.textStyle === 'italic' ? 'italic' : ''
  283. // #endif
  284. const fontSize = view.css.fontSize ? this.toNumber(view.css.fontSize) : '20rpx'.toPx()
  285. const fontFamily = view.css.fontFamily || 'sans-serif'
  286. context.font = `${textStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
  287. let width = 0
  288. let height = 0
  289. let lines = 0
  290. const linesArray = []
  291. for (let index = 0; index < textArray.length; index++) {
  292. const text = textArray[index]
  293. const textLength = this.measureText(context, text, fontSize) // context.measureText(text).width
  294. const minWidth = fontSize
  295. let partWidth = view.css.width ? this.toNumber(view.css.width) : textLength
  296. if(partWidth < minWidth) {
  297. partWidth = minWidth
  298. }
  299. const calLines = Math.ceil(textLength / partWidth)
  300. width = partWidth > width ? partWidth : width;
  301. lines += calLines;
  302. linesArray[index] = calLines;
  303. }
  304. // 计算行数
  305. lines = view.css.maxLines < lines ? view.css.maxLines : lines
  306. // 计算行高
  307. const lineHeight = view.css.lineHeight ? (typeof view.css.lineHeight === 'number' ? this.toNumber(view.css.lineHeight) * fontSize : this.toNumber(view.css.lineHeight)) : fontSize * 1.2
  308. height = lineHeight * lines
  309. return {
  310. fontSize,
  311. width: width,
  312. height: height,
  313. lines: lines,
  314. lineHeight: lineHeight,
  315. textArray: textArray,
  316. linesArray: linesArray,
  317. }
  318. },
  319. drawText(context, view) {
  320. return new Promise( async (resolve, reject) => {
  321. const {width, height, lines, lineHeight, textArray, linesArray, fontSize} = this.calcTextArrs(context, view)
  322. context.fillStyle = (view.css?.color || 'black')
  323. // context.setTextBaseline('top')
  324. let lineIndex = 0
  325. for (let i = 0; i < textArray.length; i++) {
  326. const preLineLength = Math.ceil(textArray[i].length / linesArray[i])
  327. let start = 0
  328. let alreadyCount = 0
  329. for (let j = 0; j < linesArray[i]; j++) {
  330. context.save()
  331. // 绘制行数大于最大行数,则直接跳出循环
  332. if (lineIndex >= lines) {
  333. break;
  334. }
  335. alreadyCount = preLineLength
  336. let text = textArray[i].substr(start, alreadyCount)
  337. let measuredWith = this.measureText(context, text, fontSize)
  338. // 如果测量大小小于width一个字符的大小,则进行补齐,如果测量大小超出 width,则进行减除
  339. // 如果已经到文本末尾,也不要进行该循环
  340. while ((start + alreadyCount <= textArray[i].length) && (width - measuredWith > fontSize || measuredWith - width > fontSize)) {
  341. if (measuredWith < width) {
  342. text = textArray[i].substr(start, ++alreadyCount);
  343. } else {
  344. if (text.length <= 1) {
  345. // 如果只有一个字符时,直接跳出循环
  346. break;
  347. }
  348. text = textArray[i].substr(start, --alreadyCount);
  349. // break;
  350. }
  351. measuredWith = this.measureText(context, text, fontSize)
  352. }
  353. start += text.length
  354. // 如果是最后一行了,发现还有未绘制完的内容,则加...
  355. if (lineIndex === lines - 1 && (i < textArray.length - 1 || start < textArray[i].length)) {
  356. while (this.measureText(context, `${text}...`, fontSize) > width) {
  357. if (text.length <= 1) {
  358. // 如果只有一个字符时,直接跳出循环
  359. break;
  360. }
  361. text = text.substring(0, text.length - 1);
  362. }
  363. text += '...';
  364. measuredWith = this.measureText(context, text, fontSize)
  365. }
  366. context.setTextAlign(view.css.textAlign ? view.css.textAlign : 'left');
  367. let x = this.toNumber(view.css.left);
  368. let lineX;
  369. switch (view.css.textAlign) {
  370. case 'center':
  371. x = x + measuredWith / 2 + ((this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth)) - measuredWith) / 2;
  372. lineX = x - measuredWith / 2;
  373. break;
  374. case 'right':
  375. x = x + (this.toNumber(view.css.width) || this.toNumber(this.boardWidth, 0 , this.windowWidth));
  376. lineX = x - measuredWith;
  377. break;
  378. default:
  379. lineX = x;
  380. break;
  381. }
  382. // top 等于字体高度加行高
  383. const y = this.toNumber(view.css.top) + (lineIndex === 0 ? fontSize : (fontSize + lineIndex * lineHeight))
  384. //const y = (view.css?.top?.toPx() || 0) + (this.toNumber(view.css.fontSize) + lineIndex * lineHeight) - this.toNumber(view.css.fontSize)
  385. lineIndex++;
  386. if (view.css.textStyle === 'stroke') {
  387. context.strokeText(text, x, y, measuredWith)
  388. } else {
  389. context.fillText(text, x, y, measuredWith * this.dpr)
  390. }
  391. if (view.css.textDecoration) {
  392. context.lineWidth = fontSize / 13;
  393. context.beginPath();
  394. if (/\bunderline\b/.test(view.css.textDecoration)) {
  395. context.moveTo(lineX, y);
  396. context.lineTo(lineX + measuredWith, y);
  397. }
  398. if (/\boverline\b/.test(view.css.textDecoration)) {
  399. context.moveTo(lineX, y - fontSize);
  400. context.lineTo(lineX + measuredWith, y - fontSize);
  401. }
  402. if (/\bline-through\b/.test(view.css.textDecoration)) {
  403. context.moveTo(lineX, y - fontSize / 2.5);
  404. context.lineTo(lineX + measuredWith, y - fontSize / 2.5);
  405. }
  406. context.closePath();
  407. context.strokeStyle = view.css.color;
  408. context.stroke();
  409. }
  410. context.restore()
  411. }
  412. }
  413. setTimeout(() => resolve('ok'), 100)
  414. })
  415. },
  416. drawRect(context, view) {
  417. return new Promise((resolve, reject) => {
  418. let left = view.css?.left?.toPx() || 0
  419. let top = view.css?.top?.toPx() || 0
  420. const width = view.css?.width.toPx() || 0
  421. const height = view.css?.height.toPx() || 0
  422. // 圆角
  423. let [topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius] = view.css?.radius?.split(' ').map((item) => /^\d/.test(item) && item.toPx(0, width), []) || [0]
  424. let radius = topLeftRadius
  425. topRightRadius = topRightRadius || topLeftRadius
  426. bottomRightRadius = bottomRightRadius || topLeftRadius
  427. bottomLeftRadius = bottomLeftRadius || topRightRadius
  428. // 字节不支持 transparent
  429. const color = view.css?.backgroundColor || view.css?.background || 'white' //'transparent'
  430. const border = view.css?.border?.split(' ').map(item => /^\d/.test(item) ? item.toPx() : item)
  431. const shadow = view.css?.shadow
  432. const angle = view.css?.rotate
  433. context.save()
  434. context.setFillStyle(color)
  435. // 旋转
  436. if(angle) {
  437. context.translate(left + width / 2, top + height / 2)
  438. context.rotate(angle * Math.PI / 180)
  439. context.translate(- left - width / 2 , - top - height / 2)
  440. }
  441. // 投影
  442. if(shadow) {
  443. const [x, y, b, c] = shadow.split(' ')
  444. context.shadowOffsetX = x.toPx()
  445. context.shadowOffsetY = y.toPx()
  446. context.shadowBlur = b.toPx()
  447. context.shadowColor = c
  448. }
  449. // 圆角
  450. if(radius) {
  451. context.beginPath()
  452. // 右下角
  453. context.arc(left + width - (bottomRightRadius || radius), top + height - (bottomRightRadius || radius), (bottomRightRadius || radius), 0, Math.PI * 0.5)
  454. context.lineTo(left + (bottomLeftRadius || radius), top + height)
  455. // 左下角
  456. context.arc(left + (bottomLeftRadius || radius), top + height - (bottomLeftRadius || radius), (bottomLeftRadius || radius), Math.PI * 0.5, Math.PI)
  457. context.lineTo(left, top + radius)
  458. // 左上角
  459. context.arc(left + radius, top + radius, radius, Math.PI, Math.PI * 1.5)
  460. context.lineTo(left + width - (topRightRadius || radius), top)
  461. // 右上角
  462. context.arc(left + width - (topRightRadius || radius), top + (topRightRadius || radius), (topRightRadius || radius), Math.PI * 1.5, Math.PI * 2)
  463. context.closePath()
  464. context.fill()
  465. } else {
  466. context.fillRect(left, top, width, height)
  467. }
  468. // 填充图片
  469. if(view?.type == 'image') {
  470. // 字节不支持 transparent
  471. context.fillStyle = 'white'
  472. radius && context.clip()
  473. // 获得缩放到图片大小级别的裁减框
  474. let rWidth = view.width
  475. let rHeight = view.height
  476. let startX = 0
  477. let startY = 0
  478. // 绘画区域比例
  479. const cp = width / height
  480. // 原图比例
  481. const op = rWidth / rHeight
  482. if (cp >= op) {
  483. rHeight = rWidth / cp;
  484. // startY = Math.round((view.height - rHeight) / 2)
  485. } else {
  486. rWidth = rHeight * cp;
  487. startX = Math.round((view.width - rWidth) / 2)
  488. }
  489. if (view.css && view.mode === 'scaleToFill') {
  490. context.drawImage(view.url, left, top, width, height);
  491. } else {
  492. context.drawImage(view.url, startX, startY, rWidth, rHeight, left, top, width, height)
  493. }
  494. }
  495. // 描边
  496. if(border) {
  497. const lineWidth = border[0]
  498. context.lineWidth = lineWidth
  499. if(border[1] == 'dashed') {
  500. context.setLineDash([Math.ceil(lineWidth * 4 / 3), Math.ceil(lineWidth * 4 / 3)])
  501. } else if(border[1] == 'dotted') {
  502. context.setLineDash([lineWidth, lineWidth])
  503. }
  504. // 字节不支持strokeStyle
  505. context.setStrokeStyle(border[2])
  506. // context.strokeStyle = border[2]
  507. if(radius) {
  508. context.stroke()
  509. } else {
  510. context.strokeRect(left, top, width, height)
  511. }
  512. }
  513. context.restore()
  514. setTimeout(() => resolve('ok'), 50)
  515. })
  516. },
  517. }
  518. }
  519. </script>
  520. <style></style>