u-poster.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. <template>
  2. <view class="u-poster">
  3. <view class="u-poster__preview" v-if="showPreview">
  4. <image
  5. :src="previewUrl"
  6. class="u-poster__preview-image"
  7. :mode="mode"
  8. :style="[
  9. {
  10. width: $u.addUnit(width),
  11. height: $u.addUnit(height),
  12. },
  13. ]"
  14. />
  15. </view>
  16. <u-canvas
  17. ref="canvasRef"
  18. :width="canvasWidth"
  19. :height="canvasHeight"
  20. :customStyle="{
  21. position: 'absolute',
  22. left: '-9999px',
  23. top: '-9999px',
  24. }"
  25. />
  26. </view>
  27. </template>
  28. <script>
  29. import Painter from './lib/painter';
  30. import { download } from './lib/downloader';
  31. import { equal, toPx } from './lib/util';
  32. import props from './props.js';
  33. import mixin from '../../libs/mixin/mixin';
  34. import mpMixin from '../../libs/mixin/mpMixin';
  35. /**
  36. * u-poster 海报生成器
  37. * @description 基于Painter(https://github.com/Kujiale-Mobile/Painter)的海报生成器,支持使用json格式配置生成各种定制化图片和二维码
  38. * @tutorial https://uview.d3u.cn/components/poster.html
  39. * @property {Boolean} showPreview 是否显示预览
  40. * @property {Number} width 预览宽度
  41. * @property {Number} height 预览高度
  42. * @property {String} mode 预览模式
  43. * @property {String} type 画布类型
  44. * @property {Object} palette palette
  45. * @property {Number} scaleRatio 缩放比
  46. * @property {Number} widthPixels 宽度像素
  47. * @property {Boolean} dirty 是否启用脏检查
  48. * @property {String} fileType 文件类型
  49. * @property {Number} quality 质量
  50. *
  51. * @event {Function} success 图片生成成功时触发
  52. * @event {Function} error 图片生成失败时触发
  53. * @example <u-poster :palette="palette" @success="success" @error="error" />
  54. */
  55. export default {
  56. name: 'u-poster',
  57. mixins: [mpMixin, mixin, props],
  58. data() {
  59. return {
  60. canvasWidth: 0,
  61. canvasHeight: 0,
  62. screenK: 1,
  63. paintCount: 0,
  64. maxPaintCount: 5,
  65. ratioTolerance: 0.01,
  66. previewUrl: '',
  67. };
  68. },
  69. watch: {
  70. palette: {
  71. immediate: true,
  72. deep: true,
  73. handler(newVal, oldVal) {
  74. if (this.isNeedRefresh(newVal, oldVal)) {
  75. this.render();
  76. }
  77. },
  78. },
  79. },
  80. // #ifdef VUE3
  81. emits: ['success', 'error'],
  82. // #endif
  83. methods: {
  84. isEmpty(object) {
  85. return !object || Object.keys(object).length === 0;
  86. },
  87. isNeedRefresh(newVal, oldVal) {
  88. if (
  89. !newVal ||
  90. this.isEmpty(newVal) ||
  91. (this.dirty && equal(newVal, oldVal))
  92. ) {
  93. return false;
  94. }
  95. return true;
  96. },
  97. //渲染
  98. async render(data) {
  99. this.paintCount = 0;
  100. const { pixelRatio } = uni.$u.window();
  101. const { width, height } = data || this.palette;
  102. if (!width || !height) {
  103. console.error(
  104. `You should set width and height correctly for painter, width: ${width}, height: ${height}`,
  105. );
  106. return;
  107. }
  108. if (
  109. toPx(width, this.screenK, this.scaleRatio) !==
  110. this.canvasWidth
  111. ) {
  112. this.canvasWidth = toPx(
  113. width,
  114. this.screenK,
  115. this.scaleRatio,
  116. );
  117. }
  118. if (this.widthPixels) {
  119. const newScreenK = this.widthPixels / this.canvasWidth;
  120. this.canvasWidth = this.widthPixels;
  121. }
  122. if (
  123. this.canvasHeight !==
  124. toPx(height, this.screenK, this.scaleRatio)
  125. ) {
  126. this.canvasHeight = toPx(
  127. height,
  128. this.screenK,
  129. this.scaleRatio,
  130. );
  131. }
  132. try {
  133. await this.$nextTick();
  134. const processedPalette = await this.downloadImages(
  135. this.palette,
  136. );
  137. const { canvas } = await this.$refs.canvasRef.getCanvasContext();
  138. const painter = new Painter(canvas, processedPalette, pixelRatio);
  139. painter.draw(() => {
  140. this.$refs.canvasRef.canvasToTempFilePath({
  141. width: this.canvasWidth,
  142. height: this.canvasHeight,
  143. fileType: this.fileType,
  144. quality: this.quality,
  145. }).then((tempFilePath) => {
  146. this.getImageInfo(tempFilePath);
  147. }).catch((error) => {
  148. this.$emit('error', error);
  149. });
  150. });
  151. } catch (error) {
  152. this.$emit('error', error);
  153. }
  154. },
  155. downloadImages(palette) {
  156. return new Promise((resolve, reject) => {
  157. const paletteCopy = JSON.parse(JSON.stringify(palette));
  158. const downloadTasks = [];
  159. let completedCount = 0;
  160. // 预处理:转换所有尺寸单位为像素值
  161. this.preprocessPalette(paletteCopy);
  162. // 处理背景图片
  163. if (paletteCopy.background) {
  164. downloadTasks.push(
  165. download(paletteCopy.background, this.lru)
  166. .then((path) => {
  167. paletteCopy.background = path;
  168. completedCount++;
  169. })
  170. .catch(() => {
  171. completedCount++;
  172. }),
  173. );
  174. }
  175. // 处理视图中的图片
  176. if (paletteCopy.views) {
  177. paletteCopy.views.forEach((view) => {
  178. if (view.type === 'image' && view.url) {
  179. downloadTasks.push(
  180. download(view.url, this.lru)
  181. .then(async (path) => {
  182. view.originUrl = view.url;
  183. view.url = path;
  184. try {
  185. const imageInfo =
  186. await this.getImageInfoAsync(
  187. path,
  188. );
  189. view.sWidth = imageInfo.width;
  190. view.sHeight = imageInfo.height;
  191. } catch (error) {
  192. console.warn(
  193. `getImageInfo ${view.originUrl} failed:`,
  194. error,
  195. );
  196. view.url = '';
  197. }
  198. completedCount++;
  199. })
  200. .catch(() => {
  201. completedCount++;
  202. }),
  203. );
  204. }
  205. });
  206. }
  207. // 如果没有下载任务,直接返回
  208. if (downloadTasks.length === 0) {
  209. resolve(paletteCopy);
  210. }
  211. // 等待所有下载任务完成
  212. Promise.allSettled(downloadTasks).then(() => {
  213. resolve(paletteCopy);
  214. });
  215. });
  216. },
  217. // 预处理palette,转换所有尺寸单位为像素值
  218. preprocessPalette(palette) {
  219. // 转换主尺寸
  220. ['width', 'height'].forEach((item) => {
  221. palette[item] = toPx(
  222. palette[item],
  223. this.screenK,
  224. this.scaleRatio,
  225. );
  226. });
  227. if (palette.views && palette.views.length > 0) {
  228. palette.views.forEach((view) => {
  229. if (view.style) {
  230. this.preprocessViewStyle(
  231. view.style,
  232. palette.width,
  233. palette.height,
  234. );
  235. }
  236. });
  237. }
  238. },
  239. // 预处理单个view的style
  240. preprocessViewStyle(style, parentWidth, parentHeight) {
  241. [
  242. 'width',
  243. 'height',
  244. 'left',
  245. 'right',
  246. 'top',
  247. 'bottom',
  248. 'fontSize',
  249. 'borderWidth',
  250. ].forEach((item) => {
  251. style[item] = toPx(
  252. style[item],
  253. this.screenK,
  254. this.scaleRatio,
  255. );
  256. });
  257. // 转换边框相关属性
  258. if (style.borderRadius) {
  259. if (Array.isArray(style.borderRadius)) {
  260. style.borderRadius = style.borderRadius.map((radius) =>
  261. toPx(
  262. radius,
  263. this.screenK,
  264. this.scaleRatio,
  265. Math.min(parentWidth, parentHeight),
  266. ),
  267. );
  268. } else {
  269. style.borderRadius = toPx(
  270. style.borderRadius,
  271. this.screenK,
  272. this.scaleRatio,
  273. );
  274. }
  275. }
  276. ['borderRadius', 'padding'].forEach((item) => {
  277. if (style[item]) {
  278. let list = style[item].toString().split(/\s+/);
  279. style[item] = list
  280. .map((it) =>
  281. toPx(it, this.screenK, this.scaleRatio),
  282. )
  283. .join(' ');
  284. }
  285. });
  286. if (style.lineHeight) {
  287. style.lineHeight = parseFloat(style.lineHeight);
  288. }
  289. },
  290. getImageInfoAsync(src) {
  291. return new Promise((resolve, reject) => {
  292. uni.getImageInfo({
  293. src,
  294. success: resolve,
  295. fail: reject,
  296. });
  297. });
  298. },
  299. getImageInfo(filePath) {
  300. uni.getImageInfo({
  301. src: filePath,
  302. success: (infoRes) => {
  303. if (this.paintCount > this.maxPaintCount) {
  304. this.$emit('error', error);
  305. return;
  306. }
  307. // 检查比例是否相符
  308. const ratioDiff = Math.abs(
  309. (infoRes.width * this.canvasHeight -
  310. this.canvasWidth * infoRes.height) /
  311. (infoRes.height * this.canvasHeight),
  312. );
  313. if (ratioDiff < this.ratioTolerance) {
  314. this.previewUrl = filePath;
  315. this.$emit('success', filePath);
  316. } else {
  317. this.startPaint();
  318. }
  319. this.paintCount++;
  320. },
  321. fail: (error) => {
  322. this.$emit('error', error);
  323. },
  324. });
  325. },
  326. },
  327. };
  328. </script>
  329. <style lang="scss" scoped>
  330. .u-poster {
  331. position: relative;
  332. }
  333. </style>