海报:移除 qs-canvas 库,采用社区活跃的 lime-painter 来实现海报生成

pull/53/head
puhui999 2024-05-13 18:12:42 +08:00
parent 004294ae7c
commit 8963ebc6af
23 changed files with 3538 additions and 157 deletions

2
.env
View File

@ -5,7 +5,7 @@ SHOPRO_VERSION = v1.8.3
SHOPRO_BASE_URL = http://api-dashboard.yudao.iocoder.cn SHOPRO_BASE_URL = http://api-dashboard.yudao.iocoder.cn
# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development # 后端接口 - 测试环境(通过 process.env.NODE_ENV = development
SHOPRO_DEV_BASE_URL = https://192.168.2.21:48080 SHOPRO_DEV_BASE_URL = https://192.168.1.105:48080
### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc ### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc
# 后端接口前缀(一般不建议调整) # 后端接口前缀(一般不建议调整)

View File

@ -94,7 +94,6 @@
"luch-request": "^3.0.8", "luch-request": "^3.0.8",
"pinia": "^2.0.33", "pinia": "^2.0.33",
"pinia-plugin-persist-uni": "^1.2.0", "pinia-plugin-persist-uni": "^1.2.0",
"qs-canvas": "^1.0.11",
"weixin-js-sdk": "^1.6.0" "weixin-js-sdk": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -2,56 +2,17 @@
<template> <template>
<su-popup :show="show" round="10" @close="onClosePoster" type="center" class="popup-box"> <su-popup :show="show" round="10" @close="onClosePoster" type="center" class="popup-box">
<view class="ss-flex-col ss-col-center ss-row-center"> <view class="ss-flex-col ss-col-center ss-row-center">
<view <template v-if="poster.views.length > 0">
v-if="poster.src === ''" <l-painter :board="poster" />
class="poster-title ss-flex ss-row-center" </template>
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
}"
>
海报加载中...
</view>
<image
v-else
class="poster-img"
:src="poster.src"
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
}"
:show-menu-by-longpress="true"
/>
<canvas
class="hideCanvas"
:canvas-id="poster.canvasId"
:id="poster.canvasId"
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
}"
/>
<view
class="poster-btn-box ss-m-t-20 ss-flex ss-row-between ss-col-center"
v-if="poster.src !== ''"
>
<button class="cancel-btn ss-reset-button" @tap="onClosePoster"></button>
<button class="save-btn ss-reset-button ui-BG-Main" @tap="onSavePoster">
{{
['wechatOfficialAccount', 'H5'].includes(sheep.$platform.name)
? '长按图片保存'
: '保存图片'
}}
</button>
</view>
</view> </view>
</su-popup> </su-popup>
</template> </template>
<script setup> <script setup>
import { reactive, getCurrentInstance } from 'vue'; import { getCurrentInstance, reactive } from 'vue';
import sheep from '@/sheep'; import sheep from '@/sheep';
import useCanvas from './useCanvas'; import { getPosterData } from '@/sheep/components/s-share-modal/canvas-poster/poster';
const props = defineProps({ const props = defineProps({
show: { show: {
@ -60,17 +21,22 @@
}, },
shareInfo: { shareInfo: {
type: Object, type: Object,
default() {}, default() {
},
}, },
}); });
const poster = reactive({ const poster = reactive({
canvasId: 'canvasId', css: {
width: sheep.$platform.device.windowWidth * 0.9, //
height: 600, width: sheep.$platform.device.windowWidth * 0.9,
src: '', height: 600,
},
views: [],
}); });
const emits = defineEmits(['success', 'close']); const emits = defineEmits(['success', 'close']);
const vm = getCurrentInstance(); const vm = getCurrentInstance();
@ -100,14 +66,11 @@
// 使 canvas // 使 canvas
async function getPoster(params) { async function getPoster(params) {
poster.src = ''; let drawer = await getPosterData({
poster.shareInfo = props.shareInfo; width: sheep.$platform.device.windowWidth * 0.9,
// #ifdef APP-PLUS shareInfo: props.shareInfo,
poster.canvasId = 'canvasId-' + new Date().getTime(); });
// #endif poster.views = drawer;
const canvas = await useCanvas(poster, vm);
console.log('canvas',canvas);
return canvas;
} }
defineExpose({ defineExpose({
@ -119,9 +82,11 @@
.popup-box { .popup-box {
position: relative; position: relative;
} }
.poster-title { .poster-title {
color: #999; color: #999;
} }
// //
.poster-btn-box { .poster-btn-box {
width: 600rpx; width: 600rpx;
@ -129,6 +94,7 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
bottom: -80rpx; bottom: -80rpx;
.cancel-btn { .cancel-btn {
width: 240rpx; width: 240rpx;
height: 70rpx; height: 70rpx;
@ -139,6 +105,7 @@
font-weight: 500; font-weight: 500;
color: $dark-9; color: $dark-9;
} }
.save-btn { .save-btn {
width: 240rpx; width: 240rpx;
height: 70rpx; height: 70rpx;
@ -152,6 +119,7 @@
.poster-img { .poster-img {
border-radius: 20rpx; border-radius: 20rpx;
} }
.hideCanvas { .hideCanvas {
position: fixed; position: fixed;
top: -99999rpx; top: -99999rpx;

View File

@ -1,122 +1,127 @@
import sheep from '@/sheep'; import sheep from '@/sheep';
import third from '@/sheep/api/migration/third'; import third from '@/sheep/api/migration/third';
import { formatImageUrlProtocol, getBase64Src } from './index'; import { formatImageUrlProtocol } from './index';
const goods = async (poster) => { const goods = async (poster) => {
const width = poster.width; const width = poster.width;
const userInfo = sheep.$store('user').userInfo; const userInfo = sheep.$store('user').userInfo;
const wxa_qrcode = (await third.wechat.getWxacode(poster.shareInfo.path, poster.shareInfo.query)).data; const wxa_qrcode = (await third.wechat.getWxacode(poster.shareInfo.path, poster.shareInfo.query)).data;
return { return [
background: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.goods_bg)), {
list: [ type: 'image',
{ src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.goods_bg)),
name: 'nickname', css: {
type: 'text', width,
val: userInfo.nickname, background: 'white',
x: width * 0.22, position: 'fixed',
y: width * 0.06, 'object-fit': 'contain',
paintbrushProps: { top: '0',
fillStyle: '#333', left: '0',
font: { zIndex: -1,
fontSize: 16,
fontFamily: 'sans-serif',
},
},
}, },
{ },
name: 'avatar', {
type: 'image', type: 'text',
val: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)), text: userInfo.nickname,
x: width * 0.04, css: {
y: width * 0.04, color: '#333',
fontSize: 16,
fontFamily: 'sans-serif',
position: 'fixed',
top: width * 0.06,
left: width * 0.22,
},
},
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
css: {
position: 'fixed',
left: width * 0.04,
top: width * 0.04,
width: width * 0.14, width: width * 0.14,
height: width * 0.14, height: width * 0.14,
d: width * 0.14,
}, },
{ },
name: 'goodsImage', {
type: 'image', type: 'image',
val: formatImageUrlProtocol(poster.shareInfo.poster.image), src: formatImageUrlProtocol(poster.shareInfo.poster.image),
x: width * 0.03, css: {
y: width * 0.21, position: 'fixed',
left: width * 0.03,
top: width * 0.21,
width: width * 0.94, width: width * 0.94,
height: width * 0.94, height: width * 0.94,
r: 10,
}, },
{ },
name: 'goodsTitle', {
type: 'text', type: 'text',
val: poster.shareInfo.poster.title, text: poster.shareInfo.poster.title,
x: width * 0.04, css: {
y: width * 1.18, position: 'fixed',
maxWidth: width * 0.91, left: width * 0.04,
line: 2, top: width * 1.18,
color: '#333',
fontSize: 14,
lineHeight: 5, lineHeight: 5,
paintbrushProps: { maxWidth: width * 0.91,
fillStyle: '#333',
font: {
fontSize: 14,
},
},
}, },
{ },
name: 'goodsPrice', {
type: 'text', type: 'text',
val: '¥' + poster.shareInfo.poster.price, text: '¥' + poster.shareInfo.poster.price,
x: width * 0.04, css: {
y: width * 1.3, position: 'fixed',
paintbrushProps: { left: width * 0.04,
fillStyle: '#ff0000', top: width * 1.3,
font: { fontSize: 20,
fontSize: 20, fontFamily: 'OPPOSANS',
fontFamily: 'OPPOSANS', color: '#333',
},
},
}, },
{ },
name: 'goodsOriginalPrice', {
type: 'text', type: 'text',
val: text:
poster.shareInfo.poster.original_price > 0 poster.shareInfo.poster.original_price > 0
? '¥' + poster.shareInfo.poster.original_price ? '¥' + poster.shareInfo.poster.original_price
: '', : '',
x: width * 0.3, css: {
y: width * 1.32, position: 'fixed',
paintbrushProps: { left: width * 0.3,
fillStyle: '#999', top: width * 1.32,
font: { color: '#999',
fontSize: 10, fontSize: 10,
fontFamily: 'OPPOSANS', fontFamily: 'OPPOSANS',
}, textDecoration: 'line-through',
},
textDecoration: {
line: 'line-through',
style: 'solide',
},
}, },
// #ifndef MP-WEIXIN },
{ // #ifndef MP-WEIXIN
name: 'qrcode', {
type: 'qrcode', type: 'qrcode',
val: poster.shareInfo.link, text: poster.shareInfo.link,
x: width * 0.75, css: {
y: width * 1.3, position: 'fixed',
size: width * 0.2, left: width * 0.75,
}, top: width * 1.3,
// #endif
// #ifdef MP-WEIXIN
{
name: 'wxacode',
type: 'image',
val: wxa_qrcode,
x: width * 0.75,
y: width * 1.3,
width: width * 0.2, width: width * 0.2,
height: width * 0.2, height: width * 0.2,
}, },
// #endif },
], // #endif
}; // #ifdef MP-WEIXIN
{
type: 'image',
src: wxa_qrcode,
css: {
position: 'fixed',
left: width * 0.75,
top: width * 1.3,
width: width * 0.2,
height: width * 0.2,
},
},
// #endif
];
}; };
export default goods; export default goods;

View File

@ -0,0 +1,223 @@
## 1.9.6.52024-04-14
- fix: 修复`nvue`无法生图的问题
## 1.9.6.42024-03-10
- fix: 修复代理ctx导致H5不能使用ctx.save
## 1.9.6.32024-03-08
- fix: 修复支付宝真机无法使用的问题
## 1.9.6.22024-02-22
- fix: 修复使用render函数报错的问题
## 1.9.6.12023-12-22
- fix: 修复字节小程序非2d字体偏移
- fix: 修复`canvasToTempFilePathSync`会触发两次的问题
- fix: 修复`parser`图片没有宽度的问题
## 1.9.62023-12-06
- fix: 修复背景图受padding影响
- fix: 修复因字节报错改了代理实现导致微信报错
- 1.9.5.82023-11-16
- fix: 修复margin问题
- fix: 修复borderWidth问题
- fix: 修复textBox问题
- fix: 修复字节开发工具报`could not be cloned.`问题
## 1.9.5.72023-07-27
- fix: 去掉多余的方法
- chore: 更新文档,增加自定义字体说明
## 1.9.5.62023-07-21
- feat: 有限的支持富文本
- feat: H5和APP 增加 `hidpi` prop主要用于大尺寸无法生成图片时用
- fix: 修复 钉钉小程序 缺少 `measureText` 方法
- chore: 由于微信小程序 pc 端的 canvas 2d 时不时抽风故不使用canvas 2d
## 1.9.5.52023-06-27
- fix: 修复把`emoji`表情字符拆分成多个字符的情况
## 1.9.5.42023-06-05
- fix: 修复因`canvasToTempFilePathSync`监听导致重复调用
## 1.9.5.32023-05-23
- fix: 因isPc错写成了isPC导致小程序PC不能生成图片
## 1.9.5.22023-05-22
- feat: 删除多余文件
## 1.9.5.12023-05-22
- fix: 修复 文字行数与`line-clamp`相同但不满一行时也加了省略号的问题
## 1.9.52023-05-14
- feat: 增加 `text-indent``calc` 方法
- feat: 优化 布局时间
## 1.9.4.42023-04-15
- fix: 修复无法匹配负值
- fix: 修复 Nvue IOS getImageInfo `useCORS` 为 undefined
## 1.9.4.32023-04-01
- feat: 增加支持文字描边 `text-stroke: '5rpx #fff'`
## 1.9.4.22023-03-30
- fix: 修复 支付宝小程序 isPC 在手机也为true的问题
- feat: 由 微信开发工具 3060 版 无法获取图片尺寸,现 微信开发工具 3220 版 修复该问题,故还原上一版的获取图片方式。
## 1.9.4.12023-03-28
- fix: 修复固定高度不正确问题
## 1.9.42023-03-17
- fix: nvue ios getImageInfo缺少this报错
- fix: pathType 非2d无效问题
- fix: 修复 小米9se 可能会存在多次init 导致画面多次放大
- fix: 修复 border 分开写 width style无效问题
- fix: 修复 支付宝小程序IOS 再次进入不渲染的问题
- fix: 修复 支付宝小程序安卓Zindex排序错乱问题
- fix: 修复 微信开发工具 3060 版 无法获取图片的问题
- feat: 把 for in 改为 forEach
- feat: 增加 hidden
- feat: 根节点 box-sizing 默认 `border-box`
- feat: 增加支持 `vw` `wh`
- chore: pathType 取消 默认值,因为字节开发工具不能显示
- chore: 支付宝小程序开发工具不支持 生成图片 请以真机调试为准
- bug: 企业微信 2.20.3无法使用
## 1.9.3.52022-06-29
- feat: justifyContent 增加 `space-around`、`space-between`
- feat: canvas 2d 也使用`getImageInfo`
- fix: 修复 `text``text-decoration`错位
## 1.9.3.42022-06-20
- fix: 修复 因创建节点速度问题导致顺序出错。
- fix: 修复 微信小程序 PC 无法显示本地图片
- fix: 修复 flex-box 对齐问题
- feat: 增加 `text-shadow`
- feat: 重写 `text` 对齐方式
- chore: 更新文档
## 1.9.3.32022-06-17
- fix: 修复 支付宝小程序 canvas 2d 存在ctx.draw问题导致报错
- fix: 修复 支付宝小程序 toDataURL 存在权限问题改用 `toTempFilePath`
- fix: 修复 支付宝小程序 image size 问题导致 `objectFit` 无效
## 1.9.3.22022-06-14
- fix: 修复 image 设置背景色不生效问题
- fix: 修复 nvue 环境判断缺少参数问题
## 1.9.3.12022-06-14
- fix: 修复 bottom 定位不对问题
- fix: 修复 因小数导致计算出错换行问题
- feat: 增加 `useCORS` h5端图片跨域 在设置请求头无效果后试一下设置这个值
- chore: 更新文档
## 1.9.32022-06-13
- feat: 增加 `zIndex`
- feat: 增加 `flex-box` 该功能处于原始阶段,非常简陋。
- tips: QQ小程序 vue3 不支持, 为 uni 官方BUG
## 1.9.2.92022-06-10
- fix: 修复`text-align`及`margin`居中问题
## 1.9.2.82022-06-10
- fix: 修复 Nvue `canvasToTempFilePathSync` 不生效问题
## 1.9.2.72022-06-10
- fix: 修复 margin及padding的bug
- fix: 修复 Nvue `isCanvasToTempFilePath` 不生效问题
## 1.9.2.62022-06-09
- fix: 修复 Nvue 不显示
- feat: 增加支持字体渐变
```html
<l-painter-text
text="水调歌头\n明月几时有把酒问青天。不知天上宫阙今夕是何年。我欲乘风归去又恐琼楼玉宇高处不胜寒。起舞弄清影何似在人间。"
css="background: linear-gradient(,#ff971b 0%, #1989fa 100%); background-clip: text" />
```
## 1.9.2.52022-06-09
- chore: 更变获取父级宽度的设定
- chore: `pathType` 在canvas 2d 默认为 `url`
## 1.9.2.42022-06-08
- fix: 修复 `pathType` 不生效问题
## 1.9.2.32022-06-08
- fix: 修复 `canvasToTempFilePath` 漏写 `success` 参数
## 1.9.2.22022-06-07
- chore: 更新文档
## 1.9.2.12022-06-07
- fix: 修复 vue3 赋值给this再传入导致image无法绘制
- fix: 修复 `canvasToTempFilePathSync` 时机问题
- feat: canvas 2d 更改图片生成方式 `toDataURL`
## 1.9.22022-05-30
- fix: 修复 `canvasToTempFilePathSync` 在 vue3 下只生成一次
## 1.9.1.72022-05-28
- fix: 修复 `qrcode`显示不全问题
## 1.9.1.62022-05-28
- fix: 修复 `canvasToTempFilePathSync` 会重复多次问题
- fix: 修复 `view` css `backgroundImage` 图片下载失败导致 子节点不渲染
## 1.9.1.52022-05-27
- fix: 修正支付宝小程序 canvas 2d版本号 2.7.15
## 1.9.1.42022-05-22
- fix: 修复字节小程序无法使用xml方式
- fix: 修复字节小程序无法使用base64(非2D情况下工具上无法显示)
- fix: 修复支付宝小程序 `canvasToTempFilePath` 报错
## 1.9.1.32022-04-29
- fix: 修复vue3打包后uni对象为空后的报错
## 1.9.1.22022-04-25
- fix: 删除多余文件
## 1.9.1.12022-04-25
- fix: 修复图片不显示问题
## 1.9.12022-04-12
- fix: 因四舍五入导致有些机型错位
- fix: 修复无views报错
- chore: nvue下因ios无法读取插件内static文件改由下载方式
## 1.9.02022-03-20
- fix: 因无法固定尺寸导致生成图片不全
- fix: 特定情况下text判断无效
- chore: 本地化APP Nvue webview
## 1.8.92022-02-20
- fix: 修复 小程序下载最多10次并发的问题
- fix: 修复 APP端无法获取本地图片
- fix: 修复 APP Nvue端不执行问题
- chore: 增加图片缓存机制
## 1.8.8.82022-01-27
- fix: 修复 主动调用尺寸问题
## 1.8.8.62022-01-26
- fix: 修复 nvue 下无宽度时获取父级宽度
- fix: 修复 ios app 无法渲染问题
## 1.8.82022-01-23
- fix: 修复 主动调用时无节点问题
- fix: 修复 `box-shadow` 颜色问题
- fix: 修复 `transform:rotate` 角度位置问题
- feat: 增加 `overflow:hidden`
## 1.8.72022-01-07
- fix: 修复 image 方向为 `right` 时原始宽高问题
- feat: 支持 view 设置背景图 `background-image: url(xxx)`
- chore: 去掉可选链
## 1.8.62021-11-28
- feat: 支持`view`对`inline-block`的子集使用`text-align`
## 1.8.5.52021-08-17
- chore: 更新文档,删除 replace
- fix: 修复 text 值为 number时报错
## 1.8.5.42021-08-16
- fix: 字节小程序兼容
## 1.8.5.32021-08-15
- fix: 修复线性渐变与css现实效果不一致的问题
- chore: 更新文档
## 1.8.5.22021-08-13
- chore: 增加`background-image`、`background-repeat` 能力,主要用于背景纹理的绘制,并不是代替`image`。例如:大面积的重复平铺的水印
- 注意这个功能H5暂时无法使用因为[官方的API有BUG](https://ask.dcloud.net.cn/question/128793),待官方修复!!!
## 1.8.5.12021-08-10
- fix: 修复因`margin`报错问题
## 1.8.52021-08-09
- chore: 增加margin支持`auto`,以达到居中效果
## 1.8.42021-08-06
- chore: 增加判断缓存文件条件
- fix: 修复css 多余空格报错问题
## 1.8.32021-08-04
- tips: 1.6.x 以下的版本升级到1.8.x后要为每个元素都加上定位position: 'absolute'
- fix: 修复只有一个view子元素时不计算高度的问题
## 1.8.22021-08-03
- fix: 修复 path-type 为 `url` 无效问题
- fix: 修复 qrcode `text` 为空时报错问题
- fix: 修复 image `src` 动态设置时不生效问题
- feat: 增加 css 属性 `min-width` `max-width`
## 1.8.12021-08-02
- fix: 修复无法加载本地图片
## 1.8.02021-08-02
- chore 文档更新
- 使用旧版的同学不要升级!
## 1.8.0-beta2021-07-30
- ## 全新布局方式 不兼容旧版!
- chore: 布局方式变更
- tips: 微信canvas 2d 不支持真机调试
## 1.6.62021-07-09
- chore: 统一命名规范,无须主动引入组件
## 1.6.52021-06-08
- chore: 去掉console
## 1.6.42021-06-07
- fix: 修复 数字 为纯字符串时不转换的BUG
## 1.6.32021-06-06
- fix: 修复 PC 端放大的BUG
## 1.6.22021-05-31
- fix: 修复 报`adaptor is not a function`错误
- fix: 修复 text 多行高度
- fix: 优化 默认文字的基准线
- feat: `@progress`事件,监听绘制进度
## 1.6.12021-02-28
- 删除多余节点
## 1.6.02021-02-26
- 调整为uni_modules目录规范
- 修复transform的rotate不能为负数问题
- 新增:`pathType` 指定生成图片返回的路径类型,可选值有 `base64`、`url`

View File

@ -0,0 +1,150 @@
const styles = (v ='') => v.split(';').filter(v => v && !/^[\n\s]+$/.test(v)).map(v => {
const key = v.slice(0, v.indexOf(':'))
const value = v.slice(v.indexOf(':')+1)
return {
[key
.replace(/-([a-z])/g, function() { return arguments[1].toUpperCase()})
.replace(/\s+/g, '')
]: value.replace(/^\s+/, '').replace(/\s+$/, '') || ''
}
})
export function parent(parent) {
return {
provide() {
return {
[parent]: this
}
},
data() {
return {
el: {
id: null,
css: {},
views: []
},
}
},
watch: {
css: {
handler(v) {
if(this.canvasId) {
this.el.css = (typeof v == 'object' ? v : v && Object.assign(...styles(v))) || {}
this.canvasWidth = this.el.css && this.el.css.width || this.canvasWidth
this.canvasHeight = this.el.css && this.el.css.height || this.canvasHeight
}
},
immediate: true
}
}
}
}
export function children(parent, options = {}) {
const indexKey = options.indexKey || 'index'
return {
inject: {
[parent]: {
default: null
}
},
watch: {
el: {
handler(v, o) {
if(JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
},
src: {
handler(v, o) {
if(v != o)
this.bindRelation()
},
immediate: true
},
text: {
handler(v, o) {
if(v != o) this.bindRelation()
},
immediate: true
},
css: {
handler(v, o) {
if(v != o)
this.el.css = (typeof v == 'object' ? v : v && Object.assign(...styles(v))) || {}
},
immediate: true
},
replace: {
handler(v, o) {
if(JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
}
},
created() {
if(!this._uid) {
this._uid = this._.uid
}
Object.defineProperty(this, 'parent', {
get: () => this[parent] || [],
})
Object.defineProperty(this, 'index', {
get: () => {
this.bindRelation();
const {parent: {el: {views=[]}={}}={}} = this
return views.indexOf(this.el)
},
});
this.el.type = this.type
if(this.uid) {
this.el.uid = this.uid
}
this.bindRelation()
},
// #ifdef VUE3
beforeUnmount() {
this.removeEl()
},
// #endif
// #ifdef VUE2
beforeDestroy() {
this.removeEl()
},
// #endif
methods: {
removeEl() {
if (this.parent) {
this.parent.el.views = this.parent.el.views.filter(
(item) => item._uid !== this._uid
);
}
},
bindRelation() {
if(!this.el._uid) {
this.el._uid = this._uid
}
if(['text','qrcode'].includes(this.type)) {
this.el.text = this.$slots && this.$slots.default && this.$slots.default[0].text || `${this.text || ''}`.replace(/\\n/g, '\n')
}
if(this.type == 'image') {
this.el.src = this.src
}
if (!this.parent) {
return;
}
let views = this.parent.el.views || [];
if(views.indexOf(this.el) !== -1) {
this.parent.el.views = views.map(v => v._uid == this._uid ? this.el : v)
} else {
this.parent.el.views = [...views, this.el];
}
}
},
mounted() {
// this.bindRelation()
},
}
}

View File

@ -0,0 +1,28 @@
<template>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-image',
mixins:[children('painter')],
props: {
id: String,
css: [String, Object],
src: String
},
data() {
return {
type: 'image',
el: {
css: {},
src: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,27 @@
<template>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-qrcode',
mixins:[children('painter')],
props: {
id: String,
css: [String, Object],
text: String
},
data() {
return {
type: 'qrcode',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,33 @@
<template>
<text style="opacity: 0;height: 0;"><slot/></text>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-text',
mixins:[children('painter')],
props: {
type: {
type: String,
default: 'text'
},
uid: String,
css: [String, Object],
text: [String, Number],
replace: Object,
},
data() {
return {
// type: 'text',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,34 @@
<template>
<view><slot/></view>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-view',
mixins:[children('painter'), parent('painter')],
props: {
id: String,
type: {
type: String,
default: 'view'
},
css: [String, Object],
},
data() {
return {
// type: 'view',
el: {
css: {},
views:[]
},
}
},
mounted() {
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,461 @@
<template>
<view class="lime-painter" ref="limepainter">
<view v-if="canvasId && size" :style="styles">
<!-- #ifndef APP-NVUE -->
<canvas class="lime-painter__canvas" v-if="use2dCanvas" :id="canvasId" type="2d" :style="size"></canvas>
<canvas class="lime-painter__canvas" v-else :id="canvasId" :canvas-id="canvasId" :style="size"
:width="boardWidth * dpr" :height="boardHeight * dpr" :hidpi="hidpi"></canvas>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<web-view :style="size" ref="webview"
src="/uni_modules/lime-painter/hybrid/html/index.html"
class="lime-painter__canvas" @pagefinish="onPageFinish" @error="onError" @onPostMessage="onMessage">
</web-view>
<!-- #endif -->
</view>
<slot />
</view>
</template>
<script>
import { parent } from '../common/relation'
import props from './props'
import {toPx, base64ToPath, pathToBase64, isBase64, sleep, getImageInfo }from './utils';
// #ifndef APP-NVUE
import { canIUseCanvas2d, isPC} from './utils';
import Painter from './painter';
// import Painter from '@painter'
const nvue = {}
// #endif
// #ifdef APP-NVUE
import nvue from './nvue'
// #endif
export default {
name: 'lime-painter',
mixins: [props, parent('painter'), nvue],
data() {
return {
use2dCanvas: false,
canvasHeight: 150,
canvasWidth: null,
parentWidth: 0,
inited: false,
progress: 0,
firstRender: 0,
done: false,
tasks: []
};
},
computed: {
styles() {
return `${this.size}${this.customStyle||''};` + (this.hidden && 'position: fixed; left: 1500rpx;')
},
canvasId() {
return `l-painter${this._ && this._.uid || this._uid}`
},
size() {
if (this.boardWidth && this.boardHeight) {
return `width:${this.boardWidth}px; height: ${this.boardHeight}px;`;
}
},
dpr() {
return this.pixelRatio || uni.getSystemInfoSync().pixelRatio;
},
boardWidth() {
const {width = 0} = (this.elements && this.elements.css) || this.elements || this
const w = toPx(width||this.width)
return w || Math.max(w, toPx(this.canvasWidth));
},
boardHeight() {
const {height = 0} = (this.elements && this.elements.css) || this.elements || this
const h = toPx(height||this.height)
return h || Math.max(h, toPx(this.canvasHeight));
},
hasBoard() {
return this.board && Object.keys(this.board).length
},
elements() {
return this.hasBoard ? this.board : JSON.parse(JSON.stringify(this.el))
}
},
created() {
this.use2dCanvas = this.type === '2d' && canIUseCanvas2d() && !isPC
},
async mounted() {
await sleep(30)
await this.getParentWeith()
this.$nextTick(() => {
setTimeout(() => {
this.$watch('elements', this.watchRender, {
deep: true,
immediate: true
});
}, 30)
})
},
// #ifdef VUE3
unmounted() {
this.done = false
this.inited = false
this.firstRender = 0
this.progress = 0
this.painter = null
clearTimeout(this.rendertimer)
},
// #endif
// #ifdef VUE2
destroyed() {
this.done = false
this.inited = false
this.firstRender = 0
this.progress = 0
this.painter = null
clearTimeout(this.rendertimer)
},
// #endif
methods: {
async watchRender(val, old) {
if (!val || !val.views || (!this.firstRender ? !val.views.length : !this.firstRender) || !Object.keys(val).length || JSON.stringify(val) == JSON.stringify(old)) return;
this.firstRender = 1
this.progress = 0
this.done = false
clearTimeout(this.rendertimer)
this.rendertimer = setTimeout(() => {
this.render(val);
}, this.beforeDelay)
},
async setFilePath(path, param) {
let filePath = path
const {pathType = this.pathType} = param || this
if (pathType == 'base64' && !isBase64(path)) {
filePath = await pathToBase64(path)
} else if (pathType == 'url' && isBase64(path)) {
filePath = await base64ToPath(path)
}
if (param && param.isEmit) {
this.$emit('success', filePath);
}
return filePath
},
async getSize(args) {
const {width} = args.css || args
const {height} = args.css || args
if (!this.size) {
if (width || height) {
this.canvasWidth = width || this.canvasWidth
this.canvasHeight = height || this.canvasHeight
await sleep(30);
} else {
await this.getParentWeith()
}
}
},
canvasToTempFilePathSync(args) {
// this.stopWatch && this.stopWatch()
// this.stopWatch = this.$watch('done', (v) => {
// if (v) {
// this.canvasToTempFilePath(args)
// this.stopWatch && this.stopWatch()
// }
// }, {
// immediate: true
// })
this.tasks.push(args)
if(this.done){
this.runTask()
}
},
runTask(){
while(this.tasks.length){
const task = this.tasks.shift()
this.canvasToTempFilePath(task)
}
},
// #ifndef APP-NVUE
getParentWeith() {
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`.lime-painter`)
.boundingClientRect()
.exec(res => {
const {width, height} = res[0]||{}
this.parentWidth = Math.ceil(width||0)
this.canvasWidth = this.parentWidth || 300
this.canvasHeight = height || this.canvasHeight||150
resolve(res[0])
})
})
},
async render(args = {}) {
if(!Object.keys(args).length) {
return console.error('空对象')
}
this.progress = 0
this.done = false
// #ifdef APP-NVUE
this.tempFilePath.length = 0
// #endif
await this.getSize(args)
const ctx = await this.getContext();
let {
use2dCanvas,
boardWidth,
boardHeight,
canvas,
afterDelay
} = this;
if (use2dCanvas && !canvas) {
return Promise.reject(new Error('canvas 没创建'));
}
this.boundary = {
top: 0,
left: 0,
width: boardWidth,
height: boardHeight
};
this.painter = null
if (!this.painter) {
const {width} = args.css || args
const {height} = args.css || args
if(!width && this.parentWidth) {
Object.assign(args, {width: this.parentWidth})
}
const param = {
context: ctx,
canvas,
width: boardWidth,
height: boardHeight,
pixelRatio: this.dpr,
useCORS: this.useCORS,
createImage: getImageInfo.bind(this),
performance: this.performance,
listen: {
onProgress: (v) => {
this.progress = v
this.$emit('progress', v)
},
onEffectFail: (err) => {
this.$emit('faill', err)
}
}
}
this.painter = new Painter(param)
}
try{
// vue3 data
const { width, height } = await this.painter.source(JSON.parse(JSON.stringify(args)))
this.boundary.height = this.canvasHeight = height
this.boundary.width = this.canvasWidth = width
await sleep(this.sleep);
await this.painter.render()
await new Promise(resolve => this.$nextTick(resolve));
if (!use2dCanvas) {
await this.canvasDraw();
}
if (afterDelay && use2dCanvas) {
await sleep(afterDelay);
}
this.$emit('done');
this.done = true
if (this.isCanvasToTempFilePath) {
this.canvasToTempFilePath()
.then(res => {
this.$emit('success', res.tempFilePath)
})
.catch(err => {
this.$emit('fail', new Error(JSON.stringify(err)));
});
}
this.runTask()
return Promise.resolve({
ctx,
draw: this.painter,
node: this.node
});
}catch(e){
//TODO handle the exception
}
},
canvasDraw(flag = false) {
return new Promise((resolve, reject) => this.ctx.draw(flag, () => setTimeout(() => resolve(), this
.afterDelay)));
},
async getContext() {
if (!this.canvasWidth) {
this.$emit('fail', 'painter no size')
console.error('[lime-painter]: 给画板或父级设置尺寸')
return Promise.reject();
}
if (this.ctx && this.inited) {
return Promise.resolve(this.ctx);
}
const { type, use2dCanvas, dpr, boardWidth, boardHeight } = this;
const _getContext = () => {
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.boundingClientRect()
.exec(res => {
if (res) {
const ctx = uni.createCanvasContext(this.canvasId, this);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = false;
this.canvas = res;
}
// measureText mock
if (!ctx.measureText) {
function strLen(str) {
let len = 0;
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 0 && str.charCodeAt(i) < 128) {
len++;
} else {
len += 2;
}
}
return len;
}
ctx.measureText = text => {
let fontSize = ctx.state && ctx.state.fontSize || 12;
const font = ctx.__font
if (font && fontSize == 12) {
fontSize = parseInt(font.split(' ')[3], 10);
}
fontSize /= 2;
return {
width: strLen(text) * fontSize
};
}
}
// #ifdef MP-ALIPAY
ctx.scale(dpr, dpr);
// #endif
this.ctx = ctx
resolve(this.ctx);
} else {
console.error('[lime-painter] no node')
}
});
});
};
if (!use2dCanvas) {
return _getContext();
}
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.node()
.exec(res => {
let {node: canvas} = res && res[0]||{};
if(canvas) {
const ctx = canvas.getContext(type);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = true;
this.canvas = canvas;
}
this.ctx = ctx
resolve(this.ctx);
} else {
console.error('[lime-painter]: no size')
}
});
});
},
canvasToTempFilePath(args = {}) {
return new Promise(async (resolve, reject) => {
const { use2dCanvas, canvasId, dpr, fileType, quality } = this;
const success = async (res) => {
try {
const tempFilePath = await this.setFilePath(res.tempFilePath || res, args)
const result = Object.assign(res, {tempFilePath})
args.success && args.success(result)
resolve(result)
} catch (e) {
this.$emit('fail', e)
}
}
let { top: y = 0, left: x = 0, width, height } = this.boundary || this;
// let destWidth = width * dpr;
// let destHeight = height * dpr;
// #ifdef MP-ALIPAY
// width = destWidth;
// height = destHeight;
// #endif
const copyArgs = Object.assign({
// x,
// y,
// width,
// height,
// destWidth,
// destHeight,
canvasId,
id: canvasId,
fileType,
quality,
}, args, {success});
// if(this.isPC || use2dCanvas) {
// copyArgs.canvas = this.canvas
// }
if (use2dCanvas) {
copyArgs.canvas = this.canvas
try{
// #ifndef MP-ALIPAY
const oFilePath = this.canvas.toDataURL(`image/${args.fileType||fileType}`.replace(/pg/, 'peg'), args.quality||quality)
if(/data:,/.test(oFilePath)) {
uni.canvasToTempFilePath(copyArgs, this);
} else {
const tempFilePath = await this.setFilePath(oFilePath, args)
args.success && args.success({tempFilePath})
resolve({tempFilePath})
}
// #endif
// #ifdef MP-ALIPAY
this.canvas.toTempFilePath(copyArgs)
// #endif
}catch(e){
args.fail && args.fail(e)
reject(e)
}
} else {
// #ifdef MP-ALIPAY
if(this.ctx.toTempFilePath) {
//
const ctx = uni.createCanvasContext(canvasId);
ctx.toTempFilePath(copyArgs);
} else {
my.canvasToTempFilePath(copyArgs);
}
// #endif
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath(copyArgs, this);
// #endif
}
})
}
// #endif
}
};
</script>
<style>
.lime-painter,
.lime-painter__canvas {
// #ifndef APP-NVUE
width: 100%;
// #endif
// #ifdef APP-NVUE
flex: 1;
// #endif
}
</style>

View File

@ -0,0 +1,214 @@
// #ifdef APP-NVUE
import {
sleep,
getImageInfo,
isBase64,
networkReg
} from './utils';
const dom = weex.requireModule('dom')
import {
version
} from '../../package.json'
export default {
data() {
return {
tempFilePath: [],
isInitFile: false,
osName: uni.getSystemInfoSync().osName
}
},
methods: {
getParentWeith() {
return new Promise(resolve => {
dom.getComponentRect(this.$refs.limepainter, (res) => {
this.parentWidth = Math.ceil(res.size.width)
this.canvasWidth = this.canvasWidth || this.parentWidth || 300
this.canvasHeight = res.size.height || this.canvasHeight || 150
resolve(res.size)
})
})
},
onPageFinish() {
this.webview = this.$refs.webview
this.webview.evalJS(`init(${this.dpr})`)
},
onMessage(e) {
const res = e.detail.data[0] || null;
if (res.event) {
if (res.event == 'inited') {
this.inited = true
}
if (res.event == 'fail') {
this.$emit('fail', res)
}
if (res.event == 'layoutChange') {
const data = typeof res.data == 'string' ? JSON.parse(res.data) : res.data
this.canvasWidth = Math.ceil(data.width);
this.canvasHeight = Math.ceil(data.height);
}
if (res.event == 'progressChange') {
this.progress = res.data * 1
}
if (res.event == 'file') {
this.tempFilePath.push(res.data)
if (this.tempFilePath.length > 7) {
this.tempFilePath.shift()
}
return
}
if (res.event == 'success') {
if (res.data) {
this.tempFilePath.push(res.data)
if (this.tempFilePath.length > 8) {
this.tempFilePath.shift()
}
if (this.isCanvasToTempFilePath) {
this.setFilePath(this.tempFilePath.join(''), {
isEmit: true
})
}
} else {
this.$emit('fail', 'canvas no data')
}
return
}
this.$emit(res.event, JSON.parse(res.data));
} else if (res.file) {
this.file = res.data;
} else {
console.info(res[0])
}
},
getWebViewInited() {
if (this.inited) return Promise.resolve(this.inited);
return new Promise((resolve) => {
this.$watch(
'inited',
async val => {
if (val) {
resolve(val)
}
}, {
immediate: true
}
);
})
},
getTempFilePath() {
if (this.tempFilePath.length == 8) return Promise.resolve(this.tempFilePath)
return new Promise((resolve) => {
this.$watch(
'tempFilePath',
async val => {
if (val.length == 8) {
resolve(val.join(''))
}
}, {
deep: true
}
);
})
},
getWebViewDone() {
if (this.progress == 1) return Promise.resolve(this.progress);
return new Promise((resolve) => {
this.$watch(
'progress',
async val => {
if (val == 1) {
this.$emit('done')
this.done = true
this.runTask()
resolve(val)
}
}, {
immediate: true
}
);
})
},
async render(args) {
try {
await this.getSize(args)
const {
width
} = args.css || args
if (!width && this.parentWidth) {
Object.assign(args, {
width: this.parentWidth
})
}
const newNode = await this.calcImage(args);
await this.getWebViewInited()
this.webview.evalJS(`source(${JSON.stringify(newNode)})`)
await this.getWebViewDone()
await sleep(this.afterDelay)
if (this.isCanvasToTempFilePath) {
const params = {
fileType: this.fileType,
quality: this.quality
}
this.webview.evalJS(`save(${JSON.stringify(params)})`)
}
return Promise.resolve()
} catch (e) {
this.$emit('fail', e)
}
},
async calcImage(args) {
let node = JSON.parse(JSON.stringify(args))
const urlReg = /url\((.+)\)/
const {
backgroundImage
} = node.css || {}
const isBG = backgroundImage && urlReg.exec(backgroundImage)[1]
const url = node.url || node.src || isBG
if (['text', 'qrcode'].includes(node.type)) {
return node
}
if ((node.type === "image" || isBG) && url && !isBase64(url) && (this.osName == 'ios' || !networkReg
.test(url))) {
let {
path
} = await getImageInfo(url, true)
if (isBG) {
node.css.backgroundImage = `url(${path})`
} else {
node.src = path
}
} else if (node.views && node.views.length) {
for (let i = 0; i < node.views.length; i++) {
node.views[i] = await this.calcImage(node.views[i])
}
}
return node
},
async canvasToTempFilePath(args = {}) {
if (!this.inited) {
return this.$emit('fail', 'no init')
}
this.tempFilePath = []
if (args.fileType == 'jpg') {
args.fileType = 'jpeg'
}
this.webview.evalJS(`save(${JSON.stringify(args)})`)
try {
let tempFilePath = await this.getTempFilePath()
tempFilePath = await this.setFilePath(tempFilePath, args)
args.success({
errMsg: "canvasToTempFilePath:ok",
tempFilePath
})
} catch (e) {
console.log('e', e)
args.fail({
error: e
})
}
}
}
}
// #endif

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,56 @@
export default {
props: {
board: Object,
pathType: String, // 'base64'、'url'
fileType: {
type: String,
default: 'png'
},
hidden: Boolean,
quality: {
type: Number,
default: 1
},
css: [String, Object],
// styles: [String, Object],
width: [Number, String],
height: [Number, String],
pixelRatio: Number,
customStyle: String,
isCanvasToTempFilePath: Boolean,
// useCanvasToTempFilePath: Boolean,
sleep: {
type: Number,
default: 1000 / 30
},
beforeDelay: {
type: Number,
default: 100
},
afterDelay: {
type: Number,
default: 100
},
performance: Boolean,
// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
type: {
type: String,
default: '2d'
},
// #endif
// #ifdef APP-NVUE
hybrid: Boolean,
timeout: {
type: Number,
default: 2000
},
// #endif
// #ifdef H5 || APP-PLUS
useCORS: Boolean,
hidpi: {
type: Boolean,
default: true
}
// #endif
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,368 @@
export const networkReg = /^(http|\/\/)/;
export const isBase64 = (path) => /^data:image\/(\w+);base64/.test(path);
export function sleep(delay) {
return new Promise(resolve => setTimeout(resolve, delay))
}
let {platform, SDKVersion} = uni.getSystemInfoSync()
export const isPC = /windows|mac/.test(platform)
// 缓存图片
let cache = {}
export function isNumber(value) {
return /^-?\d+(\.\d+)?$/.test(value);
}
export function toPx(value, baseSize, isDecimal = false) {
// 如果是数字
if (typeof value === 'number') {
return value
}
// 如果是字符串数字
if (isNumber(value)) {
return value * 1
}
// 如果有单位
if (typeof value === 'string') {
const reg = /^-?([0-9]+)?([.]{1}[0-9]+){0,1}(em|rpx|px|%)$/g
const results = reg.exec(value);
if (!value || !results) {
return 0;
}
const unit = results[3];
value = parseFloat(value);
let res = 0;
if (unit === 'rpx') {
res = uni.upx2px(value);
} else if (unit === 'px') {
res = value * 1;
} else if (unit === '%') {
res = value * toPx(baseSize) / 100;
} else if (unit === 'em') {
res = value * toPx(baseSize || 14);
}
return isDecimal ? res.toFixed(2) * 1 : Math.round(res);
}
return 0
}
// 计算版本
export function compareVersion(v1, v2) {
v1 = v1.split('.')
v2 = v2.split('.')
const len = Math.max(v1.length, v2.length)
while (v1.length < len) {
v1.push('0')
}
while (v2.length < len) {
v2.push('0')
}
for (let i = 0; i < len; i++) {
const num1 = parseInt(v1[i], 10)
const num2 = parseInt(v2[i], 10)
if (num1 > num2) {
return 1
} else if (num1 < num2) {
return -1
}
}
return 0
}
function gte(version) {
// #ifdef MP-ALIPAY
SDKVersion = my.SDKVersion
// #endif
return compareVersion(SDKVersion, version) >= 0;
}
export function canIUseCanvas2d() {
// #ifdef MP-WEIXIN
return gte('2.9.2');
// #endif
// #ifdef MP-ALIPAY
return gte('2.7.15');
// #endif
// #ifdef MP-TOUTIAO
return gte('1.78.0');
// #endif
return false
}
// #ifdef MP
export const prefix = () => {
// #ifdef MP-TOUTIAO
return tt
// #endif
// #ifdef MP-WEIXIN
return wx
// #endif
// #ifdef MP-BAIDU
return swan
// #endif
// #ifdef MP-ALIPAY
return my
// #endif
// #ifdef MP-QQ
return qq
// #endif
// #ifdef MP-360
return qh
// #endif
}
// #endif
/**
* base64转路径
* @param {Object} base64
*/
export function base64ToPath(base64) {
const [, format] = /^data:image\/(\w+);base64,/.exec(base64) || [];
return new Promise((resolve, reject) => {
// #ifdef MP
const fs = uni.getFileSystemManager()
//自定义文件名
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
let pre = prefix()
// #ifdef MP-TOUTIAO
const filePath = `${pre.getEnvInfoSync().common.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifndef MP-TOUTIAO
const filePath = `${pre.env.USER_DATA_PATH}/${time}.${format}`
// #endif
fs.writeFile({
filePath,
data: base64.split(',')[1],
encoding: 'base64',
success() {
resolve(filePath)
},
fail(err) {
console.error(err)
reject(err)
}
})
// #endif
// #ifdef H5
// mime类型
let mimeString = base64.split(',')[0].split(':')[1].split(';')[0];
//base64 解码
let byteString = atob(base64.split(',')[1]);
//创建缓冲数组
let arrayBuffer = new ArrayBuffer(byteString.length);
//创建视图
let intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
intArray[i] = byteString.charCodeAt(i);
}
resolve(URL.createObjectURL(new Blob([intArray], {
type: mimeString
})))
// #endif
// #ifdef APP-PLUS
const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
bitmap.loadBase64Data(base64, () => {
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
const filePath = `_doc/uniapp_temp/${time}.${format}`
bitmap.save(filePath, {},
() => {
bitmap.clear()
resolve(filePath)
},
(error) => {
bitmap.clear()
reject(error)
})
}, (error) => {
bitmap.clear()
reject(error)
})
// #endif
})
}
/**
* 路径转base64
* @param {Object} string
*/
export function pathToBase64(path) {
if (/^data:/.test(path)) return path
return new Promise((resolve, reject) => {
// #ifdef H5
let image = new Image();
image.setAttribute("crossOrigin", 'Anonymous');
image.onload = function() {
let canvas = document.createElement('canvas');
canvas.width = this.naturalWidth;
canvas.height = this.naturalHeight;
canvas.getContext('2d').drawImage(image, 0, 0);
let result = canvas.toDataURL('image/png')
resolve(result);
canvas.height = canvas.width = 0
}
image.src = path + '?v=' + Math.random()
image.onerror = (error) => {
reject(error);
};
// #endif
// #ifdef MP
if (uni.canIUse('getFileSystemManager')) {
uni.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: (res) => {
resolve('data:image/png;base64,' + res.data)
},
fail: (error) => {
console.error({error, path})
reject(error)
}
})
}
// #endif
// #ifdef APP-PLUS
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), (entry) => {
entry.file((file) => {
const fileReader = new plus.io.FileReader()
fileReader.onload = (data) => {
resolve(data.target.result)
}
fileReader.onerror = (error) => {
reject(error)
}
fileReader.readAsDataURL(file)
}, reject)
}, reject)
// #endif
})
}
export function getImageInfo(path, useCORS) {
const isCanvas2D = this && this.canvas && this.canvas.createImage
return new Promise(async (resolve, reject) => {
// let time = +new Date()
let src = path.replace(/^@\//,'/')
if (cache[path] && cache[path].errMsg) {
resolve(cache[path])
} else {
try {
// #ifdef MP || APP-PLUS
if (isBase64(path) && (isCanvas2D ? isPC : true)) {
src = await base64ToPath(path)
}
// #endif
// #ifdef H5
if(useCORS) {
src = await pathToBase64(path)
}
// #endif
} catch (error) {
reject({
...error,
src
})
}
// #ifndef APP-NVUE
if(isCanvas2D && !isPC) {
const img = this.canvas.createImage()
img.onload = function() {
const image = {
path: img,
width: img.width,
height: img.height
}
cache[path] = image
resolve(cache[path])
}
img.onerror = function(err) {
reject({err,path})
}
img.src = src
return
}
// #endif
uni.getImageInfo({
src,
success: (image) => {
const localReg = /^\.|^\/(?=[^\/])/;
// #ifdef MP-WEIXIN || MP-BAIDU || MP-QQ || MP-TOUTIAO
image.path = localReg.test(src) ? `/${image.path}` : image.path;
// #endif
if(isCanvas2D) {
const img = this.canvas.createImage()
img.onload = function() {
image.path = img
cache[path] = image
resolve(cache[path])
}
img.onerror = function(err) {
reject({err,path})
}
img.src = src
return
}
// #ifdef APP-PLUS
// console.log('getImageInfo', +new Date() - time)
// ios 比较严格 可能需要设置跨域
if(uni.getSystemInfoSync().osName == 'ios' && useCORS) {
pathToBase64(image.path).then(base64 => {
image.path = base64
cache[path] = image
resolve(cache[path])
}).catch(err => {
console.error({err, path})
reject({err,path})
})
return
}
// #endif
cache[path] = image
resolve(cache[path])
},
fail(err) {
console.error({err, path})
reject({err,path})
}
})
}
})
}
// #ifdef APP-PLUS
const getLocalFilePath = (path) => {
if (path.indexOf('_www') === 0 || path.indexOf('_doc') === 0 || path.indexOf('_documents') === 0 || path
.indexOf('_downloads') === 0) {
return path
}
if (path.indexOf('file://') === 0) {
return path
}
if (path.indexOf('/storage/emulated/0/') === 0) {
return path
}
if (path.indexOf('/') === 0) {
const localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
// #endif

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
<style type="text/css">
html,
body,
canvas {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
overflow-y: hidden;
background-color: transparent;
}
</style>
</head>
<body>
<canvas id="lime-painter"></canvas>
<script type="text/javascript" src="./uni.webview.1.5.3.js"></script>
<script type="text/javascript" src="./painter.js"></script>
<script>
var cache = [];
var painter = null;
var canvas = null;
var context = null;
var timer = null;
var pixelRatio = 1;
console.log = function (...args) {
postMessage(args);
};
// function stringify(key, value) {
// if (typeof value === 'object' && value !== null) {
// if (cache.indexOf(value) !== -1) {
// return;
// }
// cache.push(value);
// }
// return value;
// };
function emit(event, data) {
postMessage({
event,
data: (typeof data !== 'object' && data !== null ? data : JSON.stringify(data))
});
cache = [];
};
function postMessage(data) {
uni.postMessage({
data
});
};
function init(dpr) {
canvas = document.querySelector('#lime-painter');
context = canvas.getContext('2d');
pixelRatio = dpr || window.devicePixelRatio;
painter = new Painter({
id: 'lime-painter',
context,
canvas,
pixelRatio,
width: canvas.offsetWidth,
height: canvas.offsetHeight,
listen: {
onProgress(v) {
emit('progressChange', v);
},
onEffectFail(err) {
//console.error(err)
emit('fail', err);
}
}
});
emit('inited', true);
};
function save(args) {
delete args.success;
delete args.fail;
clearTimeout(timer);
timer = setTimeout(() => {
const path = painter.save(args);
if (typeof path == 'string') {
const index = Math.ceil(path.length / 8);
for (var i = 0; i < 8; i++) {
if (i == 7) {
emit('success', path.substr(i * index, index));
} else {
emit('file', path.substr(i * index, index));
}
};
} else {
// console.log('canvas no data')
emit('fail', 'canvas no data');
};
}, 30);
};
async function source(args) {
let size = await painter.source(args);
emit('layoutChange', size);
if(!canvas.height) {
console.log('canvas no size')
emit('fail', 'canvas no size');
}
painter.render().catch(err => {
// console.error(err)
emit('fail', err);
});
};
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,93 @@
{
"id": "lime-painter",
"displayName": "海报画板",
"version": "1.9.6.5",
"description": "一款canvas海报组件更优雅的海报生成方案有限的支持富文本",
"keywords": [
"海报",
"富文本",
"生成海报",
"生成二维码",
"JSON"
],
"repository": "https://gitee.com/liangei/lime-painter",
"engines": {
"HBuilderX": "^3.4.14"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "305716444"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
},
"name": "lime-painter",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}

View File

@ -0,0 +1,388 @@
/*
* HTML5 Parser By Sam Blowes
*
* Designed for HTML5 documents
*
* Original code by John Resig (ejohn.org)
* http://ejohn.org/blog/pure-javascript-html-parser/
* Original code by Erik Arvidsson, Mozilla Public License
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
*
* ----------------------------------------------------------------------------
* License
* ----------------------------------------------------------------------------
*
* This code is triple licensed using Apache Software License 2.0,
* Mozilla Public License or GNU Public License
*
* ////////////////////////////////////////////////////////////////////////////
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* ////////////////////////////////////////////////////////////////////////////
*
* The contents of this file are subject to the Mozilla Public License
* Version 1.1 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS"
* basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
* License for the specific language governing rights and limitations
* under the License.
*
* The Original Code is Simple HTML Parser.
*
* The Initial Developer of the Original Code is Erik Arvidsson.
* Portions created by Erik Arvidssson are Copyright (C) 2004. All Rights
* Reserved.
*
* ////////////////////////////////////////////////////////////////////////////
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* ----------------------------------------------------------------------------
* Usage
* ----------------------------------------------------------------------------
*
* // Use like so:
* HTMLParser(htmlString, {
* start: function(tag, attrs, unary) {},
* end: function(tag) {},
* chars: function(text) {},
* comment: function(text) {}
* });
*
* // or to get an XML string:
* HTMLtoXML(htmlString);
*
* // or to get an XML DOM Document
* HTMLtoDOM(htmlString);
*
* // or to inject into an existing document/DOM node
* HTMLtoDOM(htmlString, document);
* HTMLtoDOM(htmlString, document.body);
*
*/
// Regular Expressions for parsing tags and attributes
var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/;
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/;
var attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; // Empty Elements - HTML 5
var empty = makeMap('area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr'); // Block Elements - HTML 5
// fixed by xxx 将 ins 标签从块级名单中移除
var block = makeMap('a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video'); // Inline Elements - HTML 5
var inline = makeMap('abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var'); // Elements that you can, intentionally, leave open
// (and which close themselves)
var closeSelf = makeMap('colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr'); // Attributes that have their values filled in disabled="disabled"
var fillAttrs = makeMap('checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected'); // Special Elements (can contain anything)
var special = makeMap('script,style');
function HTMLParser(html, handler) {
var index;
var chars;
var match;
var stack = [];
var last = html;
stack.last = function () {
return this[this.length - 1];
};
while (html) {
chars = true; // Make sure we're not in a script or style element
if (!stack.last() || !special[stack.last()]) {
// Comment
if (html.indexOf('<!--') == 0) {
index = html.indexOf('-->');
if (index >= 0) {
if (handler.comment) {
handler.comment(html.substring(4, index));
}
html = html.substring(index + 3);
chars = false;
} // end tag
} else if (html.indexOf('</') == 0) {
match = html.match(endTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(endTag, parseEndTag);
chars = false;
} // start tag
} else if (html.indexOf('<') == 0) {
match = html.match(startTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(startTag, parseStartTag);
chars = false;
}
}
if (chars) {
index = html.indexOf('<');
var text = index < 0 ? html : html.substring(0, index);
html = index < 0 ? '' : html.substring(index);
if (handler.chars) {
handler.chars(text);
}
}
} else {
html = html.replace(new RegExp('([\\s\\S]*?)<\/' + stack.last() + '[^>]*>'), function (all, text) {
text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, '$1$2');
if (handler.chars) {
handler.chars(text);
}
return '';
});
parseEndTag('', stack.last());
}
if (html == last) {
throw 'Parse Error: ' + html;
}
last = html;
} // Clean up any remaining tags
parseEndTag();
function parseStartTag(tag, tagName, rest, unary) {
tagName = tagName.toLowerCase();
if (block[tagName]) {
while (stack.last() && inline[stack.last()]) {
parseEndTag('', stack.last());
}
}
if (closeSelf[tagName] && stack.last() == tagName) {
parseEndTag('', tagName);
}
unary = empty[tagName] || !!unary;
if (!unary) {
stack.push(tagName);
}
if (handler.start) {
var attrs = [];
rest.replace(attr, function (match, name) {
var value = arguments[2] ? arguments[2] : arguments[3] ? arguments[3] : arguments[4] ? arguments[4] : fillAttrs[name] ? name : '';
attrs.push({
name: name,
value: value,
escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') // "
});
});
if (handler.start) {
handler.start(tagName, attrs, unary);
}
}
}
function parseEndTag(tag, tagName) {
// If no tag name is provided, clean shop
if (!tagName) {
var pos = 0;
} // Find the closest opened tag of the same type
else {
for (var pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos] == tagName) {
break;
}
}
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (var i = stack.length - 1; i >= pos; i--) {
if (handler.end) {
handler.end(stack[i]);
}
} // Remove the open elements from the stack
stack.length = pos;
}
}
}
function makeMap(str) {
var obj = {};
var items = str.split(',');
for (var i = 0; i < items.length; i++) {
obj[items[i]] = true;
}
return obj;
}
function removeDOCTYPE(html) {
return html.replace(/<\?xml.*\?>\n/, '').replace(/<!doctype.*>\n/, '').replace(/<!DOCTYPE.*>\n/, '');
}
function parseAttrs(attrs) {
return attrs.reduce(function (pre, attr) {
var value = attr.value;
var name = attr.name;
if (pre[name]) {
pre[name] = pre[name] + " " + value;
} else {
pre[name] = value;
}
return pre;
}, {});
}
function convertStyleStringToJSON(styleString) {
var styles = styleString.split(";"); // 通过分号将样式字符串分割为多个样式声明
var result = {};
styles.forEach(function(style) {
var styleParts = style.split(":"); // 通过冒号将样式声明分割为属性和值
var property = styleParts[0].trim();
var value = styleParts[1] && styleParts[1].trim();
if (property && value) {
result[property] = value; // 将属性和值添加到结果对象中
}
});
return result;
}
function parseHtml(html) {
html = removeDOCTYPE(html);
var stacks = [];
var results = {
node: 'root',
children: []
};
HTMLParser(html, {
start: function start(tag, attrs, unary) {
var node = {
name: tag
};
if (attrs.length !== 0) {
node.attrs = parseAttrs(attrs);
node.styles = node.attrs.style ? convertStyleStringToJSON(node.attrs.style) : {}
}
if(!node.type) {
if(inline[node.name] && node.name !== 'img' ) {
node.type = 'text';
if(node.name == 'br') {
node.text = '\n'
} else if(node.name == 'strong'){
node.styles.fontWeight = 'bold'
}
} else if(node.name == 'img'){
node.type = 'image'
node.src = node.attrs.src
} else {
node.type = 'view'
if(['h1','h2','h3','h4','h5','h6'].includes(node.name)) {
node.styles.fontWeight = 'bold'
}
}
}
if (unary) {
var parent = stacks[0] || results;
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
} else {
stacks.unshift(node);
}
},
end: function end(tag) {
var node = stacks.shift();
if (node.name !== tag) console.error('invalid state: mismatch end tag');
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
const isTextBox = node.children && node.children.length > 1 && node.children.every(child => {
return ['text','image'].includes(child.type)
})
if(isTextBox) {
node.type = 'textBox'
}
},
chars: function chars(text) {
var node = {
type: 'text',
text: text
};
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
},
comment: function comment(text) {
var node = {
node: 'comment',
text: text
};
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
});
return results.children;
}
export default parseHtml;

View File

@ -0,0 +1,963 @@
# Painter 画板 测试版
> uniapp 海报画板,更优雅的海报生成方案
> [查看更多 站点 1](https://limeui.qcoon.cn/#/painter)
> [查看更多 站点 2](http://liangei.gitee.io/limeui/#/painter)
> Q 群1169785031
## 平台兼容
| H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 头条小程序 | QQ 小程序 | App |
| --- | ---------- | ------------ | ---------- | ---------- | --------- | --- |
| √ | √ | √ | 未测 | √ | √ | √ |
## 安装
在市场导入**[海报画板](https://ext.dcloud.net.cn/plugin?id=2389)uni_modules**版本的即可,无需`import`
## 代码演示
### 插件demo
- lime-painter 为 demo
- 位于 uni_modules/lime-painter/components/lime-painter
- 导入插件后直接使用可查看demo
```vue
<lime-painter />
```
### 基本用法
- 插件提供 JSON 及 Template 的方式绘制海报
- 参考 css 块状流布局模拟 css schema。
- 另外flex布局还不是成完善请谨慎使用普通的流布局我觉得已经够用了。
#### 方式一 Template
- 提供`l-painter-view`、`l-painter-text`、`l-painter-image`、`l-painter-qrcode`四种类型组件
- 通过 `css` 属性绘制样式,与 style 使用方式保持一致。
```html
<l-painter>
//如果使用Template出现顺序错乱可使用`template` 等所有变量完成再显示
<template v-if="show">
<l-painter-view
css="background: #07c160; height: 120rpx; width: 120rpx; display: inline-block"
></l-painter-view>
<l-painter-view
css="background: #1989fa; height: 120rpx; width: 120rpx; border-top-right-radius: 60rpx; border-bottom-left-radius: 60rpx; display: inline-block; margin: 0 30rpx;"
></l-painter-view>
<l-painter-view
css="background: #ff9d00; height: 120rpx; width: 120rpx; border-radius: 50%; display: inline-block"
></l-painter-view>
<template>
</l-painter>
```
#### 方式二 JSON
- 在 json 里四种类型组件的`type`为`view`、`text`、`image`、`qrcode`
- 通过 `board` 设置海报所需的 JSON 数据进行绘制或`ref`获取组件实例调用组件内的`render(json)`
- 所有类型的 schema 都具有`css`字段css 的 key 值使用**驼峰**如:`lineHeight`
```html
<l-painter :board="poster"/>
```
```js
data() {
return {
poster: {
css: {
// 根节点若无尺寸,自动获取父级节点
width: '750rpx'
},
views: [
{
css: {
background: "#07c160",
height: "120rpx",
width: "120rpx",
display: "inline-block"
},
type: "view"
},
{
css: {
background: "#1989fa",
height: "120rpx",
width: "120rpx",
borderTopRightRadius: "60rpx",
borderBottomLeftRadius: "60rpx",
display: "inline-block",
margin: "0 30rpx"
},
views: [],
type: "view"
},
{
css: {
background: "#ff9d00",
height: "120rpx",
width: "120rpx",
borderRadius: "50%",
display: "inline-block"
},
views: [],
type: "view"
},
]
}
}
}
```
### View 容器
- 类似于 `div` 可以嵌套承载更多的 view、text、imageqrcode 共同构建一颗完整的节点树
- 在 JSON 里具有 `views` 的数组字段,用于嵌套承载节点。
#### 方式一 Template
```html
<l-painter>
<l-painter-view css="background: #f0f0f0; padding-top: 100rpx;">
<l-painter-view
css="background: #d9d9d9; width: 33.33%; height: 100rpx; display: inline-block"
></l-painter-view>
<l-painter-view
css="background: #bfbfbf; width: 66.66%; height: 100rpx; display: inline-block"
></l-painter-view>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON
```js
{
css: {},
views: [
{
type: 'view',
css: {
background: '#f0f0f0',
paddingTop: '100rpx'
},
views: [
{
type: 'view',
css: {
background: '#d9d9d9',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
},
{
type: 'view',
css: {
background: '#bfbfbf',
width: '66.66%',
height: '100rpx',
display: 'inline-block'
}
}
],
}
]
}
```
### Text 文本
- 通过 `text` 属性填写文本内容。
- 支持`\n`换行符
- 支持省略号,使用 css 的`line-clamp`设置行数,当文字内容超过会显示省略号。
- 支持`text-decoration`
#### 方式一 Template
```html
<l-painter>
<l-painter-view css="background: #e0e2db; padding: 30rpx; color: #222a29">
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"
/>
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"
css="text-align:center; padding-top: 20rpx; text-decoration: line-through "
/>
<l-painter-text
text="登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼"
css="text-align:right; padding-top: 20rpx"
/>
<l-painter-text
text="水调歌头\n明月几时有把酒问青天。不知天上宫阙今夕是何年。我欲乘风归去又恐琼楼玉宇高处不胜寒。起舞弄清影何似在人间。"
css="line-clamp: 3; padding-top: 20rpx; background: linear-gradient(,#ff971b 0%, #ff5000 100%); background-clip: text"
/>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON
```js
// 基础用法
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置居中对齐
textAlign: 'center',
// 设置中划线
textDecoration: 'line-through'
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置右对齐
textAlign: 'right',
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽黄河入海流\n欲穷千里目更上一层楼',
css: {
// 设置行数,超出显示省略号
lineClamp: 3,
// 渐变文字
background: 'linear-gradient(,#ff971b 0%, #1989fa 100%)',
backgroundClip: 'text'
}
}
```
### Image 图片
- 通过 `src` 属性填写图片路径。
- 图片路径支持:网络图片,本地 static 里的图片路径,缓存路径,**字节的static目录是写相对路径**
- 通过 `css``object-fit`属性可以设置图片的填充方式,可选值见下方 CSS 表格。
- 通过 `css``object-position`配合 `object-fit` 可以设置图片的对齐方式,类似于`background-position`,详情见下方 CSS 表格。
- 使用网络图片时:小程序需要去公众平台配置 [downloadFile](https://mp.weixin.qq.com/) 域名
- 使用网络图片时:**H5 和 Nvue 需要决跨域问题**
#### 方式一 Template
```html
<l-painter>
<!-- 基础用法 -->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx"
/>
<!-- 填充方式 -->
<!-- css object-fit 设置 填充方式 见下方表格-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; background: #eee"
/>
<!-- css object-position 设置 图片的对齐方式-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; object-position: 50% 50%; background: #eee"
/>
</l-painter>
```
#### 方式二 JSON
```js
// 基础用法
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx'
}
},
// 填充方式
// css objectFit 设置 填充方式 见下方表格
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain'
}
},
// css objectPosition 设置 图片的对齐方式
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain',
objectPosition: '50% 50%'
}
}
```
### Qrcode 二维码
- 通过`text`属性填写需要生成二维码的文本。
- 通过 `css` 里的 `color` 可设置生成码点的颜色。
- 通过 `css` 里的 `background`可设置背景色。
- 通过 `css `里的 `width`、`height`设置尺寸。
#### 方式一 Template
```html
<l-painter>
<l-painter-qrcode
text="limeui.qcoon.cn"
css="width: 200rpx; height: 200rpx"
/>
</l-painter>
```
#### 方式二 JSON
```js
{
type: 'qrcode',
text: 'limeui.qcoon.cn',
css: {
width: '200rpx',
height: '200rpx',
}
}
```
### 富文本
- 这是一个有限支持的测试能力只能通过JSON方式不要抱太大希望!
- 首先需要把富文本转成JSON,这需要引入`parser`这个包,如果你不使用是不会进入主包
```html
<l-painter ref="painter"/>
```
```js
import parseHtml from '@/uni_modules/lime-painter/parser'
const json = parseHtml(`<p><span>测试测试</span><img src="/static/logo.png"/></p>`)
this.$refs.painter.render(json)
```
### 生成图片
- 方式1、通过设置`isCanvasToTempFilePath`自动生成图片并在 `@success` 事件里接收海报临时路径
- 方式2、通过调用内部方法生成图片
```html
<l-painter ref="painter">...code</l-painter>
```
```js
this.$refs.painter.canvasToTempFilePathSync({
fileType: "jpg",
// 如果返回的是base64是无法使用 saveImageToPhotosAlbum需要设置 pathType为url
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
// 非H5 保存到相册
// H5 提示用户长按图另存
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
},
});
```
### 主动调用方式
- 通过获取组件实例内部的`render`函数 传递`JSON`即可
```html
<l-painter ref="painter" />
```
```js
// 渲染
this.$refs.painter.render(jsonSchema);
// 生成图片
this.$refs.painter.canvasToTempFilePathSync({
fileType: "jpg",
// 如果返回的是base64是无法使用 saveImageToPhotosAlbum需要设置 pathType为url
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
// 非H5 保存到相册
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
},
});
```
### H5跨域
- 一般是需要后端或管理OSS资源的大佬处理
- 一般OSS的处理方式:
1、设置来源
```cmd
*
```
2、允许Methods
```html
GET
```
3、允许Headers
```html
access-control-allow-origin:*
```
4、最后如果还是不行,可试下给插件设置`useCORS`
```html
<l-painter useCORS>
```
### 海报示例
- 提供一份示例,只把插件当成生成图片的工具,非必要不要在弹窗里使用。
- 通过设置`isCanvasToTempFilePath`主动生成图片,再由 `@success` 事件接收海报临时路径
- 设置`hidden`隐藏画板。
请注意,示例用到了图片,海报的渲染是包括下载图片的时间,也许在某天图片会失效或访问超级慢,请更换为你的图片再查看,另外如果你是小程序请在使用示例时把**不校验合法域名**勾上!!!!!不然不显示还以为是插件的锅,求求了大佬们!
#### 方式一 Template
```html
<image :src="path" mode="widthFix"></image>
<l-painter
isCanvasToTempFilePath
@success="path = $event"
hidden
css="width: 750rpx; padding-bottom: 40rpx; background: linear-gradient(,#ff971b 0%, #ff5000 100%)"
>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="margin-left: 40rpx; margin-top: 40rpx; width: 84rpx; height: 84rpx; border-radius: 50%;"
/>
<l-painter-view
css="margin-top: 40rpx; padding-left: 20rpx; display: inline-block"
>
<l-painter-text
text="隔壁老王"
css="display: block; padding-bottom: 10rpx; color: #fff; font-size: 32rpx; fontWeight: bold"
/>
<l-painter-text
text="为您挑选了一个好物"
css="color: rgba(255,255,255,.7); font-size: 24rpx"
/>
</l-painter-view>
<l-painter-view
css="margin-left: 40rpx; margin-top: 30rpx; padding: 32rpx; box-sizing: border-box; background: #fff; border-radius: 16rpx; width: 670rpx; box-shadow: 0 20rpx 58rpx rgba(0,0,0,.15)"
>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="object-fit: cover; object-position: 50% 50%; width: 606rpx; height: 606rpx; border-radius: 12rpx;"
/>
<l-painter-view
css="margin-top: 32rpx; color: #FF0000; font-weight: bold; font-size: 28rpx; line-height: 1em;"
>
<l-painter-text text="¥" css="vertical-align: bottom" />
<l-painter-text
text="39"
css="vertical-align: bottom; font-size: 58rpx"
/>
<l-painter-text text=".39" css="vertical-align: bottom" />
<l-painter-text
text="¥59.99"
css="vertical-align: bottom; padding-left: 10rpx; font-weight: normal; text-decoration: line-through; color: #999999"
/>
</l-painter-view>
<l-painter-view css="margin-top: 32rpx; font-size: 26rpx; color: #8c5400">
<l-painter-text text="自营" css="color: #212121; background: #ffb400;" />
<l-painter-text
text="30天最低价"
css="margin-left: 16rpx; background: #fff4d9; text-decoration: line-through;"
/>
<l-painter-text
text="满减优惠"
css="margin-left: 16rpx; background: #fff4d9"
/>
<l-painter-text
text="超高好评"
css="margin-left: 16rpx; background: #fff4d9"
/>
</l-painter-view>
<l-painter-view css="margin-top: 30rpx">
<l-painter-text
css="line-clamp: 2; color: #333333; line-height: 1.8em; font-size: 36rpx; width: 478rpx; padding-right:32rpx; box-sizing: border-box"
text="360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝"
></l-painter-text>
<l-painter-qrcode
css="width: 128rpx; height: 128rpx;"
text="limeui.qcoon.cn"
></l-painter-qrcode>
</l-painter-view>
</l-painter-view>
</l-painter>
```
```js
data() {
return {
path: ''
}
}
```
#### 方式二 JSON
```html
<image :src="path" mode="widthFix"></image>
<l-painter
:board="poster"
isCanvasToTempFilePath
@success="path = $event"
hidden
/>
```
```js
data() {
return {
path: '',
poster: {
css: {
width: "750rpx",
paddingBottom: "40rpx",
background: "linear-gradient(,#000 0%, #ff5000 100%)"
},
views: [
{
src: "https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg",
type: "image",
css: {
background: "#fff",
objectFit: "cover",
marginLeft: "40rpx",
marginTop: "40rpx",
width: "84rpx",
border: "2rpx solid #fff",
boxSizing: "border-box",
height: "84rpx",
borderRadius: "50%"
}
},
{
type: "view",
css: {
marginTop: "40rpx",
paddingLeft: "20rpx",
display: "inline-block"
},
views: [
{
text: "隔壁老王",
type: "text",
css: {
display: "block",
paddingBottom: "10rpx",
color: "#fff",
fontSize: "32rpx",
fontWeight: "bold"
}
},
{
text: "为您挑选了一个好物",
type: "text",
css: {
color: "rgba(255,255,255,.7)",
fontSize: "24rpx"
},
}
],
},
{
css: {
marginLeft: "40rpx",
marginTop: "30rpx",
padding: "32rpx",
boxSizing: "border-box",
background: "#fff",
borderRadius: "16rpx",
width: "670rpx",
boxShadow: "0 20rpx 58rpx rgba(0,0,0,.15)"
},
views: [
{
src: "https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg",
type: "image",
css: {
objectFit: "cover",
objectPosition: "50% 50%",
width: "606rpx",
height: "606rpx"
},
}, {
css: {
marginTop: "32rpx",
color: "#FF0000",
fontWeight: "bold",
fontSize: "28rpx",
lineHeight: "1em"
},
views: [{
text: "¥",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "39",
type: "text",
css: {
verticalAlign: "bottom",
fontSize: "58rpx"
},
}, {
text: ".39",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "¥59.99",
type: "text",
css: {
verticalAlign: "bottom",
paddingLeft: "10rpx",
fontWeight: "normal",
textDecoration: "line-through",
color: "#999999"
}
}],
type: "view"
}, {
css: {
marginTop: "32rpx",
fontSize: "26rpx",
color: "#8c5400"
},
views: [{
text: "自营",
type: "text",
css: {
color: "#212121",
background: "#ffb400"
},
}, {
text: "30天最低价",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9",
textDecoration: "line-through"
},
}, {
text: "满减优惠",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}, {
text: "超高好评",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}],
type: "view"
}, {
css: {
marginTop: "30rpx"
},
views: [
{
text: "360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝",
type: "text",
css: {
paddingRight: "32rpx",
boxSizing: "border-box",
lineClamp: 2,
color: "#333333",
lineHeight: "1.8em",
fontSize: "36rpx",
width: "478rpx"
},
}, {
text: "limeui.qcoon.cn",
type: "qrcode",
css: {
width: "128rpx",
height: "128rpx",
},
}],
type: "view"
}],
type: "view"
}
]
}
}
}
```
### 自定义字体
- 需要平台的支持,已知微信小程序支持,其它的没试过,如果可行请告之
```
// 需要在app.vue中下载字体
uni.loadFontFace({
global:true,
scopes: ['native'],
family: '自定义字体名称',
source: 'url("https://sungd.github.io/Pacifico.ttf")',
success() {
console.log('success')
}
})
// 然后就可以在插件的css中写font-family: '自定义字体名称'
```
### Nvue
- 必须为HBX 3.4.11及以上
### 原生小程序
- 插件里的`painter.js`支持在原生小程序中使用
- new Painter 之后在`source`里传入 JSON
- 再调用`render`绘制海报
- 如需生成图片,请查看微信小程序 cavnas 的[canvasToTempFilePath](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html)
```html
<canvas type="2d" id="painter" style="width: 100%"></canvas>
```
```js
import { Painter } from "./painter";
page({
data: {
poster: {
css: {
width: "750rpx",
},
views: [
{
type: "view",
css: {
background: "#d2d4c8",
paddingTop: "100rpx",
},
views: [
{
type: "view",
css: {
background: "#5f7470",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
{
type: "view",
css: {
background: "#889696",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
{
type: "view",
css: {
background: "#b8bdb5",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
],
},
],
},
},
async onLoad() {
const res = await this.getCentext();
const painter = new Painter(res);
// 返回计算布局后的整个内容尺寸
const { width, height } = await painter.source(this.data.poster);
// 得到计算后的尺寸后 可给canvas尺寸赋值达到动态响应效果
// 渲染
await painter.render();
},
// 获取canvas 2d
// 非2d 需要传一个 createImage 方法用于获取图片信息 即把 getImageInfo 的 success 通过 promise resolve 返回
getCentext() {
return new Promise((resolve) => {
wx.createSelectorQuery()
.select(`#painter`)
.node()
.exec((res) => {
let { node: canvas } = res[0];
resolve({
canvas,
context: canvas.getContext("2d"),
width: canvas.width,
height: canvas.height,
// createImage: getImageInfo()
pixelRatio: 2,
});
});
});
},
});
```
### 旧版(1.6.x)更新
- 由于 1.8.x 版放弃了以定位的方式,所以 1.6.x 版更新之后要每个样式都加上`position: absolute`
- 旧版的 `image` mode 模式被放弃,使用`object-fit`
- 旧版的 `isRenderImage` 改成 `is-canvas-to-temp-file-path`
- 旧版的 `maxLines` 改成 `line-clamp`
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| -------------------------- | ------------------------------------------------------------ | ---------------- | ------------ |
| board | JSON 方式的海报元素对象集 | <em>object</em> | - |
| css | 海报内容最外层的样式,可以理解为`body` | <em>object</em> | 参数请向下看 |
| custom-style | canvas 元素的样式 | <em>string</em> | |
| hidden | 隐藏画板 | <em>boolean</em> | `false` |
| is-canvas-to-temp-file-path | 是否生成图片,在`@success`事件接收图片地址 | <em>boolean</em> | `false` |
| after-delay | 生成图片错乱,可延时生成图片 | <em>number</em> | `100` |
| type | canvas 类型,对微信头条支付宝小程序可有效,可选值:`2d``''` | <em>string</em> | `2d` |
| file-type | 生成图片的后缀类型, 可选值:`png`、`jpg` | <em>string</em> | `png` |
| path-type | 生成图片路径类型,可选值`url`、`base64` | <em>string</em> | `-` |
| pixel-ratio | 生成图片的像素密度,默认为对应手机的像素密度,`nvue`无效 | <em>number</em> | `-` |
| hidpi | H5和APP是否使用高清处理 | <em>boolean</em> | `true` |
| width | **废弃** 画板的宽度,一般只用于通过内部方法时加上 | <em>number</em> | `` |
| height | **废弃** 画板的高度 ,同上 | <em>number</em> | `` |
### css
| 属性名 | 支持的值或类型 | 默认值 |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| (min\max)width | 支持`%`、`rpx`、`px` | - |
| height | 同上 | - |
| color | `string` | - |
| position | 定位,可选值:`absolute`、`fixed` | - |
| ↳ left、top、right、bottom | 配合`position`才生效,支持`%`、`rpx`、`px` | - |
| margin | 可简写或各方向分别写,如:`margin-top`,支持`auto`、`rpx`、`px` | - |
| padding | 可简写或各方向分别写,支持`rpx`、`px` | - |
| border | 可简写或各个值分开写:`border-width`、`border-style` 、`border-color`,简写请按顺序写 | - |
| line-clamp | `number`,超过行数显示省略号 | - |
| vertical-align | 文字垂直对齐,可选值:`bottom`、`top`、`middle` | `middle` |
| line-height | 文字行高,支持`rpx`、`px`、`em` | `1.4em` |
| font-weight | 文字粗细,可选值:`normal`、`bold` | `normal` |
| font-size | 文字大小,`string`,支持`rpx`、`px` | `14px` |
| text-decoration | 文本修饰,可选值:`underline` 、`line-through`、`overline` | - |
| text-stroke | 文字描边,可简写或各个值分开写,如:`text-stroke-color`, `text-stroke-width` | - |
| text-align | 文本水平对齐,可选值:`right` 、`center` | `left` |
| display | 框类型,可选值:`block`、`inline-block`、`flex`、`none`,当为`none`时是不渲染该段, `flex`功能简陋。 | - |
| flex | 配合 display: flex; 属性定义了在分配多余空间,目前只用为数值如: flex: 1 | - |
| align-self | 配合 display: flex; 单个项目垂直轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| justify-content | 配合 display: flex; 水平轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| align-items | 配合 display: flex; 垂直轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| border-radius | 圆角边框,支持`%`、`rpx`、`px` | - |
| box-sizing | 可选值:`border-box` | - |
| box-shadow | 投影 | - |
| background(color) | 支持渐变,但必须写百分比!如:`linear-gradient(,#ff971b 0%, #ff5000 100%)`、`radial-gradient(#0ff 15%, #f0f 60%)`,目前 radial-gradient 渐变的圆心为元素中点,半径为最长边,不支持设置 | - |
| background-clip | 文字渐变,配合`background`背景渐变,设置`background-clip: text` 达到文字渐变效果 | - |
| background-image | view 元素背景:`url(src)`,若只是设置背景图,请不要设置`background-repeat` | - |
| background-repeat | 设置是否及如何重复背景纹理,可选值:`repeat`、`repeat-x`、`repeat-y`、`no-repeat` | `repeat` |
| [object-fit](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-fit/) | 图片元素适应容器方式,类似于`mode`,可选值:`cover`、 `contain``fill``none` | - |
| [object-position](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-position) | 图片的对齐方式,配合`object-fit`使用 | - |
### 图片填充模式 object-fit
| 名称 | 含义 |
| ------- | ------------------------------------------------------ |
| contain | 保持宽高缩放图片,使图片的长边能完全显示出来 |
| cover | 保持宽高缩放图片,使图片的短边能完全显示出来,裁剪长边 |
| fill | 拉伸图片,使图片填满元素 |
| none | 保持图片原有尺寸 |
### 事件 Events
| 事件名 | 说明 | 返回值 |
| -------- | ---------------------------------------------------------------- | ------ |
| success | 生成图片成功,若使用`is-canvas-to-temp-filePath` 可以接收图片地址 | path |
| fail | 生成图片失败 | error |
| done | 绘制成功 | |
| progress | 绘制进度 | number |
### 暴露函数 Expose
| 事件名 | 说明 | 返回值 |
| -------- | ---------------------------------------------------------------- | ------ |
| render(object) | 渲染器传入JSON 绘制海报 | promise |
| [canvasToTempFilePath](https://uniapp.dcloud.io/api/canvas/canvasToTempFilePath.html#canvastotempfilepath)(object) | 把当前画布指定区域的内容导出生成指定大小的图片,并返回文件临时路径。 | |
| canvasToTempFilePathSync(object) | 同步接口,同上 | |
## 常见问题
- 1、H5 端使用网络图片需要解决跨域问题。
- 2、小程序使用网络图片需要去公众平台增加下载白名单二级域名也需要配
- 3、H5 端生成图片是 base64有时显示只有一半可以使用原生标签`<IMG/>`
- 4、发生保存图片倾斜变形或提示 native buffer exceed size limit 时,使用 pixel-ratio="2"参数,降分辨率。
- 5、h5 保存图片不需要调接口,提示用户长按图片保存。
- 6、画板不能隐藏包括`v-if``v-show`、`display:none`、`opacity:0`,另外也不要把画板放在弹窗里。如果需要隐藏画板请设置 `custom-style="position: fixed; left: 200%"`
- 7、微信小程序真机调试请使用 **真机调试2.0**不支持1.0。
- 8、微信小程序打开调试时可以生但并闭无法生成时这种情况一般是没有在公众号配置download域名
- 9、HBX 3.4.5之前的版本不支持vue3
- 10、在微信开发工具上 canvas 层级最高无法zindex并不影响真机
- 11、请不要导入非uni_modules插件
- 12、关于QQ小程序 报 Propertyor method"toJSON"is not defined 请把基础库调到 1.50.3
- 13、支付宝小程序 IDE 不支持 生成图片 请以真机调试结果为准
- 14、返回值为字符串 `data:,` 大概是尺寸超过限制,设置 pixel-ratio="2"
- 华为手机 APP 上无法生成图片,请使用 HBX2.9.11++(已过时,忽略这条)
- IOS APP 请勿使用 HBX2.9.3.20201014 的版本!这个版本无法生成图片。(已过时,忽略这条)
- 苹果微信 7.0.20 存在闪退和图片无法 onload 为微信 bug已过时忽略这条
- 微信小程序 IOS 旧接口 如父级设置圆角子级也设会导致子级的失效为旧接口BUG。
- 微信小程序 安卓 旧接口 如使用图片必须加背景色为旧接口BUG。
- 微信小程序 安卓端 [图片可能在首次可以加载成功,再次加载会不触发任何事件](https://developers.weixin.qq.com/community/develop/doc/000ee2b8dacf4009337f51f4556800?highLine=canvas%25202d%2520createImage),临时解决方法是给图片加个时间戳
## 打赏
如果你觉得本插件,解决了你的问题,赠人玫瑰,手留余香。
![](https://testingcf.jsdelivr.net/gh/liangei/image@1.9/alipay.png)
![](https://testingcf.jsdelivr.net/gh/liangei/image@1.9/wpay.png)