├── src ├── app.scss ├── pages │ └── index │ │ ├── index.scss │ │ └── index.tsx ├── static │ ├── sky.jpg │ └── avatar.jpg ├── palette │ ├── text-example.js │ ├── shadow-example.js │ ├── image-example.js │ └── card.js ├── index.html ├── app.tsx └── component │ └── poster │ ├── lib │ ├── util.ts │ ├── gradient.js │ ├── downloader.js │ ├── pen.js │ └── qrcode.js │ └── index.tsx ├── .gitignore ├── .gitattributes ├── config ├── dev.js ├── prod.js └── index.js ├── .editorconfig ├── project.config.json ├── project.swan.json ├── .eslintrc ├── global.d.ts ├── tsconfig.json ├── package.json └── README.md /src/app.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/index/index.scss: -------------------------------------------------------------------------------- 1 | .save-button { 2 | margin-top: 50rpx; 3 | } -------------------------------------------------------------------------------- /src/static/sky.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duyanren/taro-painter/HEAD/src/static/sky.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | deploy_versions/ 3 | .temp/ 4 | .rn_temp/ 5 | node_modules/ 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /src/static/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duyanren/taro-painter/HEAD/src/static/avatar.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.scss linguist-language=Typescript 2 | *.css linguist-language=Typescript 3 | *.ts linguist-language=Typescript 4 | *.js linguist-language=Typescript 5 | -------------------------------------------------------------------------------- /config/dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | NODE_ENV: '"development"' 4 | }, 5 | defineConstants: { 6 | }, 7 | weapp: {}, 8 | h5: {} 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /project.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "miniprogramRoot": "./dist", 3 | "projectname": "taro-painter", 4 | "description": "基于taro封装painter海报组件", 5 | "appid": "wxea9c1d6d2a09f316", 6 | "setting": { 7 | "urlCheck": true, 8 | "es6": false, 9 | "postcss": false, 10 | "minified": false 11 | }, 12 | "compileType": "miniprogram" 13 | } 14 | -------------------------------------------------------------------------------- /project.swan.json: -------------------------------------------------------------------------------- 1 | { 2 | "developType": "normal", 3 | "editor": { "curPath": "", "expands": [], "markdownUploadTimeMap": {}, "paths": [], "recentlyFiles": [] }, 4 | "host": "baiduboxapp", 5 | "appid": "16292354", 6 | "setting": { "urlCheck": true }, 7 | "swan": { "baiduboxapp": { "swanJsVersion": "3.90.5", "extensionJsVersion": "1.4.0" } } 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["taro"], 3 | "rules": { 4 | "no-unused-vars": ["error", { "varsIgnorePattern": "Taro" }], 5 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx", ".tsx"] }] 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "useJSXTextNode": true, 13 | "project": "./tsconfig.json" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | NODE_ENV: '"production"' 4 | }, 5 | defineConstants: { 6 | }, 7 | weapp: {}, 8 | h5: { 9 | /** 10 | * 如果h5端编译后体积过大,可以使用webpack-bundle-analyzer插件对打包体积进行分析。 11 | * 参考代码如下: 12 | * webpackChain (chain) { 13 | * chain.plugin('analyzer') 14 | * .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, []) 15 | * } 16 | */ 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png"; 2 | declare module "*.gif"; 3 | declare module "*.jpg"; 4 | declare module "*.jpeg"; 5 | declare module "*.svg"; 6 | declare module "*.css"; 7 | declare module "*.less"; 8 | declare module "*.scss"; 9 | declare module "*.sass"; 10 | declare module "*.styl"; 11 | 12 | // @ts-ignore 13 | declare const process: { 14 | env: { 15 | TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq'; 16 | [key: string]: any; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/palette/text-example.js: -------------------------------------------------------------------------------- 1 | const text = '锄禾日当午汗滴禾下土谁知盘中餐粒粒皆辛苦'; 2 | export default class ImageExample { 3 | palette() { 4 | const views = []; 5 | let tmpText = ''; 6 | let index = 0; 7 | for (let i = 0; i < text.length; i++) { 8 | tmpText = `${tmpText}${text[i]}\n`; 9 | if (i % 5 === 4) { 10 | views.push({ 11 | type: 'text', 12 | text: tmpText, 13 | css: { 14 | right: `${50 + index}rpx`, 15 | top: '60rpx', 16 | fontSize: '40rpx', 17 | lineHeight: '50rpx', 18 | }, 19 | }); 20 | index += 50; 21 | tmpText = ''; 22 | } 23 | } 24 | return ({ 25 | width: '654rpx', 26 | height: '500rpx', 27 | background: '#eee', 28 | views: views, 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "commonjs", 5 | "removeComments": false, 6 | "preserveConstEnums": true, 7 | "moduleResolution": "node", 8 | "experimentalDecorators": true, 9 | "noImplicitAny": false, 10 | "allowSyntheticDefaultImports": true, 11 | "outDir": "lib", 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "strictNullChecks": true, 15 | "sourceMap": true, 16 | "baseUrl": ".", 17 | "rootDir": ".", 18 | "jsx": "preserve", 19 | "jsxFactory": "Taro.createElement", 20 | "allowJs": true, 21 | "resolveJsonModule": true, 22 | "typeRoots": [ 23 | "node_modules/@types", 24 | "global.d.ts" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "dist" 30 | ], 31 | "compileOnSave": false 32 | } 33 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import Taro, { Component, Config } from '@tarojs/taro' 2 | import Index from './pages/index' 3 | 4 | import './app.scss' 5 | 6 | // 如果需要在 h5 环境中开启 React Devtools 7 | // 取消以下注释: 8 | // if (process.env.NODE_ENV !== 'production' && process.env.TARO_ENV === 'h5') { 9 | // require('nerv-devtools') 10 | // } 11 | 12 | class App extends Component { 13 | 14 | /** 15 | * 指定config的类型声明为: Taro.Config 16 | * 17 | * 由于 typescript 对于 object 类型推导只能推出 Key 的基本类型 18 | * 对于像 navigationBarTextStyle: 'black' 这样的推导出的类型是 string 19 | * 提示和声明 navigationBarTextStyle: 'black' | 'white' 类型冲突, 需要显示声明类型 20 | */ 21 | config: Config = { 22 | pages: [ 23 | 'pages/index/index' 24 | ], 25 | window: { 26 | backgroundTextStyle: 'light', 27 | navigationBarBackgroundColor: '#fff', 28 | navigationBarTitleText: 'WeChat', 29 | navigationBarTextStyle: 'black' 30 | } 31 | } 32 | 33 | componentDidMount () {} 34 | 35 | componentDidShow () {} 36 | 37 | componentDidHide () {} 38 | 39 | componentDidCatchError () {} 40 | 41 | // 在 App 类中的 render() 函数没有实际作用 42 | // 请勿修改此函数 43 | render () { 44 | return ( 45 | 46 | ) 47 | } 48 | } 49 | 50 | Taro.render(, document.getElementById('app')) 51 | -------------------------------------------------------------------------------- /src/palette/shadow-example.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: dyr 3 | * @Description: file content 4 | * @Date: 2019-11-06 18:48:23 5 | * @LastEditors: dyr 6 | * @LastEditTime: 2019-11-15 16:01:19 7 | */ 8 | export default class ShadowExample { 9 | palette() { 10 | return ({ 11 | width: '654rpx', 12 | height: '400rpx', 13 | background: '#eee', 14 | views: [{ 15 | type: 'image', 16 | url: '/static/sky.jpg', 17 | css: { 18 | shadow: '10rpx 10rpx 5rpx #888888', 19 | }, 20 | }, 21 | { 22 | type: 'rect', 23 | css: { 24 | width: '250rpx', 25 | height: '150rpx', 26 | right: '50rpx', 27 | top: '60rpx', 28 | shadow: '-10rpx 10rpx 5rpx #888888', 29 | color: 'linear-gradient(-135deg, #fedcba 0%, rgba(18, 52, 86, 1) 20%, #987 80%)', 30 | }, 31 | }, 32 | { 33 | type: 'qrcode', 34 | content: 'https://github.com/Kujiale-Mobile/Painter', 35 | css: { 36 | top: '230rpx', 37 | width: '120rpx', 38 | height: '120rpx', 39 | shadow: '10rpx 10rpx 5rpx #888888', 40 | }, 41 | }, 42 | { 43 | type: 'text', 44 | text: "shadow: '10rpx 10rpx 5rpx #888888'", 45 | css: { 46 | left: '180rpx', 47 | fontSize: '30rpx', 48 | shadow: '10rpx 10rpx 5rpx #888888', 49 | top: '290rpx', 50 | }, 51 | }, 52 | ], 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/pages/index/index.tsx: -------------------------------------------------------------------------------- 1 | import Taro, { Component, Config } from "@tarojs/taro"; 2 | import { View, Button } from "@tarojs/components"; 3 | 4 | import Card from "../../palette/card"; 5 | import Poster from "../../component/poster/index"; 6 | import "./index.scss"; 7 | 8 | interface IProps {} 9 | interface IState { 10 | imagePath: string; 11 | template: object; 12 | } 13 | 14 | export default class Index extends Component { 15 | /** 16 | * 指定config的类型声明为: Taro.Config 17 | * 18 | * 由于 typescript 对于 object 类型推导只能推出 Key 的基本类型 19 | * 对于像 navigationBarTextStyle: 'black' 这样的推导出的类型是 string 20 | * 提示和声明 navigationBarTextStyle: 'black' | 'white' 类型冲突, 需要显示声明类型 21 | */ 22 | config: Config = { 23 | navigationBarTitleText: "painter demo" 24 | }; 25 | state: IState = { 26 | template: new Card().palette(), 27 | imagePath: "" 28 | }; 29 | // 图片生成成功回调 30 | onImgOK = e => { 31 | this.setState({ 32 | imagePath: e.path 33 | }); 34 | }; 35 | // 图片生成失败回调 36 | onImgErr = error => { 37 | console.log( 38 | "%cerror: ", 39 | "color: MidnightBlue; background: Aquamarine; font-size: 20px;", 40 | error 41 | ); 42 | }; 43 | painterRef: Poster | null; 44 | 45 | // 保存图片到本地相册 46 | saveImage() { 47 | this.painterRef && this.painterRef.saveImage() 48 | } 49 | 50 | componentWillMount() {} 51 | 52 | componentWillUnmount() {} 53 | 54 | componentDidShow() {} 55 | 56 | componentDidHide() {} 57 | 58 | render() { 59 | return ( 60 | 61 | this.painterRef = node} 67 | /> 68 | 71 | 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/palette/image-example.js: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: dyr 3 | * @Description: file content 4 | * @Date: 2019-11-06 18:48:23 5 | * @LastEditors: dyr 6 | * @LastEditTime: 2019-11-15 16:01:10 7 | */ 8 | export default class ImageExample { 9 | palette() { 10 | return ({ 11 | width: '654rpx', 12 | height: '1000rpx', 13 | background: '#eee', 14 | views: [ 15 | { 16 | type: 'image', 17 | url: '/static/sky.jpg', 18 | }, 19 | { 20 | type: 'text', 21 | text: '未设置height、width时', 22 | css: { 23 | right: '0rpx', 24 | top: '60rpx', 25 | fontSize: '30rpx', 26 | }, 27 | }, 28 | { 29 | type: 'image', 30 | url: '/static/sky.jpg', 31 | css: { 32 | width: '200rpx', 33 | height: '200rpx', 34 | top: '230rpx', 35 | }, 36 | }, 37 | { 38 | type: 'text', 39 | text: "mode: 'aspectFill' 或 无", 40 | css: { 41 | left: '210rpx', 42 | fontSize: '30rpx', 43 | top: '290rpx', 44 | }, 45 | }, 46 | { 47 | type: 'image', 48 | url: '/static/sky.jpg', 49 | css: { 50 | width: '200rpx', 51 | height: '200rpx', 52 | mode: 'scaleToFill', 53 | top: '500rpx', 54 | }, 55 | }, 56 | { 57 | type: 'text', 58 | text: "mode: 'scaleToFill'", 59 | css: { 60 | left: '210rpx', 61 | top: '560rpx', 62 | fontSize: '30rpx', 63 | }, 64 | }, 65 | { 66 | type: 'image', 67 | url: '/static/sky.jpg', 68 | css: { 69 | width: '200rpx', 70 | height: 'auto', 71 | top: '750rpx', 72 | }, 73 | }, 74 | { 75 | type: 'text', 76 | text: '设置height为auto', 77 | css: { 78 | left: '210rpx', 79 | top: '780rpx', 80 | fontSize: '30rpx', 81 | }, 82 | }, 83 | ], 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taro-painter", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "基于taro封装painter海报组件", 6 | "templateInfo": { 7 | "name": "default", 8 | "typescript": true, 9 | "css": "sass" 10 | }, 11 | "scripts": { 12 | "build:weapp": "taro build --type weapp", 13 | "build:swan": "taro build --type swan", 14 | "build:alipay": "taro build --type alipay", 15 | "build:tt": "taro build --type tt", 16 | "build:h5": "taro build --type h5", 17 | "build:rn": "taro build --type rn", 18 | "build:qq": "taro build --type qq", 19 | "build:quickapp": "taro build --type quickapp", 20 | "dev:weapp": "npm run build:weapp -- --watch", 21 | "dev:swan": "npm run build:swan -- --watch", 22 | "dev:alipay": "npm run build:alipay -- --watch", 23 | "dev:tt": "npm run build:tt -- --watch", 24 | "dev:h5": "npm run build:h5 -- --watch", 25 | "dev:rn": "npm run build:rn -- --watch", 26 | "dev:qq": "npm run build:qq -- --watch", 27 | "dev:quickapp": "npm run build:quickapp -- --watch" 28 | }, 29 | "author": "", 30 | "license": "MIT", 31 | "dependencies": { 32 | "@tarojs/components": "1.3.22", 33 | "@tarojs/router": "1.3.22", 34 | "@tarojs/taro": "1.3.22", 35 | "@tarojs/taro-alipay": "1.3.22", 36 | "@tarojs/taro-h5": "1.3.22", 37 | "@tarojs/taro-qq": "1.3.22", 38 | "@tarojs/taro-quickapp": "1.3.22", 39 | "@tarojs/taro-swan": "1.3.22", 40 | "@tarojs/taro-tt": "1.3.22", 41 | "@tarojs/taro-weapp": "1.3.22", 42 | "nervjs": "^1.5.0", 43 | "nerv-devtools": "^1.5.0" 44 | }, 45 | "devDependencies": { 46 | "@types/react": "^16.4.6", 47 | "@types/webpack-env": "^1.13.6", 48 | "@tarojs/plugin-babel": "1.3.22", 49 | "@tarojs/plugin-csso": "1.3.22", 50 | "@tarojs/plugin-sass": "1.3.22", 51 | "@tarojs/plugin-uglifyjs": "1.3.22", 52 | "@tarojs/webpack-runner": "1.3.22", 53 | "babel-plugin-transform-class-properties": "^6.24.1", 54 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 55 | "babel-plugin-transform-jsx-stylesheet": "^0.6.5", 56 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 57 | "babel-preset-env": "^1.6.1", 58 | "babel-eslint": "^8.2.3", 59 | "eslint": "^5.16.0", 60 | "eslint-config-taro": "1.3.22", 61 | "eslint-plugin-react": "^7.8.2", 62 | "eslint-plugin-react-hooks": "^1.6.1", 63 | "eslint-plugin-import": "^2.12.0", 64 | "stylelint": "9.3.0", 65 | "stylelint-config-taro-rn": "1.3.22", 66 | "stylelint-taro-rn": "1.3.22", 67 | "eslint-plugin-taro": "1.3.22", 68 | "@typescript-eslint/parser": "^1.6.0", 69 | "typescript": "^3.0.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | projectName: 'taro-painter', 3 | date: '2019-11-15', 4 | designWidth: 750, 5 | deviceRatio: { 6 | '640': 2.34 / 2, 7 | '750': 1, 8 | '828': 1.81 / 2 9 | }, 10 | sourceRoot: 'src', 11 | outputRoot: 'dist', 12 | plugins: { 13 | babel: { 14 | sourceMap: true, 15 | presets: [ 16 | ['env', { 17 | modules: false 18 | }] 19 | ], 20 | plugins: [ 21 | 'transform-decorators-legacy', 22 | 'transform-class-properties', 23 | 'transform-object-rest-spread' 24 | ] 25 | } 26 | }, 27 | defineConstants: { 28 | }, 29 | copy: { 30 | patterns: [ 31 | { 32 | from: "src/static/", 33 | to: `dist/static/` 34 | }, 35 | ], 36 | options: { 37 | } 38 | }, 39 | weapp: { 40 | module: { 41 | postcss: { 42 | autoprefixer: { 43 | enable: true, 44 | config: { 45 | browsers: [ 46 | 'last 3 versions', 47 | 'Android >= 4.1', 48 | 'ios >= 8' 49 | ] 50 | } 51 | }, 52 | pxtransform: { 53 | enable: true, 54 | config: { 55 | 56 | } 57 | }, 58 | url: { 59 | enable: true, 60 | config: { 61 | limit: 10240 // 设定转换尺寸上限 62 | } 63 | }, 64 | cssModules: { 65 | enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true 66 | config: { 67 | namingPattern: 'module', // 转换模式,取值为 global/module 68 | generateScopedName: '[name]__[local]___[hash:base64:5]' 69 | } 70 | } 71 | } 72 | } 73 | }, 74 | h5: { 75 | publicPath: '/', 76 | staticDirectory: 'static', 77 | module: { 78 | postcss: { 79 | autoprefixer: { 80 | enable: true, 81 | config: { 82 | browsers: [ 83 | 'last 3 versions', 84 | 'Android >= 4.1', 85 | 'ios >= 8' 86 | ] 87 | } 88 | }, 89 | cssModules: { 90 | enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true 91 | config: { 92 | namingPattern: 'module', // 转换模式,取值为 global/module 93 | generateScopedName: '[name]__[local]___[hash:base64:5]' 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | module.exports = function (merge) { 102 | if (process.env.NODE_ENV === 'development') { 103 | return merge({}, config, require('./dev')) 104 | } 105 | return merge({}, config, require('./prod')) 106 | } 107 | -------------------------------------------------------------------------------- /src/component/poster/lib/util.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * @Author: dyr 3 | * @Description: 通用方法 4 | * @Date: 2019-11-06 18:48:23 5 | * @LastEditors: dyr 6 | * @LastEditTime: 2019-11-15 20:28:00 7 | */ 8 | import Taro from "@tarojs/taro"; 9 | 10 | export const isValidUrl = (url) => { 11 | return /(ht|f)tp(s?):\/\/([^ \\/]*\.)+[^ \\/]*(:[0-9]+)?\/?/.test(url); 12 | } 13 | 14 | /** 15 | * 深度对比两个对象是否一致 16 | * from: https://github.com/epoberezkin/fast-deep-equal 17 | * @param {Object} a 对象a 18 | * @param {Object} b 对象b 19 | * @return {Boolean} 是否相同 20 | */ 21 | /* eslint-disable */ 22 | export const equal = (a, b) => { 23 | if (a === b) return true; 24 | 25 | if (a && b && typeof a == "object" && typeof b == "object") { 26 | var arrA = Array.isArray(a), 27 | arrB = Array.isArray(b), 28 | i, 29 | length, 30 | key; 31 | 32 | if (arrA && arrB) { 33 | length = a.length; 34 | if (length != b.length) return false; 35 | for (i = length; i-- !== 0; ) if (!equal(a[i], b[i])) return false; 36 | return true; 37 | } 38 | 39 | if (arrA != arrB) return false; 40 | 41 | var dateA = a instanceof Date, 42 | dateB = b instanceof Date; 43 | if (dateA != dateB) return false; 44 | if (dateA && dateB) return a.getTime() == b.getTime(); 45 | 46 | var regexpA = a instanceof RegExp, 47 | regexpB = b instanceof RegExp; 48 | if (regexpA != regexpB) return false; 49 | if (regexpA && regexpB) return a.toString() == b.toString(); 50 | 51 | var keys = Object.keys(a); 52 | length = keys.length; 53 | 54 | if (length !== Object.keys(b).length) return false; 55 | 56 | for (i = length; i-- !== 0; ) 57 | if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; 58 | 59 | for (i = length; i-- !== 0; ) { 60 | key = keys[i]; 61 | if (!equal(a[key], b[key])) return false; 62 | } 63 | 64 | return true; 65 | } 66 | 67 | return a !== a && b !== b; 68 | } 69 | /** 70 | * @description: 获取设备权限 71 | * @param {string} scope 需要获取权限的 scope 72 | * @return: Promise 73 | */ 74 | export const getAuthSetting = (scope: string): Promise => { 75 | return new Promise(resolve => { 76 | return Taro.authorize({ 77 | scope 78 | }) 79 | .then(() => { 80 | resolve(true); 81 | }) 82 | .catch(() => { 83 | resolve(false); 84 | }); 85 | }); 86 | }; 87 | 88 | /** 89 | * @description: 保存图片到系统相册 90 | * @param {string} imgUrl 图片url 91 | * @return: Promise 92 | */ 93 | export const saveImageToPhotosAlbum = (imgUrl: string): Promise => { 94 | return new Promise((resolve, rejecet) => { 95 | return Taro.saveImageToPhotosAlbum({ filePath: imgUrl }) 96 | .then(() => { 97 | resolve(true); 98 | }) 99 | .catch(() => { 100 | rejecet(false); 101 | }); 102 | }); 103 | }; 104 | -------------------------------------------------------------------------------- /src/component/poster/lib/gradient.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // 当ctx传入当前文件,const grd = ctx.createCircularGradient() 和 3 | // const grd = this.ctx.createLinearGradient() 无效,因此只能分开处理 4 | // 先分析,在外部创建grd,再传入使用就可以 5 | 6 | !(function () { 7 | 8 | var api = { 9 | isGradient: function(bg) { 10 | if (bg && (bg.startsWith('linear') || bg.startsWith('radial'))) { 11 | return true; 12 | } 13 | return false; 14 | }, 15 | 16 | doGradient: function(bg, width, height, ctx) { 17 | if (bg.startsWith('linear')) { 18 | linearEffect(width, height, bg, ctx); 19 | } else if (bg.startsWith('radial')) { 20 | radialEffect(width, height, bg, ctx); 21 | } 22 | }, 23 | } 24 | 25 | function analizeGrad(string) { 26 | const colorPercents = string.substring(0, string.length - 1).split("%,"); 27 | const colors = []; 28 | const percents = []; 29 | for (let colorPercent of colorPercents) { 30 | colors.push(colorPercent.substring(0, colorPercent.lastIndexOf(" ")).trim()); 31 | percents.push(colorPercent.substring(colorPercent.lastIndexOf(" "), colorPercent.length) / 100); 32 | } 33 | return {colors: colors, percents: percents}; 34 | } 35 | 36 | function radialEffect(width, height, bg, ctx) { 37 | const colorPer = analizeGrad(bg.match(/radial-gradient\((.+)\)/)[1]); 38 | const grd = ctx.createCircularGradient(0, 0, width < height ? height / 2 : width / 2); 39 | for (let i = 0; i < colorPer.colors.length; i++) { 40 | grd.addColorStop(colorPer.percents[i], colorPer.colors[i]); 41 | } 42 | ctx.fillStyle = grd; 43 | //ctx.fillRect(-(width / 2), -(height / 2), width, height); 44 | } 45 | 46 | function analizeLinear(bg, width, height) { 47 | const direction = bg.match(/([-]?\d{1,3})deg/); 48 | const dir = direction && direction[1] ? parseFloat(direction[1]) : 0; 49 | let coordinate; 50 | switch (dir) { 51 | case 0: coordinate = [0, -height / 2, 0, height / 2]; break; 52 | case 90: coordinate = [width / 2, 0, -width / 2, 0]; break; 53 | case -90: coordinate = [-width / 2, 0, width / 2, 0]; break; 54 | case 180: coordinate = [0, height / 2, 0, -height / 2]; break; 55 | case -180: coordinate = [0, -height / 2, 0, height / 2]; break; 56 | default: 57 | let x1 = 0; 58 | let y1 = 0; 59 | let x2 = 0; 60 | let y2 = 0; 61 | if (direction[1] > 0 && direction[1] < 90) { 62 | x1 = (width / 2) - ((width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (90 - direction[1]) * Math.PI * 2 / 360) / 2; 63 | y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1; 64 | x2 = -x1; 65 | y1 = -y2; 66 | } else if (direction[1] > -180 && direction[1] < -90) { 67 | x1 = -(width / 2) + ((width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (90 - direction[1]) * Math.PI * 2 / 360) / 2; 68 | y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1; 69 | x2 = -x1; 70 | y1 = -y2; 71 | } else if (direction[1] > 90 && direction[1] < 180) { 72 | x1 = (width / 2) + (-(width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (90 - direction[1]) * Math.PI * 2 / 360) / 2; 73 | y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1; 74 | x2 = -x1; 75 | y1 = -y2; 76 | } else { 77 | x1 = -(width / 2) - (-(width / 2) * Math.tan((90 - direction[1]) * Math.PI * 2 / 360) - height / 2) * Math.sin(2 * (90 - direction[1]) * Math.PI * 2 / 360) / 2; 78 | y2 = Math.tan((90 - direction[1]) * Math.PI * 2 / 360) * x1; 79 | x2 = -x1; 80 | y1 = -y2; 81 | } 82 | coordinate = [x1, y1, x2, y2]; 83 | break; 84 | } 85 | return coordinate; 86 | } 87 | 88 | function linearEffect(width, height, bg, ctx) { 89 | const param = analizeLinear(bg, width, height); 90 | const grd = ctx.createLinearGradient(param[0], param[1], param[2], param[3]); 91 | const content = bg.match(/linear-gradient\((.+)\)/)[1]; 92 | const colorPer = analizeGrad(content.substring(content.indexOf(',') + 1)); 93 | for (let i = 0; i < colorPer.colors.length; i++) { 94 | grd.addColorStop(colorPer.percents[i], colorPer.colors[i]); 95 | } 96 | ctx.fillStyle = grd 97 | //ctx.fillRect(-(width / 2), -(height / 2), width, height); 98 | } 99 | 100 | module.exports = { api } 101 | 102 | })(); 103 | -------------------------------------------------------------------------------- /src/palette/card.js: -------------------------------------------------------------------------------- 1 | export default class LastMayday { 2 | palette() { 3 | return ({ 4 | width: '654rpx', 5 | height: '1000rpx', 6 | background: '#eee', 7 | views: [ 8 | _textDecoration('overline', 0), 9 | _textDecoration('underline', 1), 10 | _textDecoration('line-through', 2), 11 | _textDecoration('overline underline line-through', 3, 'red'), 12 | { 13 | type: 'rect', 14 | css: { 15 | width: '200rpx', 16 | right: '20rpx', 17 | top: '30rpx', 18 | height: '100rpx', 19 | borderRadius: '100rpx', 20 | shadow: '10rpx 10rpx 5rpx #888888', 21 | color: 'linear-gradient(-135deg, #fedcba 0%, rgba(18, 52, 86, 1) 20%, #987 80%)', 22 | }, 23 | }, 24 | { 25 | id: 'my-text-id', 26 | type: 'text', 27 | text: "fontWeight: 'bold'", 28 | css: [{ 29 | top: `${startTop + 4 * gapSize}rpx`, 30 | shadow: '10rpx 10rpx 5rpx #888888', 31 | fontWeight: 'bold', 32 | }, common], 33 | }, 34 | { 35 | type: 'rect', 36 | css: { 37 | width: '20rpx', 38 | height: '20rpx', 39 | color: 'red', 40 | left: [`${startTop}rpx`, 'my-text-id'], 41 | top: `${startTop + 4 * gapSize + 15}rpx`, 42 | }, 43 | }, 44 | { 45 | id: 'text-id-2', 46 | type: 'text', 47 | text: '我是把width设置为400rpx后,我就换行了xx行了', 48 | css: [{ 49 | top: `${startTop + 5 * gapSize}rpx`, 50 | align: 'center', 51 | width: '400rpx', 52 | background: '#538e60', 53 | textAlign: 'center', 54 | padding: '10rpx', 55 | }, common, { left: '300rpx' }], 56 | }, 57 | { 58 | type: 'rect', 59 | css: { 60 | width: '20rpx', 61 | height: '20rpx', 62 | color: 'red', 63 | left: '200rpx', 64 | top: [`${startTop + 5 * gapSize}rpx`, 'text-id-2'], 65 | }, 66 | }, 67 | { 68 | type: 'text', 69 | text: '我设置了maxLines为1,看看会产生什么效果', 70 | css: [{ 71 | top: `${startTop + 7 * gapSize}rpx`, 72 | width: '500rpx', 73 | maxLines: 1, 74 | }, common], 75 | }, 76 | _image(0), 77 | _des(0, '普通'), 78 | _image(1, 30), 79 | _des(1, 'rotate: 30'), 80 | _image(2, 30, '20rpx'), 81 | _des(2, 'borderRadius: 30rpx'), 82 | _image(3, 0, '60rpx'), 83 | _des(3, '圆形'), 84 | { 85 | type: 'image', 86 | url: '/static/avatar.jpg', 87 | css: { 88 | bottom: '40rpx', 89 | left: '40rpx', 90 | borderRadius: '50rpx', 91 | borderWidth: '10rpx', 92 | borderColor: 'yellow', 93 | width: '100rpx', 94 | height: '100rpx', 95 | }, 96 | }, 97 | { 98 | type: 'qrcode', 99 | content: 'https://github.com/Kujiale-Mobile/Painter', 100 | css: { 101 | bottom: '40rpx', 102 | left: '180rpx', 103 | color: 'red', 104 | borderWidth: '10rpx', 105 | borderColor: 'blue', 106 | width: '120rpx', 107 | height: '120rpx', 108 | }, 109 | }, 110 | { 111 | type: 'rect', 112 | css: { 113 | bottom: '40rpx', 114 | right: '40rpx', 115 | color: 'radial-gradient(rgba(0, 0, 0, 0) 5%, #0ff 15%, #f0f 60%)', 116 | borderRadius: '20rpx', 117 | borderWidth: '10rpx', 118 | width: '120rpx', 119 | height: '120rpx', 120 | }, 121 | }, 122 | { 123 | type: 'text', 124 | text: 'borderWidth', 125 | css: { 126 | bottom: '40rpx', 127 | right: '200rpx', 128 | color: 'green', 129 | borderWidth: '2rpx', 130 | }, 131 | }, 132 | ], 133 | }); 134 | } 135 | } 136 | 137 | const startTop = 50; 138 | const startLeft = 20; 139 | const gapSize = 70; 140 | const common = { 141 | left: `${startLeft}rpx`, 142 | fontSize: '40rpx', 143 | }; 144 | 145 | function _textDecoration(decoration, index, color) { 146 | return ({ 147 | type: 'text', 148 | text: decoration, 149 | css: [{ 150 | top: `${startTop + index * gapSize}rpx`, 151 | color: color, 152 | textDecoration: decoration, 153 | }, common], 154 | }); 155 | } 156 | 157 | function _image(index, rotate, borderRadius) { 158 | return ( 159 | { 160 | type: 'image', 161 | url: 'https://statics-web.iqiyi.com/garden/miracle/head-icon.png', 162 | css: { 163 | top: `${startTop + 8.5 * gapSize}rpx`, 164 | left: `${startLeft + 160 * index}rpx`, 165 | width: '120rpx', 166 | height: '120rpx', 167 | shadow: '10rpx 10rpx 5rpx #888888', 168 | rotate: rotate, 169 | borderRadius: borderRadius, 170 | }, 171 | } 172 | ); 173 | } 174 | 175 | function _des(index, content) { 176 | const des = { 177 | type: 'text', 178 | text: content, 179 | css: { 180 | fontSize: '22rpx', 181 | top: `${startTop + 8.5 * gapSize + 140}rpx`, 182 | }, 183 | }; 184 | if (index === 3) { 185 | des.css.right = '60rpx'; 186 | } else { 187 | des.css.left = `${startLeft + 120 * index + 30}rpx`; 188 | } 189 | return des; 190 | } 191 | -------------------------------------------------------------------------------- /src/component/poster/lib/downloader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * LRU 文件存储,使用该 downloader 可以让下载的文件存储在本地,下次进入小程序后可以直接使用 3 | * 详细设计文档可查看 https://juejin.im/post/5b42d3ede51d4519277b6ce3 4 | */ 5 | import Taro from "@tarojs/taro"; 6 | import {isValidUrl} from './util' 7 | const SAVED_FILES_KEY = "savedFiles"; 8 | const KEY_TOTAL_SIZE = "totalSize"; 9 | const KEY_PATH = "path"; 10 | const KEY_TIME = "time"; 11 | const KEY_SIZE = "size"; 12 | 13 | // 可存储总共为 6M,目前小程序可允许的最大本地存储为 10M 14 | let MAX_SPACE_IN_B = 6 * 1024 * 1024; 15 | let savedFiles = {}; 16 | 17 | export default class Dowloader { 18 | constructor() { 19 | // app 如果设置了最大存储空间,则使用 app 中的 20 | if (Taro.getApp().PAINTER_MAX_LRU_SPACE) { 21 | MAX_SPACE_IN_B = Taro.getApp().PAINTER_MAX_LRU_SPACE; 22 | } 23 | try { 24 | const res = Taro.getStorageSync(SAVED_FILES_KEY); 25 | if (res) { 26 | savedFiles = res; 27 | } 28 | } catch (error) {} 29 | } 30 | 31 | /** 32 | * 下载文件,会用 lru 方式来缓存文件到本地 33 | * @param {String} url 文件的 url 34 | */ 35 | download(url) { 36 | return new Promise((resolve, reject) => { 37 | if (!(url && isValidUrl(url))) { 38 | resolve(url); 39 | return; 40 | } 41 | const file = getFile(url); 42 | 43 | if (file) { 44 | // 检查文件是否正常,不正常需要重新下载 45 | Taro.getSavedFileInfo({ 46 | filePath: file[KEY_PATH], 47 | success: res => { 48 | resolve(file[KEY_PATH]); 49 | }, 50 | fail: error => { 51 | console.error( 52 | `the file is broken, redownload it, ${JSON.stringify(error)}` 53 | ); 54 | downloadFile(url).then( 55 | path => { 56 | resolve(path); 57 | }, 58 | () => { 59 | reject(); 60 | } 61 | ); 62 | } 63 | }); 64 | } else { 65 | downloadFile(url).then( 66 | path => { 67 | resolve(path); 68 | }, 69 | () => { 70 | reject(); 71 | } 72 | ); 73 | } 74 | }); 75 | } 76 | } 77 | 78 | function downloadFile(url) { 79 | return new Promise((resolve, reject) => { 80 | Taro.downloadFile({ 81 | url: url, 82 | success: function(res) { 83 | if (res.statusCode !== 200) { 84 | console.error(`downloadFile ${url} failed res.statusCode is not 200`); 85 | reject(); 86 | return; 87 | } 88 | const { tempFilePath } = res; 89 | Taro.getFileInfo({ 90 | filePath: tempFilePath, 91 | success: tmpRes => { 92 | const newFileSize = tmpRes.size; 93 | doLru(newFileSize).then( 94 | () => { 95 | saveFile(url, newFileSize, tempFilePath).then(filePath => { 96 | resolve(filePath); 97 | }); 98 | }, 99 | () => { 100 | resolve(tempFilePath); 101 | } 102 | ); 103 | }, 104 | fail: error => { 105 | // 文件大小信息获取失败,则此文件也不要进行存储 106 | console.error( 107 | `getFileInfo ${res.tempFilePath} failed, ${JSON.stringify(error)}` 108 | ); 109 | resolve(res.tempFilePath); 110 | } 111 | }); 112 | }, 113 | fail: function(error) { 114 | console.error(`downloadFile failed, ${JSON.stringify(error)} `); 115 | reject(); 116 | } 117 | }); 118 | }); 119 | } 120 | 121 | function saveFile(key, newFileSize, tempFilePath) { 122 | return new Promise((resolve, reject) => { 123 | Taro.saveFile({ 124 | tempFilePath: tempFilePath, 125 | success: fileRes => { 126 | const totalSize = savedFiles[KEY_TOTAL_SIZE] 127 | ? savedFiles[KEY_TOTAL_SIZE] 128 | : 0; 129 | savedFiles[key] = {}; 130 | savedFiles[key][KEY_PATH] = fileRes.savedFilePath; 131 | savedFiles[key][KEY_TIME] = new Date().getTime(); 132 | savedFiles[key][KEY_SIZE] = newFileSize; 133 | savedFiles["totalSize"] = newFileSize + totalSize; 134 | Taro.setStorage({ 135 | key: SAVED_FILES_KEY, 136 | data: savedFiles 137 | }); 138 | resolve(fileRes.savedFilePath); 139 | }, 140 | fail: error => { 141 | console.error( 142 | `saveFile ${key} failed, then we delete all files, ${JSON.stringify( 143 | error 144 | )}` 145 | ); 146 | // 由于 saveFile 成功后,res.tempFilePath 处的文件会被移除,所以在存储未成功时,我们还是继续使用临时文件 147 | resolve(tempFilePath); 148 | // 如果出现错误,就直接情况本地的所有文件,因为你不知道是不是因为哪次lru的某个文件未删除成功 149 | reset(); 150 | } 151 | }); 152 | }); 153 | } 154 | 155 | /** 156 | * 清空所有下载相关内容 157 | */ 158 | function reset() { 159 | Taro.removeStorage({ 160 | key: SAVED_FILES_KEY, 161 | success: () => { 162 | Taro.getSavedFileList({ 163 | success: listRes => { 164 | removeFiles(listRes.fileList); 165 | }, 166 | fail: getError => { 167 | console.error(`getSavedFileList failed, ${JSON.stringify(getError)}`); 168 | } 169 | }); 170 | } 171 | }); 172 | } 173 | 174 | function doLru(size) { 175 | return new Promise((resolve, reject) => { 176 | let totalSize = savedFiles[KEY_TOTAL_SIZE] ? savedFiles[KEY_TOTAL_SIZE] : 0; 177 | 178 | if (size + totalSize <= MAX_SPACE_IN_B) { 179 | resolve(); 180 | return; 181 | } 182 | // 如果加上新文件后大小超过最大限制,则进行 lru 183 | const pathsShouldDelete = []; 184 | // 按照最后一次的访问时间,从小到大排序 185 | const allFiles = JSON.parse(JSON.stringify(savedFiles)); 186 | delete allFiles[KEY_TOTAL_SIZE]; 187 | const sortedKeys = Object.keys(allFiles).sort((a, b) => { 188 | return allFiles[a][KEY_TIME] - allFiles[b][KEY_TIME]; 189 | }); 190 | 191 | for (const sortedKey of sortedKeys) { 192 | totalSize -= savedFiles[sortedKey].size; 193 | pathsShouldDelete.push(savedFiles[sortedKey][KEY_PATH]); 194 | delete savedFiles[sortedKey]; 195 | if (totalSize + size < MAX_SPACE_IN_B) { 196 | break; 197 | } 198 | } 199 | 200 | savedFiles["totalSize"] = totalSize; 201 | 202 | Taro.setStorage({ 203 | key: SAVED_FILES_KEY, 204 | data: savedFiles, 205 | success: () => { 206 | // 保证 storage 中不会存在不存在的文件数据 207 | if (pathsShouldDelete.length > 0) { 208 | removeFiles(pathsShouldDelete); 209 | } 210 | resolve(); 211 | }, 212 | fail: error => { 213 | console.error(`doLru setStorage failed, ${JSON.stringify(error)}`); 214 | reject(); 215 | } 216 | }); 217 | }); 218 | } 219 | 220 | function removeFiles(pathsShouldDelete) { 221 | for (const pathDel of pathsShouldDelete) { 222 | let delPath = pathDel; 223 | if (typeof pathDel === "object") { 224 | delPath = pathDel.filePath; 225 | } 226 | Taro.removeSavedFile({ 227 | filePath: delPath, 228 | fail: error => { 229 | console.error( 230 | `removeSavedFile ${pathDel} failed, ${JSON.stringify(error)}` 231 | ); 232 | } 233 | }); 234 | } 235 | } 236 | 237 | function getFile(key) { 238 | if (!savedFiles[key]) { 239 | return; 240 | } 241 | savedFiles[key]["time"] = new Date().getTime(); 242 | Taro.setStorage({ 243 | key: SAVED_FILES_KEY, 244 | data: savedFiles 245 | }); 246 | return savedFiles[key]; 247 | } 248 | -------------------------------------------------------------------------------- /src/component/poster/index.tsx: -------------------------------------------------------------------------------- 1 | import Taro, { Component } from "@tarojs/taro"; 2 | import { Canvas } from "@tarojs/components"; 3 | 4 | import Pen from "./lib/pen"; 5 | import Downloader from "./lib/downloader"; 6 | import { getAuthSetting, saveImageToPhotosAlbum, equal } from "./lib/util"; 7 | const downloader = new Downloader(); 8 | 9 | // 最大尝试的绘制次数 10 | const MAX_PAINT_COUNT = 5; 11 | 12 | interface IProps { 13 | customStyle: string; // canvas自定义样式 14 | palette: object; // painter模板 15 | widthPixels: number; // 像素宽度 16 | dirty: boolean; // 启用脏检查,默认 false 17 | onImgErr: Function; // 图片失败回调 18 | onImgOK: Function; // 图片成功回调 19 | } 20 | 21 | interface IState { 22 | painterStyle: string; // canvas 宽度+高度样式 23 | } 24 | 25 | export default class QyPoster extends Component { 26 | static defaultProps = { 27 | customStyle: "", 28 | palette: {}, 29 | widthPixels: 0, 30 | dirty: false, 31 | onImgErr: () => null, 32 | onImgOK: () => null 33 | }; 34 | 35 | canvasId: string = "k-canvas"; // canvas-id 36 | 37 | filePath: string = ''; // 生成的文件路径 38 | 39 | state: IState = { 40 | painterStyle: "" 41 | }; 42 | 43 | canvasWidthInPx: number = 0; // width to px 44 | canvasHeightInPx: number = 0; // height to px 45 | paintCount: number = 0; // 绘制次数 46 | /** 47 | * 判断一个 object 是否为空 48 | * @param {object} object 49 | */ 50 | isEmpty(object) { 51 | for (const _i in object) { 52 | return false; 53 | } 54 | return true; 55 | } 56 | 57 | isNeedRefresh = (newVal, oldVal) => { 58 | if ( 59 | !newVal || 60 | this.isEmpty(newVal) || 61 | (this.props.dirty && equal(newVal, oldVal)) 62 | ) { 63 | return false; 64 | } 65 | return true; 66 | }; 67 | 68 | setStringPrototype = (screenK, scale) => { 69 | /** 70 | * 是否支持负数 71 | * @param {Boolean} minus 是否支持负数 72 | */ 73 | //@ts-ignore 74 | String.prototype.toPx = function toPx(minus) { 75 | let reg; 76 | if (minus) { 77 | reg = /^-?[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g; 78 | } else { 79 | reg = /^[0-9]+([.]{1}[0-9]+){0,1}(rpx|px)$/g; 80 | } 81 | const results = reg.exec(this); 82 | if (!this || !results) { 83 | console.error(`The size: ${this} is illegal`); 84 | return 0; 85 | } 86 | const unit = results[2]; 87 | const value = parseFloat(this); 88 | 89 | let res = 0; 90 | if (unit === "rpx") { 91 | res = Math.round(value * screenK * (scale || 1)); 92 | } else if (unit === "px") { 93 | res = Math.round(value * (scale || 1)); 94 | } 95 | return res; 96 | }; 97 | }; 98 | // 执行绘制 99 | startPaint = () => { 100 | // 如果palette模板为空 则return 101 | if (this.isEmpty(this.props.palette)) { 102 | return; 103 | } 104 | 105 | if (!(Taro.getApp().systemInfo && Taro.getApp().systemInfo.screenWidth)) { 106 | try { 107 | Taro.getApp().systemInfo = Taro.getSystemInfoSync(); 108 | } catch (e) { 109 | const error = `Painter get system info failed, ${JSON.stringify(e)}`; 110 | console.error(error); 111 | this.props.onImgErr && this.props.onImgErr(error); 112 | return; 113 | } 114 | } 115 | let screenK = Taro.getApp().systemInfo.screenWidth / 750; 116 | this.setStringPrototype(screenK, 1); 117 | 118 | this.downloadImages().then((palette: any) => { 119 | const { width, height } = palette; 120 | 121 | if (!width || !height) { 122 | console.error( 123 | `You should set width and height correctly for painter, width: ${width}, height: ${height}` 124 | ); 125 | return; 126 | } 127 | this.canvasWidthInPx = width.toPx(); 128 | if (this.props.widthPixels) { 129 | // 如果重新设置过像素宽度,则重新设置比例 130 | this.setStringPrototype( 131 | screenK, 132 | this.props.widthPixels / this.canvasWidthInPx 133 | ); 134 | this.canvasWidthInPx = this.props.widthPixels; 135 | } 136 | 137 | this.canvasHeightInPx = height.toPx(); 138 | this.setState({ 139 | painterStyle: `width:${this.canvasWidthInPx}px;height:${this.canvasHeightInPx}px;` 140 | }); 141 | const ctx = Taro.createCanvasContext(this.canvasId, this.$scope); 142 | const pen = new Pen(ctx, palette); 143 | pen.paint(() => { 144 | this.saveImgToLocal(); 145 | }); 146 | }); 147 | }; 148 | 149 | // 下载图片 150 | downloadImages = () => { 151 | return new Promise(resolve => { 152 | let preCount = 0; 153 | let completeCount = 0; 154 | const paletteCopy = JSON.parse(JSON.stringify(this.props.palette)); 155 | if (paletteCopy.background) { 156 | preCount++; 157 | downloader.download(paletteCopy.background).then( 158 | path => { 159 | paletteCopy.background = path; 160 | completeCount++; 161 | if (preCount === completeCount) { 162 | resolve(paletteCopy); 163 | } 164 | }, 165 | () => { 166 | completeCount++; 167 | if (preCount === completeCount) { 168 | resolve(paletteCopy); 169 | } 170 | } 171 | ); 172 | } 173 | if (paletteCopy.views) { 174 | for (const view of paletteCopy.views) { 175 | if (view && view.type === "image" && view.url) { 176 | preCount++; 177 | downloader.download(view.url).then( 178 | path => { 179 | view.url = path; 180 | Taro.getImageInfo({ 181 | src: view.url, 182 | //@ts-ignore 183 | success: res => { 184 | // 获得一下图片信息,供后续裁减使用 185 | view.sWidth = res.width; 186 | view.sHeight = res.height; 187 | }, 188 | fail: error => { 189 | // 如果图片坏了,则直接置空,防止坑爹的 canvas 画崩溃了 190 | view.url = ""; 191 | console.error( 192 | `getImageInfo ${view.url} failed, ${JSON.stringify( 193 | error 194 | )}` 195 | ); 196 | }, 197 | complete: () => { 198 | completeCount++; 199 | if (preCount === completeCount) { 200 | resolve(paletteCopy); 201 | } 202 | } 203 | }); 204 | }, 205 | () => { 206 | completeCount++; 207 | if (preCount === completeCount) { 208 | resolve(paletteCopy); 209 | } 210 | } 211 | ); 212 | } 213 | } 214 | } 215 | if (preCount === 0) { 216 | resolve(paletteCopy); 217 | } 218 | }); 219 | }; 220 | 221 | // 保存图片到本地 222 | saveImgToLocal = () => { 223 | setTimeout(() => { 224 | Taro.canvasToTempFilePath( 225 | { 226 | canvasId: this.canvasId, 227 | success: res => { 228 | this.getImageInfo(res.tempFilePath); 229 | }, 230 | fail: error => { 231 | console.error( 232 | `canvasToTempFilePath failed, ${JSON.stringify(error)}` 233 | ); 234 | this.props.onImgErr && this.props.onImgErr(error); 235 | } 236 | }, 237 | this.$scope 238 | ); 239 | }, 300); 240 | }; 241 | 242 | getImageInfo = filePath => { 243 | Taro.getImageInfo({ 244 | src: filePath, 245 | //@ts-ignore 246 | success: infoRes => { 247 | if (this.paintCount > MAX_PAINT_COUNT) { 248 | const error = `The result is always fault, even we tried ${MAX_PAINT_COUNT} times`; 249 | console.error(error); 250 | this.props.onImgErr && this.props.onImgErr(error); 251 | return; 252 | } 253 | // 比例相符时才证明绘制成功,否则进行强制重绘制 254 | if ( 255 | Math.abs( 256 | (infoRes.width * this.canvasHeightInPx - 257 | this.canvasWidthInPx * infoRes.height) / 258 | (infoRes.height * this.canvasHeightInPx) 259 | ) < 0.01 260 | ) { 261 | this.filePath = filePath; 262 | this.props.onImgOK && this.props.onImgOK({ path: filePath }); 263 | } else { 264 | this.startPaint(); 265 | } 266 | this.paintCount++; 267 | }, 268 | fail: error => { 269 | console.error(`getImageInfo failed, ${JSON.stringify(error)}`); 270 | this.props.onImgErr && this.props.onImgErr(error); 271 | } 272 | }); 273 | }; 274 | 275 | // 保存海报到手机相册 276 | saveImage() { 277 | const scope = "scope.writePhotosAlbum"; 278 | getAuthSetting(scope).then((res: boolean) => { 279 | if (res) { 280 | // 授权过 直接保存 281 | this.saveImageToPhotos(); 282 | return false; 283 | } 284 | // 未授权过 先获取权限 285 | getAuthSetting(scope).then((status: boolean) => { 286 | if (status) { 287 | // 获取保存图片到相册权限成功 288 | this.saveImageToPhotos(); 289 | return false; 290 | } 291 | // 用户拒绝授权后的回调 获取权限失败 292 | Taro.showModal({ 293 | title: "提示", 294 | content: "若不打开授权,则无法将图片保存在相册中!", 295 | showCancel: true, 296 | cancelText: "暂不授权", 297 | cancelColor: "#000000", 298 | confirmText: "去授权", 299 | confirmColor: "#3CC51F", 300 | success: function(res) { 301 | if (res.confirm) { 302 | // 用户点击去授权 303 | Taro.openSetting({ 304 | //调起客户端小程序设置界面,返回用户设置的操作结果。 305 | }); 306 | } else { 307 | // 308 | } 309 | } 310 | }); 311 | }); 312 | }); 313 | } 314 | saveImageToPhotos = () => { 315 | saveImageToPhotosAlbum(this.filePath) 316 | .then(() => { 317 | // 成功保存图片到本地相册 318 | // 保存失败 319 | Taro.showToast({ 320 | title: "保存成功", 321 | icon: "none" 322 | }); 323 | }) 324 | .catch(() => { 325 | // 保存失败 326 | Taro.showToast({ 327 | title: "保存失败", 328 | icon: "none" 329 | }); 330 | }); 331 | }; 332 | 333 | componentWillMount() { 334 | this.startPaint(); 335 | } 336 | componentWillReceiveProps(nextProps) { 337 | if (nextProps.palette !== this.props.palette) { 338 | this.paintCount = 0; 339 | this.startPaint(); 340 | } 341 | } 342 | 343 | render() { 344 | return ( 345 | 349 | ); 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Painter 2 | 3 | ## 简介 4 | 5 | 小程序生成图片库,轻松通过 json 方式绘制一张可以发到朋友圈的图片, 6 | 基于https://github.com/Kujiale-Mobile/Painter项目====***==改造成Taro写法==***==== 7 | 8 | ## 9 | 10 | **Painter 的优势** 11 | 12 | - 功能全,支持文本、图片、矩形、qrcode 类型的 view 绘制 13 | - 布局全,支持多种布局方式,如 align(对齐方式)、rotate(旋转) 14 | - 支持圆角,其中图片,矩形,和整个画布支持 borderRadius 来设置圆角 15 | - 杠杠的性能优化,我们对网络素材图片加载实现了一套 LRU 存储机制,不用重复下载素材图片。 16 | - 杠杠的容错,因为某些特殊情况会导致 Canvas 绘图不完整。我们对此加入了对结果图片进行检测机制,如果绘图出错会进行重绘。 17 | 18 | **TODO** 19 | 20 | - [x] borderWidth 和 borderColor 属性支持,可设置边框 21 | - [x] image 加入 mode 属性 22 | - [x] fontFamily 属性支持,使用方法见下方详细说明 23 | - [x] 支持渐变色 24 | - [x] 支持 box-shadow 和 text-shadow,统一使用 shadow 表示。具体说明请看下方。 25 | - [x] text 加入 background 属性。具体说明请看下方。 26 | - [x] 可获取 text 的宽度 27 | - [x] 支持元素的相对定位方法 28 | - [x] 可通过文本中的换行符进行主动换行 29 | - [x] 生成的图片支持分辨率调节 30 | 31 | ### 使用 Painter 32 | 33 | ## Palette 规范 34 | 35 | 如你使用 wxss + wxml 规范进行绘制一样,Painter 需要根据一定的规范来进行图片绘制。当然 Painter 的绘制规范要比 wxml 简单很多。 36 | 37 | ### 调色板属性 38 | 39 | 一个调色板首先需要给予一些整体属性 40 | 41 | ``` 42 | background: 可以是颜色值,也可以为网络图片的链接,默认为白色,支持渐变色 43 | width: 宽度 44 | height: 高度 45 | borderRadius: 边框的圆角(该属性也同样适用于子 view) 46 | views: 里面承载子 view 47 | ``` 48 | 49 | ### View 属性 50 | 51 | 当我们把整体的调色板属性构建起来后,里面就可以添加子 View 来进行绘制了。 52 | 53 | | type | 内容 | description | 自有 css | 54 | | ------ | ------- | ------------------------------ | ----------------------------------------------------------------- | 55 | | image | url | 表示图片资源的地址,本地或网络 | 见 image 小节 | 56 | | text | text | 文本的内容 | 见 text 小节 | 57 | | rect | 无 | 矩形 | color: 颜色,支持渐变色 | 58 | | qrcode | content | 画二维码 | background: 背景颜色(默认为透明色)color: 二维码颜色(默认黑色) | 59 | 60 | #### image 61 | 62 | Painter 的 image 可以设置成本地图片或者网络图片,注意本地图片请使用绝对路径。并且如果未设置 image 的长宽,则长宽的属性值会默认设为 auto。若长宽均为 auto 则会使用图片本身的长宽来布局,大小为图片的像素值除以 pixelRatio 。 63 | 64 | | 属性名称 | 说明 | 默认值 | 65 | | -------- | -------------------- | ---------- | 66 | | width | image 的宽度 | auto | 67 | | height | image 的高度 | auto | 68 | | mode | 图片裁剪、缩放的模式 | aspectFill | 69 | 70 | **scaleToFill**:不保持纵横比缩放图片,使图片的宽高完全拉伸至填满 image 元素 71 | 72 | **aspectFill**:保持纵横比缩放图片,只保证图片的短边能完全显示出来。也就是说,图片通常只在水平或垂直方向是完整的,另一个方向将会发生截取。 73 | 74 | **注:mode 属性和小程序 image 的 mode 属性功能一致,只是支持的类型只有两种,且默认值不同。 当 width 或 height 属性设置为 auto 时,mode 属性失效** 75 | 76 | ![](https://user-images.githubusercontent.com/49523717/61441645-a4f1f200-a978-11e9-9f9c-467cfcf3ec04.png) 77 | 78 |
例子代码(点击展开)
79 | 80 | ``` 81 | export default class ImageExample { 82 | palette() { 83 | return ({ 84 | width: '654rpx', 85 | height: '1000rpx', 86 | background: '#eee', 87 | views: [ 88 | { 89 | type: 'image', 90 | url: '/palette/sky.jpg', 91 | }, 92 | { 93 | type: 'text', 94 | text: '未设置height、width时', 95 | css: { 96 | right: '0rpx', 97 | top: '60rpx', 98 | fontSize: '30rpx', 99 | }, 100 | }, 101 | { 102 | type: 'image', 103 | url: '/palette/sky.jpg', 104 | css: { 105 | width: '200rpx', 106 | height: '200rpx', 107 | top: '230rpx', 108 | }, 109 | }, 110 | { 111 | type: 'text', 112 | text: "mode: 'aspectFill' 或 无", 113 | css: { 114 | left: '210rpx', 115 | fontSize: '30rpx', 116 | top: '290rpx', 117 | }, 118 | }, 119 | { 120 | type: 'image', 121 | url: '/palette/sky.jpg', 122 | css: { 123 | width: '200rpx', 124 | height: '200rpx', 125 | mode: 'scaleToFill', 126 | top: '500rpx', 127 | }, 128 | }, 129 | { 130 | type: 'text', 131 | text: "mode: 'scaleToFill'", 132 | css: { 133 | left: '210rpx', 134 | top: '560rpx', 135 | fontSize: '30rpx', 136 | }, 137 | }, 138 | { 139 | type: 'image', 140 | url: '/palette/sky.jpg', 141 | css: { 142 | width: '200rpx', 143 | height: 'auto', 144 | top: '750rpx', 145 | }, 146 | }, 147 | { 148 | type: 'text', 149 | text: '设置height为auto', 150 | css: { 151 | left: '210rpx', 152 | top: '780rpx', 153 | fontSize: '30rpx', 154 | }, 155 | }, 156 | ], 157 | }); 158 | } 159 | } 160 | ``` 161 | 162 |
163 | 164 | #### text 165 | 166 | 因为 text 的特殊性,此处对 text 进行单独说明。 167 | 168 | | 属性名称 | 说明 | 默认值 | 169 | | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | 170 | | fontSize | 字体大小 | 20rpx | 171 | | color | 字体颜色 | black | 172 | | maxLines | 最大行数 | 不限,根据 width 来 | 173 | | lineHeight | 行高(上下两行文字 baseline 的距离) | fontSize 大小 | 174 | | fontWeight | 字体粗细。仅支持 normal, bold | normal | 175 | | textDecoration | 文本修饰,支持 underline、 overline、 line-through,也可组合使用 | 无效果 | 176 | | textStyle | fill: 填充样式,stroke:镂空样式 | fill | 177 | | fontFamily | 字体,如需加载网络字体,前提前使用 wx.loadFontFace 进行加载(https://developers.weixin.qq.com/miniprogram/dev/api/media/font/wx.loadFontFace.html) | sans-serif | 178 | | background | 文字背景颜色 | 无 | 179 | | padding | 文字背景颜色边际与文字间距 | 0rpx | 180 | | textAlign | 文字的对齐方式,分为 left, center, right,view 的对齐方式请看 align 属性 | left | 181 | 182 | 当文字设置 width 属性后,则文字布局的最大宽度不会超过该 width 。如果内容超过 width,则会进行换行,如果此时未设置 maxLines 属性,则会把所有内容进行换行处理,行数由内容和 width 决定。如果此时设置了 maxLines 属性,则最大展示所设置的行数,如果还有多余内容未展示出来,则后面会带上 ... 。 183 | 184 | - **以下用个例子说下上述几个属性的用法** 185 | 186 | ![](https://user-images.githubusercontent.com/4279515/46778602-07152b00-cd47-11e8-9965-091a3d58f417.png) 187 | 188 |
例子代码(点击展开)
189 | 190 | ``` 191 | export default class LastMayday { 192 | palette() { 193 | return ({ 194 | width: '654rpx', 195 | height: '700rpx', 196 | background: '#eee', 197 | views: [ 198 | _textDecoration('overline', 0), 199 | _textDecoration('underline', 1), 200 | _textDecoration('line-through', 2), 201 | _textDecoration('overline underline line-through', 3, 'red'), 202 | { 203 | type: 'text', 204 | text: "fontWeight: 'bold'", 205 | css: [{ 206 | top: `${startTop + 4 * gapSize}rpx`, 207 | fontWeight: 'bold', 208 | }, common], 209 | }, 210 | { 211 | type: 'text', 212 | text: '我是把width设置为300rpx后,我就换行了', 213 | css: [{ 214 | top: `${startTop + 5 * gapSize}rpx`, 215 | width: '400rpx', 216 | }, common], 217 | }, 218 | { 219 | type: 'text', 220 | text: '我设置了maxLines为1,看看会产生什么效果', 221 | css: [{ 222 | top: `${startTop + 7 * gapSize}rpx`, 223 | width: '400rpx', 224 | maxLines: 1, 225 | }, common], 226 | }, 227 | { 228 | type: 'text', 229 | text: "textStyle: 'stroke'", 230 | css: [{ 231 | top: `${startTop + 8 * gapSize}rpx`, 232 | textStyle: 'stroke', 233 | fontWeight: 'bold', 234 | }, common], 235 | }, 236 | ], 237 | }); 238 | } 239 | } 240 | 241 | const startTop = 50; 242 | const gapSize = 70; 243 | const common = { 244 | left: '20rpx', 245 | fontSize: '40rpx', 246 | }; 247 | 248 | function _textDecoration(decoration, index, color) { 249 | return ({ 250 | type: 'text', 251 | text: decoration, 252 | css: [{ 253 | top: `${startTop + index * gapSize}rpx`, 254 | color: color, 255 | textDecoration: decoration, 256 | }, common], 257 | }); 258 | } 259 | ``` 260 | 261 |
262 | 263 | ### 布局属性 264 | 265 | 以上 View ,除去自己拥有的特别属性外,还有以下的通用布局属性 266 | 267 | | 属性 | 说明 | 默认 | 268 | | ------------------------ | ---------------------------------------------- | --------------------- | 269 | | rotate | 旋转,按照顺时针旋转的度数 | 不旋转 | 270 | | width、height | view 的宽度和高度,其中 image 和 text 可不设置 | | 271 | | top、right、bottom、left | 如 css 中为 absolute 布局时的作用,可为 负值 | 默认 top 和 left 为 0 | 272 | 273 | ![](https://user-images.githubusercontent.com/4279515/46778627-290ead80-cd47-11e8-8483-2e36e39b36f0.png) 274 | 275 | #### 相对布局方法 276 | 277 | 很多人有获得文本宽度的需求,因为文本宽度随着字数不同而动态变化,如果想在文本后面加个图标,那么我们就需要获得文本宽度。Painter 的解决方案如下: 278 | 279 | ``` 280 | 1,首先你需要为检测长度的文本添加一个 id。如下 281 | { 282 | id: 'my-text-id', 283 | type: 'text', 284 | 285 | 2,然后在后面的 view 中,你可以在 left 和 right 属性中使用这个id。如下 286 | left: ['10rpx', 'my-text-id', 比例] 287 | 表示布局在距离左边(10rpx + 该text文本宽度 * 比例) 的距离,比例默认为 1,可省去,你也可以使用负数或小数来做计算,最终的 left 会加上文本宽度乘以该数的值。 288 | 289 | 注意:比例一定为一个 number 290 | ``` 291 | 292 | 如果想获得高度,top 也支持上述用法,并且除文本外,你可以对任何 view 设置一个 id,然后使用上述方法进行相对布局。 293 | 294 | **注:相对布局的那个 view 代码一定需要在被相对的 view 的下面。** 295 | 296 | #### border 类型 297 | 298 | | 属性 | 说明 | 默认 | 299 | | ------------ | -------------------------------------------------------------- | ---------------------- | 300 | | borderRadius | 边界圆角程度,如果是正方形布局,该属性为一半宽或高时,则为圆形 | 0 | 301 | | borderWidth | 边界宽度,外边界 | 必设值,否则无边框效果 | 302 | | borderColor | 边框颜色 | black | 303 | 304 | ![](https://user-images.githubusercontent.com/4279515/46778646-3cba1400-cd47-11e8-916a-3fddc172534d.png) 305 | 306 | #### align 307 | 308 | Painter 的 align 类型与 css 中的 align 有些许不同。在 Painter 中 align 表示 view 本身的对齐方式,而不像 css 中表示对其子 view 的操作。align 可以作用在 Painter 支持的所有 view 上。它以设置的 left、top、right、bottom 的位置为基准,然后做不同的对齐操作。并且 align 在文字多行情况下,会影响多行文字的对齐方式。 309 | 310 | **注意:如果布局使用了 right 确定位置,则该 view 会默认右对齐布局,但此时文字还是从左边绘制。** 311 | 312 | ![](https://user-images.githubusercontent.com/4279515/46778660-4e9bb700-cd47-11e8-8d93-e522185e8188.png) 313 | 314 |
例子代码(点击展开)
315 | 316 | ``` 317 | { 318 | width: '654rpx', 319 | height: '600rpx', 320 | background: '#eee', 321 | views: [ 322 | { 323 | type: 'rect', 324 | css: { 325 | top: '40rpx', 326 | left: '327rpx', 327 | color: 'rgba(255, 0, 0, 0.5)', 328 | width: '5rpx', 329 | height: '500rpx', 330 | }, 331 | }, 332 | { 333 | type: 'image', 334 | url: '/palette/avatar.jpg', 335 | css: { 336 | top: '40rpx', 337 | left: '327rpx', 338 | width: '100rpx', 339 | height: '100rpx', 340 | }, 341 | }, 342 | { 343 | type: 'qrcode', 344 | content: '/palette/avatar.jpg', 345 | css: { 346 | top: '180rpx', 347 | left: '327rpx', 348 | width: '120rpx', 349 | height: '120rpx', 350 | }, 351 | }, 352 | { 353 | type: 'text', 354 | text: "align: 'left' 或者不写", 355 | css: { 356 | top: '320rpx', 357 | left: '327rpx', 358 | fontSize: '30rpx', 359 | }, 360 | }, 361 | { 362 | type: 'text', 363 | text: "align: 'right'", 364 | css: { 365 | top: '370rpx', 366 | left: '327rpx', 367 | align: 'right', 368 | fontSize: '30rpx', 369 | }, 370 | }, 371 | { 372 | type: 'text', 373 | text: "align: 'center'", 374 | css: { 375 | top: '420rpx', 376 | left: '327rpx', 377 | align: 'center', 378 | fontSize: '30rpx', 379 | }, 380 | }, 381 | { 382 | type: 'text', 383 | text: "在多行的情况下,align 会影响内部 text 的对齐,比如这边设置 align: 'center'", 384 | css: { 385 | top: '480rpx', 386 | right: '327rpx', 387 | width: '400rpx', 388 | align: 'center', 389 | fontSize: '30rpx', 390 | }, 391 | }, 392 | ], 393 | } 394 | ``` 395 | 396 |
397 | 398 | ### CSS3 支持 399 | 400 | #### shadow 401 | 402 | Painter 中的 shadow 可以同时修饰 image、rect、text、qrcode 等 。在修饰 text 时则相当于 text-shadow;修饰 image 和 rect 时相当于 box-shadow;修饰 qrcode 时,则相当于二维码有效区域的投影。 403 | 404 | ![](https://user-images.githubusercontent.com/4279515/51457535-ab6a2d00-1d8c-11e9-8812-9ab1ee8dafa4.png) 405 | 406 | 使用方法: 407 | 408 | ```bash 409 | shadow: 'h-shadow v-shadow blur color'; 410 | h-shadow: 必需。水平阴影的位置。允许负值。 411 | v-shadow: 必需。垂直阴影的位置。允许负值。 412 | blur: 必需。模糊的距离。 413 | color: 必需。阴影的颜色。 414 | ``` 415 | 416 |
例子代码(点击展开)
417 | 418 | ```bash 419 | export default class ShadowExample { 420 | palette() { 421 | return ({ 422 | width: '654rpx', 423 | height: '400rpx', 424 | background: '#eee', 425 | views: [{ 426 | type: 'image', 427 | url: '/palette/sky.jpg', 428 | css: { 429 | shadow: '10rpx 10rpx 5rpx #888888', 430 | } 431 | }, 432 | { 433 | type: 'rect', 434 | css: { 435 | width: '250rpx', 436 | height: '150rpx', 437 | right: '50rpx', 438 | top: '60rpx', 439 | shadow: '10rpx 10rpx 5rpx #888888', 440 | color: 'linear-gradient(-135deg, #fedcba 0%, rgba(18, 52, 86, 1) 20%, #987 80%)', 441 | } 442 | }, 443 | { 444 | type: 'qrcode', 445 | content: 'https://github.com/Kujiale-Mobile/Painter', 446 | css: { 447 | top: '230rpx', 448 | width: '120rpx', 449 | height: '120rpx', 450 | shadow: '10rpx 10rpx 5rpx #888888', 451 | }, 452 | }, 453 | { 454 | type: 'text', 455 | text: "shadow: '10rpx 10rpx 5rpx #888888'", 456 | css: { 457 | left: '180rpx', 458 | fontSize: '30rpx', 459 | shadow: '10rpx 10rpx 5rpx #888888', 460 | top: '290rpx', 461 | }, 462 | }, 463 | ], 464 | }); 465 | } 466 | } 467 | ``` 468 | 469 |
470 | 471 | #### 渐变色支持 472 | 473 | 你可以在画布的 background 属性或者 rect 的 color 属性中使用以下方式实现 css 3 的渐变色,其中 radial-gradient 渐变的圆心为 view 中点,半径为最长边,目前不支持自己设置。 474 | 475 | ``` 476 | linear-gradient(-135deg, blue 0%, rgba(18, 52, 86, 1) 20%, #987 80%) 477 | 478 | radial-gradient(rgba(0, 0, 0, 0) 5%, #0ff 15%, #f0f 60%) 479 | ``` 480 | 481 | **!!!注意:颜色后面的百分比一定得写。** 482 | 483 | ### 其他技巧 484 | 485 | #### 文字竖行显示 486 | 487 | 因为 Painter 支持换行符,所以我们可以配合向字符之间插入换行符来达到竖排显示的效果,并且我们还能自由控制是从左到右或从右到左,如下图所示。 488 | 489 | ![](https://user-images.githubusercontent.com/4279515/61357471-f16efc00-a8aa-11e9-84b3-192fe158f38d.png) 490 | 491 |
例子代码(点击展开)
492 | 493 | ``` 494 | const text = '锄禾日当午汗滴禾下土谁知盘中餐粒粒皆辛苦'; 495 | export default class ImageExample { 496 | palette() { 497 | const views = []; 498 | let tmpText = ''; 499 | let index = 0; 500 | for (let i = 0; i < text.length; i++) { 501 | tmpText = `${tmpText}${text[i]}\n`; 502 | if (i % 5 === 4) { 503 | views.push({ 504 | type: 'text', 505 | text: tmpText, 506 | css: { 507 | right: `${50 + index}rpx`, 508 | top: '60rpx', 509 | fontSize: '40rpx', 510 | lineHeight: '50rpx', 511 | }, 512 | }); 513 | index += 50; 514 | tmpText = ''; 515 | } 516 | } 517 | return ({ 518 | width: '654rpx', 519 | height: '500rpx', 520 | background: '#eee', 521 | views: views, 522 | }); 523 | } 524 | } 525 | ``` 526 | 527 |
528 | 529 | ### Tips(一定要看哦~) 530 | 531 | 1,目前 Painter 中支持两种尺寸单位,px 和 rpx,代表的意思和小程序中一致,此处就不多说。 532 | 533 | 2,目前子 view 的 css 属性支持 object 或 array。所以意味着,你可以把几个子 view 共用的 css 属性提取出来。做到让 Palette 更加简洁。 534 | 535 | 3,因为我们的 palette 是以 js 承载的 json,所以意味着你可以在每一个属性中很方便的加上自己的逻辑。也可以把某些属性单独提取出来,让多个 palette 共用,做到模块化。 536 | 537 | 4,如果你只希望获得一张生成的图片来展示,可以把 Painter 挪动到屏幕外进行绘制,绘制完后得到一张图片再进行展示,如下面这样。 538 | 539 | ``` 540 | 541 | ``` 542 | 543 | ## 举个栗子 544 | 545 | ``` 546 | { 547 | background: '#eee', 548 | width: '654rpx', 549 | height: '400rpx', 550 | borderRadius: '20rpx', 551 | views: [ 552 | { 553 | type: 'image', 554 | url: 'https://qhyxpicoss.kujiale.com/r/2017/12/04/L3D123I45VHNYULVSAEYCV3P3X6888_3200x2400.jpg@!70q', 555 | css: { 556 | top: '48rpx', 557 | right: '48rpx', 558 | width: '192rpx', 559 | height: '192rpx', 560 | }, 561 | } 562 | ... 563 | ], 564 | } 565 | ``` 566 | 567 | 绘制效果如下 568 | 569 | ![](https://user-images.githubusercontent.com/4279515/46778534-ba315480-cd46-11e8-940a-4c8f53f93928.png) 570 | 571 | ## 参考 572 | 573 | [https://github.com/Kujiale-Mobile/Painter](https://note.youdao.com/) 574 | -------------------------------------------------------------------------------- /src/component/poster/lib/pen.js: -------------------------------------------------------------------------------- 1 | const QR = require('./qrcode.js'); 2 | const GD = require('./gradient.js'); 3 | 4 | export default class Painter { 5 | constructor(ctx, data) { 6 | this.ctx = ctx; 7 | this.data = data; 8 | this.globalWidth = {}; 9 | this.globalHeight = {}; 10 | } 11 | 12 | paint(callback) { 13 | this.style = { 14 | width: this.data.width.toPx(), 15 | height: this.data.height.toPx(), 16 | }; 17 | this._background(); 18 | for (const view of this.data.views) { 19 | this._drawAbsolute(view); 20 | } 21 | this.ctx.draw(false, () => { 22 | callback(); 23 | }); 24 | } 25 | 26 | _background() { 27 | this.ctx.save(); 28 | const { 29 | width, 30 | height, 31 | } = this.style; 32 | const bg = this.data.background; 33 | this.ctx.translate(width / 2, height / 2); 34 | 35 | this._doClip(this.data.borderRadius, width, height); 36 | if (!bg) { 37 | // 如果未设置背景,则默认使用白色 38 | this.ctx.fillStyle = '#fff'; 39 | this.ctx.fillRect(-(width / 2), -(height / 2), width, height); 40 | } else if (bg.startsWith('#') || bg.startsWith('rgba') || bg.toLowerCase() === 'transparent') { 41 | // 背景填充颜色 42 | this.ctx.fillStyle = bg; 43 | this.ctx.fillRect(-(width / 2), -(height / 2), width, height); 44 | } else if (GD.api.isGradient(bg)) { 45 | GD.api.doGradient(bg, width, height, this.ctx); 46 | this.ctx.fillRect(-(width / 2), -(height / 2), width, height); 47 | } else { 48 | // 背景填充图片 49 | this.ctx.drawImage(bg, -(width / 2), -(height / 2), width, height); 50 | } 51 | this.ctx.restore(); 52 | } 53 | 54 | _drawAbsolute(view) { 55 | // 证明 css 为数组形式,需要合并 56 | if (view.css && view.css.length) { 57 | /* eslint-disable no-param-reassign */ 58 | view.css = Object.assign(...view.css); 59 | } 60 | switch (view.type) { 61 | case 'image': 62 | this._drawAbsImage(view); 63 | break; 64 | case 'text': 65 | this._fillAbsText(view); 66 | break; 67 | case 'rect': 68 | this._drawAbsRect(view); 69 | break; 70 | case 'qrcode': 71 | this._drawQRCode(view); 72 | break; 73 | default: 74 | break; 75 | } 76 | } 77 | 78 | /** 79 | * 根据 borderRadius 进行裁减 80 | */ 81 | _doClip(borderRadius, width, height) { 82 | if (borderRadius && width && height) { 83 | const r = Math.min(borderRadius.toPx(), width / 2, height / 2); 84 | // 防止在某些机型上周边有黑框现象,此处如果直接设置 fillStyle 为透明,在 Android 机型上会导致被裁减的图片也变为透明, iOS 和 IDE 上不会 85 | // globalAlpha 在 1.9.90 起支持,低版本下无效,但把 fillStyle 设为了 white,相对默认的 black 要好点 86 | this.ctx.globalAlpha = 0; 87 | this.ctx.fillStyle = 'white'; 88 | this.ctx.beginPath(); 89 | this.ctx.arc(-width / 2 + r, -height / 2 + r, r, 1 * Math.PI, 1.5 * Math.PI); 90 | this.ctx.lineTo(width / 2 - r, -height / 2); 91 | this.ctx.arc(width / 2 - r, -height / 2 + r, r, 1.5 * Math.PI, 2 * Math.PI); 92 | this.ctx.lineTo(width / 2, height / 2 - r); 93 | this.ctx.arc(width / 2 - r, height / 2 - r, r, 0, 0.5 * Math.PI); 94 | this.ctx.lineTo(-width / 2 + r, height / 2); 95 | this.ctx.arc(-width / 2 + r, height / 2 - r, r, 0.5 * Math.PI, 1 * Math.PI); 96 | this.ctx.closePath(); 97 | this.ctx.fill(); 98 | // 在 ios 的 6.6.6 版本上 clip 有 bug,禁掉此类型上的 clip,也就意味着,在此版本微信的 ios 设备下无法使用 border 属性 99 | if (!(getApp().systemInfo && 100 | getApp().systemInfo.version <= '6.6.6' && 101 | getApp().systemInfo.platform === 'ios')) { 102 | this.ctx.clip(); 103 | } 104 | this.ctx.globalAlpha = 1; 105 | } 106 | } 107 | 108 | /** 109 | * 画边框 110 | */ 111 | _doBorder(view, width, height) { 112 | if (!view.css) { 113 | return; 114 | } 115 | const { 116 | borderRadius, 117 | borderWidth, 118 | borderColor, 119 | } = view.css; 120 | if (!borderWidth) { 121 | return; 122 | } 123 | this.ctx.save(); 124 | this._preProcess(view, true); 125 | let r; 126 | if (borderRadius) { 127 | r = Math.min(borderRadius.toPx(), width / 2, height / 2); 128 | } else { 129 | r = 0; 130 | } 131 | const lineWidth = borderWidth.toPx(); 132 | this.ctx.lineWidth = lineWidth; 133 | this.ctx.strokeStyle = (borderColor || 'black'); 134 | this.ctx.beginPath(); 135 | this.ctx.arc(-width / 2 + r, -height / 2 + r, r + lineWidth / 2, 1 * Math.PI, 1.5 * Math.PI); 136 | this.ctx.lineTo(width / 2 - r, -height / 2 - lineWidth / 2); 137 | this.ctx.arc(width / 2 - r, -height / 2 + r, r + lineWidth / 2, 1.5 * Math.PI, 2 * Math.PI); 138 | this.ctx.lineTo(width / 2 + lineWidth / 2, height / 2 - r); 139 | this.ctx.arc(width / 2 - r, height / 2 - r, r + lineWidth / 2, 0, 0.5 * Math.PI); 140 | this.ctx.lineTo(-width / 2 + r, height / 2 + lineWidth / 2); 141 | this.ctx.arc(-width / 2 + r, height / 2 - r, r + lineWidth / 2, 0.5 * Math.PI, 1 * Math.PI); 142 | this.ctx.closePath(); 143 | this.ctx.stroke(); 144 | this.ctx.restore(); 145 | } 146 | 147 | _preProcess(view, notClip) { 148 | let width = 0; 149 | let height; 150 | let extra; 151 | switch (view.type) { 152 | case 'text': { 153 | const textArray = view.text.split('\n'); 154 | // 处理多个连续的'\n' 155 | for (let i = 0; i < textArray.length; ++i) { 156 | if (textArray[i] === '') { 157 | textArray[i] = ' '; 158 | } 159 | } 160 | const fontWeight = view.css.fontWeight === 'bold' ? 'bold' : 'normal'; 161 | view.css.fontSize = view.css.fontSize ? view.css.fontSize : '20rpx'; 162 | this.ctx.font = `normal ${fontWeight} ${view.css.fontSize.toPx()}px ${view.css.fontFamily ? view.css.fontFamily : 'sans-serif'}`; 163 | // this.ctx.setFontSize(view.css.fontSize.toPx()); 164 | // 计算行数 165 | let lines = 0; 166 | const linesArray = []; 167 | for (let i = 0; i < textArray.length; ++i) { 168 | const textLength = this.ctx.measureText(textArray[i]).width; 169 | const partWidth = view.css.width ? view.css.width.toPx() : textLength; 170 | const calLines = Math.ceil(textLength / partWidth); 171 | width = partWidth > width ? partWidth : width; 172 | lines += calLines; 173 | linesArray[i] = calLines; 174 | } 175 | lines = view.css.maxLines < lines ? view.css.maxLines : lines; 176 | const lineHeight = view.css.lineHeight ? view.css.lineHeight.toPx() : view.css.fontSize.toPx(); 177 | height = lineHeight * lines; 178 | extra = { 179 | lines: lines, 180 | lineHeight: lineHeight, 181 | textArray: textArray, 182 | linesArray: linesArray, 183 | }; 184 | break; 185 | } 186 | case 'image': { 187 | // image的长宽设置成auto的逻辑处理 188 | const ratio = getApp().systemInfo.pixelRatio ? getApp().systemInfo.pixelRatio : 2; 189 | // 有css却未设置width或height,则默认为auto 190 | if (view.css) { 191 | if (!view.css.width) { 192 | view.css.width = 'auto'; 193 | } 194 | if (!view.css.height) { 195 | view.css.height = 'auto'; 196 | } 197 | } 198 | if (!view.css || (view.css.width === 'auto' && view.css.height === 'auto')) { 199 | width = Math.round(view.sWidth / ratio); 200 | height = Math.round(view.sHeight / ratio); 201 | } else if (view.css.width === 'auto') { 202 | height = view.css.height.toPx(); 203 | width = view.sWidth / view.sHeight * height; 204 | } else if (view.css.height === 'auto') { 205 | width = view.css.width.toPx(); 206 | height = view.sHeight / view.sWidth * width; 207 | } else { 208 | width = view.css.width.toPx(); 209 | height = view.css.height.toPx(); 210 | } 211 | break; 212 | } 213 | default: 214 | if (!(view.css.width && view.css.height)) { 215 | console.error('You should set width and height'); 216 | return; 217 | } 218 | width = view.css.width.toPx(); 219 | height = view.css.height.toPx(); 220 | break; 221 | } 222 | let x; 223 | if (view.css && view.css.right) { 224 | if (typeof view.css.right === 'string') { 225 | x = this.style.width - view.css.right.toPx(true); 226 | } else { 227 | // 可以用数组方式,把文字长度计算进去 228 | // [right, 文字id, 乘数(默认 1)] 229 | const rights = view.css.right; 230 | x = this.style.width - rights[0].toPx(true) - this.globalWidth[rights[1]] * (rights[2] || 1); 231 | } 232 | } else if (view.css && view.css.left) { 233 | if (typeof view.css.left === 'string') { 234 | x = view.css.left.toPx(true); 235 | } else { 236 | const lefts = view.css.left; 237 | x = lefts[0].toPx(true) + this.globalWidth[lefts[1]] * (lefts[2] || 1); 238 | } 239 | } else { 240 | x = 0; 241 | } 242 | //const y = view.css && view.css.bottom ? this.style.height - height - view.css.bottom.toPx(true) : (view.css && view.css.top ? view.css.top.toPx(true) : 0); 243 | let y; 244 | if (view.css && view.css.bottom) { 245 | y = this.style.height - height - view.css.bottom.toPx(true); 246 | } else { 247 | if (view.css && view.css.top) { 248 | if (typeof view.css.top === 'string') { 249 | y = view.css.top.toPx(true); 250 | } else { 251 | const tops = view.css.top; 252 | y = tops[0].toPx(true) + this.globalHeight[tops[1]] * (tops[2] || 1); 253 | } 254 | } else { 255 | y = 0 256 | } 257 | } 258 | 259 | const angle = view.css && view.css.rotate ? this._getAngle(view.css.rotate) : 0; 260 | // 当设置了 right 时,默认 align 用 right,反之用 left 261 | const align = view.css && view.css.align ? view.css.align : (view.css && view.css.right ? 'right' : 'left'); 262 | switch (align) { 263 | case 'center': 264 | this.ctx.translate(x, y + height / 2); 265 | break; 266 | case 'right': 267 | this.ctx.translate(x - width / 2, y + height / 2); 268 | break; 269 | default: 270 | this.ctx.translate(x + width / 2, y + height / 2); 271 | break; 272 | } 273 | this.ctx.rotate(angle); 274 | if (!notClip && view.css && view.css.borderRadius && view.type !== 'rect') { 275 | this._doClip(view.css.borderRadius, width, height); 276 | } 277 | this._doShadow(view); 278 | if (view.id) { 279 | this.globalWidth[view.id] = width; 280 | this.globalHeight[view.id] = height; 281 | } 282 | return { 283 | width: width, 284 | height: height, 285 | x: x, 286 | y: y, 287 | extra: extra, 288 | }; 289 | } 290 | 291 | // 画文字的背景图片 292 | _doBackground(view) { 293 | this.ctx.save(); 294 | const { 295 | width: rawWidth, 296 | height: rawHeight, 297 | } = this._preProcess(view, true); 298 | 299 | const { 300 | background, 301 | padding, 302 | } = view.css; 303 | let pd = [0, 0, 0, 0]; 304 | if (padding) { 305 | const pdg = padding.split(/\s+/); 306 | if (pdg.length === 1) { 307 | const x = pdg[0].toPx(); 308 | pd = [x, x, x, x]; 309 | } 310 | if (pdg.length === 2) { 311 | const x = pdg[0].toPx(); 312 | const y = pdg[1].toPx(); 313 | pd = [x, y, x, y]; 314 | } 315 | if (pdg.length === 3) { 316 | const x = pdg[0].toPx(); 317 | const y = pdg[1].toPx(); 318 | const z = pdg[2].toPx(); 319 | pd = [x, y, z, y]; 320 | } 321 | if (pdg.length === 4) { 322 | const x = pdg[0].toPx(); 323 | const y = pdg[1].toPx(); 324 | const z = pdg[2].toPx(); 325 | const a = pdg[3].toPx(); 326 | pd = [x, y, z, a]; 327 | } 328 | } 329 | const width = rawWidth + pd[1] + pd[3]; 330 | const height = rawHeight + pd[0] + pd[2]; 331 | this._doClip(view.css.borderRadius, width, height) 332 | if (GD.api.isGradient(background)) { 333 | GD.api.doGradient(background, width, height, this.ctx); 334 | } else { 335 | this.ctx.fillStyle = background; 336 | } 337 | this.ctx.fillRect(-(width / 2), -(height / 2), width, height); 338 | 339 | this.ctx.restore(); 340 | } 341 | 342 | _drawQRCode(view) { 343 | this.ctx.save(); 344 | const { 345 | width, 346 | height, 347 | } = this._preProcess(view); 348 | QR.api.draw(view.content, this.ctx, -width / 2, -height / 2, width, height, view.css.background, view.css.color); 349 | this.ctx.restore(); 350 | this._doBorder(view, width, height); 351 | } 352 | 353 | _drawAbsImage(view) { 354 | if (!view.url) { 355 | return; 356 | } 357 | this.ctx.save(); 358 | const { 359 | width, 360 | height, 361 | } = this._preProcess(view); 362 | // 获得缩放到图片大小级别的裁减框 363 | let rWidth = view.sWidth; 364 | let rHeight = view.sHeight; 365 | let startX = 0; 366 | let startY = 0; 367 | // 绘画区域比例 368 | const cp = width / height; 369 | // 原图比例 370 | const op = view.sWidth / view.sHeight; 371 | if (cp >= op) { 372 | rHeight = rWidth / cp; 373 | startY = Math.round((view.sHeight - rHeight) / 2); 374 | } else { 375 | rWidth = rHeight * cp; 376 | startX = Math.round((view.sWidth - rWidth) / 2); 377 | } 378 | if (view.css && view.css.mode === 'scaleToFill') { 379 | this.ctx.drawImage(view.url, -(width / 2), -(height / 2), width, height); 380 | } else { 381 | this.ctx.drawImage(view.url, startX, startY, rWidth, rHeight, -(width / 2), -(height / 2), width, height); 382 | } 383 | this.ctx.restore(); 384 | this._doBorder(view, width, height); 385 | } 386 | 387 | _fillAbsText(view) { 388 | if (!view.text) { 389 | return; 390 | } 391 | if (view.css.background) { 392 | // 生成背景 393 | this._doBackground(view); 394 | } 395 | this.ctx.save(); 396 | const { 397 | width, 398 | height, 399 | extra, 400 | } = this._preProcess(view, view.css.background && view.css.borderRadius); 401 | 402 | this.ctx.fillStyle = (view.css.color || 'black'); 403 | const { 404 | lines, 405 | lineHeight, 406 | textArray, 407 | linesArray, 408 | } = extra; 409 | // 如果设置了id,则保留 text 的长度 410 | if (view.id) { 411 | let textWidth = 0; 412 | for (let i = 0; i < textArray.length; ++i) { 413 | textWidth = this.ctx.measureText(textArray[i]).width > textWidth ? this.ctx.measureText(textArray[i]).width : textWidth; 414 | } 415 | this.globalWidth[view.id] = width ? (textWidth < width ? textWidth : width) : textWidth; 416 | } 417 | let lineIndex = 0; 418 | for (let j = 0; j < textArray.length; ++j) { 419 | const preLineLength = Math.round(textArray[j].length / linesArray[j]); 420 | let start = 0; 421 | let alreadyCount = 0; 422 | for (let i = 0; i < linesArray[j]; ++i) { 423 | // 绘制行数大于最大行数,则直接跳出循环 424 | if (lineIndex >= lines) { 425 | break; 426 | } 427 | alreadyCount = preLineLength; 428 | let text = textArray[j].substr(start, alreadyCount); 429 | let measuredWith = this.ctx.measureText(text).width; 430 | // 如果测量大小小于width一个字符的大小,则进行补齐,如果测量大小超出 width,则进行减除 431 | // 如果已经到文本末尾,也不要进行该循环 432 | while ((start + alreadyCount <= textArray[j].length) && (width - measuredWith > view.css.fontSize.toPx() || measuredWith > width)) { 433 | if (measuredWith < width) { 434 | text = textArray[j].substr(start, ++alreadyCount); 435 | } else { 436 | if (text.length <= 1) { 437 | // 如果只有一个字符时,直接跳出循环 438 | break; 439 | } 440 | text = textArray[j].substr(start, --alreadyCount); 441 | } 442 | measuredWith = this.ctx.measureText(text).width; 443 | } 444 | start += text.length; 445 | // 如果是最后一行了,发现还有未绘制完的内容,则加... 446 | if (lineIndex === lines - 1 && (j < textArray.length - 1 || start < textArray[j].length)) { 447 | while (this.ctx.measureText(`${text}...`).width > width) { 448 | if (text.length <= 1) { 449 | // 如果只有一个字符时,直接跳出循环 450 | break; 451 | } 452 | text = text.substring(0, text.length - 1); 453 | } 454 | text += '...'; 455 | measuredWith = this.ctx.measureText(text).width; 456 | } 457 | this.ctx.setTextAlign(view.css.textAlign ? view.css.textAlign : 'left'); 458 | let x; 459 | switch (view.css.textAlign) { 460 | case 'center': 461 | x = 0; 462 | break; 463 | case 'right': 464 | x = (width / 2); 465 | break; 466 | default: 467 | x = -(width / 2); 468 | break; 469 | } 470 | const y = -(height / 2) + (lineIndex === 0 ? view.css.fontSize.toPx() : (view.css.fontSize.toPx() + lineIndex * lineHeight)); 471 | lineIndex++; 472 | if (view.css.textStyle === 'stroke') { 473 | this.ctx.strokeText(text, x, y, measuredWith); 474 | } else { 475 | this.ctx.fillText(text, x, y, measuredWith); 476 | } 477 | const fontSize = view.css.fontSize.toPx(); 478 | if (view.css.textDecoration) { 479 | this.ctx.beginPath(); 480 | if (/\bunderline\b/.test(view.css.textDecoration)) { 481 | this.ctx.moveTo(x, y); 482 | this.ctx.lineTo(x + measuredWith, y); 483 | } 484 | if (/\boverline\b/.test(view.css.textDecoration)) { 485 | this.ctx.moveTo(x, y - fontSize); 486 | this.ctx.lineTo(x + measuredWith, y - fontSize); 487 | } 488 | if (/\bline-through\b/.test(view.css.textDecoration)) { 489 | this.ctx.moveTo(x, y - fontSize / 3); 490 | this.ctx.lineTo(x + measuredWith, y - fontSize / 3); 491 | } 492 | this.ctx.closePath(); 493 | this.ctx.strokeStyle = view.css.color; 494 | this.ctx.stroke(); 495 | } 496 | } 497 | } 498 | this.ctx.restore(); 499 | this._doBorder(view, width, height); 500 | } 501 | 502 | _drawAbsRect(view) { 503 | this.ctx.save(); 504 | const { 505 | width, 506 | height, 507 | } = this._preProcess(view); 508 | if (GD.api.isGradient(view.css.color)) { 509 | GD.api.doGradient(view.css.color, width, height, this.ctx); 510 | } else { 511 | this.ctx.fillStyle = view.css.color; 512 | } 513 | const borderRadius = view.css.borderRadius 514 | const r = borderRadius ? Math.min(borderRadius.toPx(), width / 2, height / 2) : 0; 515 | this.ctx.beginPath(); 516 | this.ctx.arc(-width / 2 + r, -height / 2 + r, r, 1 * Math.PI, 1.5 * Math.PI); //左上角圆弧 517 | this.ctx.lineTo(width / 2 - r, -height / 2); 518 | this.ctx.arc(width / 2 - r, -height / 2 + r, r, 1.5 * Math.PI, 2 * Math.PI); // 右上角圆弧 519 | this.ctx.lineTo(width / 2, height / 2 - r); 520 | this.ctx.arc(width / 2 - r, height / 2 - r, r, 0, 0.5 * Math.PI); // 右下角圆弧 521 | this.ctx.lineTo(-width / 2 + r, height / 2); 522 | this.ctx.arc(-width / 2 + r, height / 2 - r, r, 0.5 * Math.PI, 1 * Math.PI); // 左下角圆弧 523 | this.ctx.closePath(); 524 | this.ctx.fill(); 525 | this.ctx.restore(); 526 | this._doBorder(view, width, height); 527 | } 528 | 529 | // shadow 支持 (x, y, blur, color), 不支持 spread 530 | // shadow:0px 0px 10px rgba(0,0,0,0.1); 531 | _doShadow(view) { 532 | if (!view.css || !view.css.shadow) { 533 | return; 534 | } 535 | const box = view.css.shadow.replace(/,\s+/g, ',').split(' '); 536 | if (box.length > 4) { 537 | console.error('shadow don\'t spread option'); 538 | return; 539 | } 540 | this.ctx.shadowOffsetX = parseInt(box[0], 10); 541 | this.ctx.shadowOffsetY = parseInt(box[1], 10); 542 | this.ctx.shadowBlur = parseInt(box[2], 10); 543 | this.ctx.shadowColor = box[3]; 544 | } 545 | 546 | _getAngle(angle) { 547 | return Number(angle) * Math.PI / 180; 548 | } 549 | } -------------------------------------------------------------------------------- /src/component/poster/lib/qrcode.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | !(function () { 3 | 4 | // alignment pattern 5 | var adelta = [ 6 | 0, 11, 15, 19, 23, 27, 31, 7 | 16, 18, 20, 22, 24, 26, 28, 20, 22, 24, 24, 26, 28, 28, 22, 24, 24, 8 | 26, 26, 28, 28, 24, 24, 26, 26, 26, 28, 28, 24, 26, 26, 26, 28, 28 9 | ]; 10 | 11 | // version block 12 | var vpat = [ 13 | 0xc94, 0x5bc, 0xa99, 0x4d3, 0xbf6, 0x762, 0x847, 0x60d, 14 | 0x928, 0xb78, 0x45d, 0xa17, 0x532, 0x9a6, 0x683, 0x8c9, 15 | 0x7ec, 0xec4, 0x1e1, 0xfab, 0x08e, 0xc1a, 0x33f, 0xd75, 16 | 0x250, 0x9d5, 0x6f0, 0x8ba, 0x79f, 0xb0b, 0x42e, 0xa64, 17 | 0x541, 0xc69 18 | ]; 19 | 20 | // final format bits with mask: level << 3 | mask 21 | var fmtword = [ 22 | 0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976, //L 23 | 0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0, //M 24 | 0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183, 0x2eda, 0x2bed, //Q 25 | 0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b //H 26 | ]; 27 | 28 | // 4 per version: number of blocks 1,2; data width; ecc width 29 | var eccblocks = [ 30 | 1, 0, 19, 7, 1, 0, 16, 10, 1, 0, 13, 13, 1, 0, 9, 17, 31 | 1, 0, 34, 10, 1, 0, 28, 16, 1, 0, 22, 22, 1, 0, 16, 28, 32 | 1, 0, 55, 15, 1, 0, 44, 26, 2, 0, 17, 18, 2, 0, 13, 22, 33 | 1, 0, 80, 20, 2, 0, 32, 18, 2, 0, 24, 26, 4, 0, 9, 16, 34 | 1, 0, 108, 26, 2, 0, 43, 24, 2, 2, 15, 18, 2, 2, 11, 22, 35 | 2, 0, 68, 18, 4, 0, 27, 16, 4, 0, 19, 24, 4, 0, 15, 28, 36 | 2, 0, 78, 20, 4, 0, 31, 18, 2, 4, 14, 18, 4, 1, 13, 26, 37 | 2, 0, 97, 24, 2, 2, 38, 22, 4, 2, 18, 22, 4, 2, 14, 26, 38 | 2, 0, 116, 30, 3, 2, 36, 22, 4, 4, 16, 20, 4, 4, 12, 24, 39 | 2, 2, 68, 18, 4, 1, 43, 26, 6, 2, 19, 24, 6, 2, 15, 28, 40 | 4, 0, 81, 20, 1, 4, 50, 30, 4, 4, 22, 28, 3, 8, 12, 24, 41 | 2, 2, 92, 24, 6, 2, 36, 22, 4, 6, 20, 26, 7, 4, 14, 28, 42 | 4, 0, 107, 26, 8, 1, 37, 22, 8, 4, 20, 24, 12, 4, 11, 22, 43 | 3, 1, 115, 30, 4, 5, 40, 24, 11, 5, 16, 20, 11, 5, 12, 24, 44 | 5, 1, 87, 22, 5, 5, 41, 24, 5, 7, 24, 30, 11, 7, 12, 24, 45 | 5, 1, 98, 24, 7, 3, 45, 28, 15, 2, 19, 24, 3, 13, 15, 30, 46 | 1, 5, 107, 28, 10, 1, 46, 28, 1, 15, 22, 28, 2, 17, 14, 28, 47 | 5, 1, 120, 30, 9, 4, 43, 26, 17, 1, 22, 28, 2, 19, 14, 28, 48 | 3, 4, 113, 28, 3, 11, 44, 26, 17, 4, 21, 26, 9, 16, 13, 26, 49 | 3, 5, 107, 28, 3, 13, 41, 26, 15, 5, 24, 30, 15, 10, 15, 28, 50 | 4, 4, 116, 28, 17, 0, 42, 26, 17, 6, 22, 28, 19, 6, 16, 30, 51 | 2, 7, 111, 28, 17, 0, 46, 28, 7, 16, 24, 30, 34, 0, 13, 24, 52 | 4, 5, 121, 30, 4, 14, 47, 28, 11, 14, 24, 30, 16, 14, 15, 30, 53 | 6, 4, 117, 30, 6, 14, 45, 28, 11, 16, 24, 30, 30, 2, 16, 30, 54 | 8, 4, 106, 26, 8, 13, 47, 28, 7, 22, 24, 30, 22, 13, 15, 30, 55 | 10, 2, 114, 28, 19, 4, 46, 28, 28, 6, 22, 28, 33, 4, 16, 30, 56 | 8, 4, 122, 30, 22, 3, 45, 28, 8, 26, 23, 30, 12, 28, 15, 30, 57 | 3, 10, 117, 30, 3, 23, 45, 28, 4, 31, 24, 30, 11, 31, 15, 30, 58 | 7, 7, 116, 30, 21, 7, 45, 28, 1, 37, 23, 30, 19, 26, 15, 30, 59 | 5, 10, 115, 30, 19, 10, 47, 28, 15, 25, 24, 30, 23, 25, 15, 30, 60 | 13, 3, 115, 30, 2, 29, 46, 28, 42, 1, 24, 30, 23, 28, 15, 30, 61 | 17, 0, 115, 30, 10, 23, 46, 28, 10, 35, 24, 30, 19, 35, 15, 30, 62 | 17, 1, 115, 30, 14, 21, 46, 28, 29, 19, 24, 30, 11, 46, 15, 30, 63 | 13, 6, 115, 30, 14, 23, 46, 28, 44, 7, 24, 30, 59, 1, 16, 30, 64 | 12, 7, 121, 30, 12, 26, 47, 28, 39, 14, 24, 30, 22, 41, 15, 30, 65 | 6, 14, 121, 30, 6, 34, 47, 28, 46, 10, 24, 30, 2, 64, 15, 30, 66 | 17, 4, 122, 30, 29, 14, 46, 28, 49, 10, 24, 30, 24, 46, 15, 30, 67 | 4, 18, 122, 30, 13, 32, 46, 28, 48, 14, 24, 30, 42, 32, 15, 30, 68 | 20, 4, 117, 30, 40, 7, 47, 28, 43, 22, 24, 30, 10, 67, 15, 30, 69 | 19, 6, 118, 30, 18, 31, 47, 28, 34, 34, 24, 30, 20, 61, 15, 30 70 | ]; 71 | 72 | // Galois field log table 73 | var glog = [ 74 | 0xff, 0x00, 0x01, 0x19, 0x02, 0x32, 0x1a, 0xc6, 0x03, 0xdf, 0x33, 0xee, 0x1b, 0x68, 0xc7, 0x4b, 75 | 0x04, 0x64, 0xe0, 0x0e, 0x34, 0x8d, 0xef, 0x81, 0x1c, 0xc1, 0x69, 0xf8, 0xc8, 0x08, 0x4c, 0x71, 76 | 0x05, 0x8a, 0x65, 0x2f, 0xe1, 0x24, 0x0f, 0x21, 0x35, 0x93, 0x8e, 0xda, 0xf0, 0x12, 0x82, 0x45, 77 | 0x1d, 0xb5, 0xc2, 0x7d, 0x6a, 0x27, 0xf9, 0xb9, 0xc9, 0x9a, 0x09, 0x78, 0x4d, 0xe4, 0x72, 0xa6, 78 | 0x06, 0xbf, 0x8b, 0x62, 0x66, 0xdd, 0x30, 0xfd, 0xe2, 0x98, 0x25, 0xb3, 0x10, 0x91, 0x22, 0x88, 79 | 0x36, 0xd0, 0x94, 0xce, 0x8f, 0x96, 0xdb, 0xbd, 0xf1, 0xd2, 0x13, 0x5c, 0x83, 0x38, 0x46, 0x40, 80 | 0x1e, 0x42, 0xb6, 0xa3, 0xc3, 0x48, 0x7e, 0x6e, 0x6b, 0x3a, 0x28, 0x54, 0xfa, 0x85, 0xba, 0x3d, 81 | 0xca, 0x5e, 0x9b, 0x9f, 0x0a, 0x15, 0x79, 0x2b, 0x4e, 0xd4, 0xe5, 0xac, 0x73, 0xf3, 0xa7, 0x57, 82 | 0x07, 0x70, 0xc0, 0xf7, 0x8c, 0x80, 0x63, 0x0d, 0x67, 0x4a, 0xde, 0xed, 0x31, 0xc5, 0xfe, 0x18, 83 | 0xe3, 0xa5, 0x99, 0x77, 0x26, 0xb8, 0xb4, 0x7c, 0x11, 0x44, 0x92, 0xd9, 0x23, 0x20, 0x89, 0x2e, 84 | 0x37, 0x3f, 0xd1, 0x5b, 0x95, 0xbc, 0xcf, 0xcd, 0x90, 0x87, 0x97, 0xb2, 0xdc, 0xfc, 0xbe, 0x61, 85 | 0xf2, 0x56, 0xd3, 0xab, 0x14, 0x2a, 0x5d, 0x9e, 0x84, 0x3c, 0x39, 0x53, 0x47, 0x6d, 0x41, 0xa2, 86 | 0x1f, 0x2d, 0x43, 0xd8, 0xb7, 0x7b, 0xa4, 0x76, 0xc4, 0x17, 0x49, 0xec, 0x7f, 0x0c, 0x6f, 0xf6, 87 | 0x6c, 0xa1, 0x3b, 0x52, 0x29, 0x9d, 0x55, 0xaa, 0xfb, 0x60, 0x86, 0xb1, 0xbb, 0xcc, 0x3e, 0x5a, 88 | 0xcb, 0x59, 0x5f, 0xb0, 0x9c, 0xa9, 0xa0, 0x51, 0x0b, 0xf5, 0x16, 0xeb, 0x7a, 0x75, 0x2c, 0xd7, 89 | 0x4f, 0xae, 0xd5, 0xe9, 0xe6, 0xe7, 0xad, 0xe8, 0x74, 0xd6, 0xf4, 0xea, 0xa8, 0x50, 0x58, 0xaf 90 | ]; 91 | 92 | // Galios field exponent table 93 | var gexp = [ 94 | 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1d, 0x3a, 0x74, 0xe8, 0xcd, 0x87, 0x13, 0x26, 95 | 0x4c, 0x98, 0x2d, 0x5a, 0xb4, 0x75, 0xea, 0xc9, 0x8f, 0x03, 0x06, 0x0c, 0x18, 0x30, 0x60, 0xc0, 96 | 0x9d, 0x27, 0x4e, 0x9c, 0x25, 0x4a, 0x94, 0x35, 0x6a, 0xd4, 0xb5, 0x77, 0xee, 0xc1, 0x9f, 0x23, 97 | 0x46, 0x8c, 0x05, 0x0a, 0x14, 0x28, 0x50, 0xa0, 0x5d, 0xba, 0x69, 0xd2, 0xb9, 0x6f, 0xde, 0xa1, 98 | 0x5f, 0xbe, 0x61, 0xc2, 0x99, 0x2f, 0x5e, 0xbc, 0x65, 0xca, 0x89, 0x0f, 0x1e, 0x3c, 0x78, 0xf0, 99 | 0xfd, 0xe7, 0xd3, 0xbb, 0x6b, 0xd6, 0xb1, 0x7f, 0xfe, 0xe1, 0xdf, 0xa3, 0x5b, 0xb6, 0x71, 0xe2, 100 | 0xd9, 0xaf, 0x43, 0x86, 0x11, 0x22, 0x44, 0x88, 0x0d, 0x1a, 0x34, 0x68, 0xd0, 0xbd, 0x67, 0xce, 101 | 0x81, 0x1f, 0x3e, 0x7c, 0xf8, 0xed, 0xc7, 0x93, 0x3b, 0x76, 0xec, 0xc5, 0x97, 0x33, 0x66, 0xcc, 102 | 0x85, 0x17, 0x2e, 0x5c, 0xb8, 0x6d, 0xda, 0xa9, 0x4f, 0x9e, 0x21, 0x42, 0x84, 0x15, 0x2a, 0x54, 103 | 0xa8, 0x4d, 0x9a, 0x29, 0x52, 0xa4, 0x55, 0xaa, 0x49, 0x92, 0x39, 0x72, 0xe4, 0xd5, 0xb7, 0x73, 104 | 0xe6, 0xd1, 0xbf, 0x63, 0xc6, 0x91, 0x3f, 0x7e, 0xfc, 0xe5, 0xd7, 0xb3, 0x7b, 0xf6, 0xf1, 0xff, 105 | 0xe3, 0xdb, 0xab, 0x4b, 0x96, 0x31, 0x62, 0xc4, 0x95, 0x37, 0x6e, 0xdc, 0xa5, 0x57, 0xae, 0x41, 106 | 0x82, 0x19, 0x32, 0x64, 0xc8, 0x8d, 0x07, 0x0e, 0x1c, 0x38, 0x70, 0xe0, 0xdd, 0xa7, 0x53, 0xa6, 107 | 0x51, 0xa2, 0x59, 0xb2, 0x79, 0xf2, 0xf9, 0xef, 0xc3, 0x9b, 0x2b, 0x56, 0xac, 0x45, 0x8a, 0x09, 108 | 0x12, 0x24, 0x48, 0x90, 0x3d, 0x7a, 0xf4, 0xf5, 0xf7, 0xf3, 0xfb, 0xeb, 0xcb, 0x8b, 0x0b, 0x16, 109 | 0x2c, 0x58, 0xb0, 0x7d, 0xfa, 0xe9, 0xcf, 0x83, 0x1b, 0x36, 0x6c, 0xd8, 0xad, 0x47, 0x8e, 0x00 110 | ]; 111 | 112 | // Working buffers: 113 | // data input and ecc append, image working buffer, fixed part of image, run lengths for badness 114 | var strinbuf = [], eccbuf = [], qrframe = [], framask = [], rlens = []; 115 | // Control values - width is based on version, last 4 are from table. 116 | var version, width, neccblk1, neccblk2, datablkw, eccblkwid; 117 | var ecclevel = 2; 118 | // set bit to indicate cell in qrframe is immutable. symmetric around diagonal 119 | function setmask(x, y) { 120 | var bt; 121 | if (x > y) { 122 | bt = x; 123 | x = y; 124 | y = bt; 125 | } 126 | // y*y = 1+3+5... 127 | bt = y; 128 | bt *= y; 129 | bt += y; 130 | bt >>= 1; 131 | bt += x; 132 | framask[bt] = 1; 133 | } 134 | 135 | // enter alignment pattern - black to qrframe, white to mask (later black frame merged to mask) 136 | function putalign(x, y) { 137 | var j; 138 | 139 | qrframe[x + width * y] = 1; 140 | for (j = -2; j < 2; j++) { 141 | qrframe[(x + j) + width * (y - 2)] = 1; 142 | qrframe[(x - 2) + width * (y + j + 1)] = 1; 143 | qrframe[(x + 2) + width * (y + j)] = 1; 144 | qrframe[(x + j + 1) + width * (y + 2)] = 1; 145 | } 146 | for (j = 0; j < 2; j++) { 147 | setmask(x - 1, y + j); 148 | setmask(x + 1, y - j); 149 | setmask(x - j, y - 1); 150 | setmask(x + j, y + 1); 151 | } 152 | } 153 | 154 | //======================================================================== 155 | // Reed Solomon error correction 156 | // exponentiation mod N 157 | function modnn(x) { 158 | while (x >= 255) { 159 | x -= 255; 160 | x = (x >> 8) + (x & 255); 161 | } 162 | return x; 163 | } 164 | 165 | var genpoly = []; 166 | 167 | // Calculate and append ECC data to data block. Block is in strinbuf, indexes to buffers given. 168 | function appendrs(data, dlen, ecbuf, eclen) { 169 | var i, j, fb; 170 | 171 | for (i = 0; i < eclen; i++) 172 | strinbuf[ecbuf + i] = 0; 173 | for (i = 0; i < dlen; i++) { 174 | fb = glog[strinbuf[data + i] ^ strinbuf[ecbuf]]; 175 | if (fb != 255) /* fb term is non-zero */ 176 | for (j = 1; j < eclen; j++) 177 | strinbuf[ecbuf + j - 1] = strinbuf[ecbuf + j] ^ gexp[modnn(fb + genpoly[eclen - j])]; 178 | else 179 | for (j = ecbuf; j < ecbuf + eclen; j++) 180 | strinbuf[j] = strinbuf[j + 1]; 181 | strinbuf[ecbuf + eclen - 1] = fb == 255 ? 0 : gexp[modnn(fb + genpoly[0])]; 182 | } 183 | } 184 | 185 | //======================================================================== 186 | // Frame data insert following the path rules 187 | 188 | // check mask - since symmetrical use half. 189 | function ismasked(x, y) { 190 | var bt; 191 | if (x > y) { 192 | bt = x; 193 | x = y; 194 | y = bt; 195 | } 196 | bt = y; 197 | bt += y * y; 198 | bt >>= 1; 199 | bt += x; 200 | return framask[bt]; 201 | } 202 | 203 | //======================================================================== 204 | // Apply the selected mask out of the 8. 205 | function applymask(m) { 206 | var x, y, r3x, r3y; 207 | 208 | switch (m) { 209 | case 0: 210 | for (y = 0; y < width; y++) 211 | for (x = 0; x < width; x++) 212 | if (!((x + y) & 1) && !ismasked(x, y)) 213 | qrframe[x + y * width] ^= 1; 214 | break; 215 | case 1: 216 | for (y = 0; y < width; y++) 217 | for (x = 0; x < width; x++) 218 | if (!(y & 1) && !ismasked(x, y)) 219 | qrframe[x + y * width] ^= 1; 220 | break; 221 | case 2: 222 | for (y = 0; y < width; y++) 223 | for (r3x = 0, x = 0; x < width; x++ , r3x++) { 224 | if (r3x == 3) 225 | r3x = 0; 226 | if (!r3x && !ismasked(x, y)) 227 | qrframe[x + y * width] ^= 1; 228 | } 229 | break; 230 | case 3: 231 | for (r3y = 0, y = 0; y < width; y++ , r3y++) { 232 | if (r3y == 3) 233 | r3y = 0; 234 | for (r3x = r3y, x = 0; x < width; x++ , r3x++) { 235 | if (r3x == 3) 236 | r3x = 0; 237 | if (!r3x && !ismasked(x, y)) 238 | qrframe[x + y * width] ^= 1; 239 | } 240 | } 241 | break; 242 | case 4: 243 | for (y = 0; y < width; y++) 244 | for (r3x = 0, r3y = ((y >> 1) & 1), x = 0; x < width; x++ , r3x++) { 245 | if (r3x == 3) { 246 | r3x = 0; 247 | r3y = !r3y; 248 | } 249 | if (!r3y && !ismasked(x, y)) 250 | qrframe[x + y * width] ^= 1; 251 | } 252 | break; 253 | case 5: 254 | for (r3y = 0, y = 0; y < width; y++ , r3y++) { 255 | if (r3y == 3) 256 | r3y = 0; 257 | for (r3x = 0, x = 0; x < width; x++ , r3x++) { 258 | if (r3x == 3) 259 | r3x = 0; 260 | if (!((x & y & 1) + !(!r3x | !r3y)) && !ismasked(x, y)) 261 | qrframe[x + y * width] ^= 1; 262 | } 263 | } 264 | break; 265 | case 6: 266 | for (r3y = 0, y = 0; y < width; y++ , r3y++) { 267 | if (r3y == 3) 268 | r3y = 0; 269 | for (r3x = 0, x = 0; x < width; x++ , r3x++) { 270 | if (r3x == 3) 271 | r3x = 0; 272 | if (!(((x & y & 1) + (r3x && (r3x == r3y))) & 1) && !ismasked(x, y)) 273 | qrframe[x + y * width] ^= 1; 274 | } 275 | } 276 | break; 277 | case 7: 278 | for (r3y = 0, y = 0; y < width; y++ , r3y++) { 279 | if (r3y == 3) 280 | r3y = 0; 281 | for (r3x = 0, x = 0; x < width; x++ , r3x++) { 282 | if (r3x == 3) 283 | r3x = 0; 284 | if (!(((r3x && (r3x == r3y)) + ((x + y) & 1)) & 1) && !ismasked(x, y)) 285 | qrframe[x + y * width] ^= 1; 286 | } 287 | } 288 | break; 289 | } 290 | return; 291 | } 292 | 293 | // Badness coefficients. 294 | var N1 = 3, N2 = 3, N3 = 40, N4 = 10; 295 | 296 | // Using the table of the length of each run, calculate the amount of bad image 297 | // - long runs or those that look like finders; called twice, once each for X and Y 298 | function badruns(length) { 299 | var i; 300 | var runsbad = 0; 301 | for (i = 0; i <= length; i++) 302 | if (rlens[i] >= 5) 303 | runsbad += N1 + rlens[i] - 5; 304 | // BwBBBwB as in finder 305 | for (i = 3; i < length - 1; i += 2) 306 | if (rlens[i - 2] == rlens[i + 2] 307 | && rlens[i + 2] == rlens[i - 1] 308 | && rlens[i - 1] == rlens[i + 1] 309 | && rlens[i - 1] * 3 == rlens[i] 310 | // white around the black pattern? Not part of spec 311 | && (rlens[i - 3] == 0 // beginning 312 | || i + 3 > length // end 313 | || rlens[i - 3] * 3 >= rlens[i] * 4 || rlens[i + 3] * 3 >= rlens[i] * 4) 314 | ) 315 | runsbad += N3; 316 | return runsbad; 317 | } 318 | 319 | // Calculate how bad the masked image is - blocks, imbalance, runs, or finders. 320 | function badcheck() { 321 | var x, y, h, b, b1; 322 | var thisbad = 0; 323 | var bw = 0; 324 | 325 | // blocks of same color. 326 | for (y = 0; y < width - 1; y++) 327 | for (x = 0; x < width - 1; x++) 328 | if ((qrframe[x + width * y] && qrframe[(x + 1) + width * y] 329 | && qrframe[x + width * (y + 1)] && qrframe[(x + 1) + width * (y + 1)]) // all black 330 | || !(qrframe[x + width * y] || qrframe[(x + 1) + width * y] 331 | || qrframe[x + width * (y + 1)] || qrframe[(x + 1) + width * (y + 1)])) // all white 332 | thisbad += N2; 333 | 334 | // X runs 335 | for (y = 0; y < width; y++) { 336 | rlens[0] = 0; 337 | for (h = b = x = 0; x < width; x++) { 338 | if ((b1 = qrframe[x + width * y]) == b) 339 | rlens[h]++; 340 | else 341 | rlens[++h] = 1; 342 | b = b1; 343 | bw += b ? 1 : -1; 344 | } 345 | thisbad += badruns(h); 346 | } 347 | 348 | // black/white imbalance 349 | if (bw < 0) 350 | bw = -bw; 351 | 352 | var big = bw; 353 | var count = 0; 354 | big += big << 2; 355 | big <<= 1; 356 | while (big > width * width) 357 | big -= width * width, count++; 358 | thisbad += count * N4; 359 | 360 | // Y runs 361 | for (x = 0; x < width; x++) { 362 | rlens[0] = 0; 363 | for (h = b = y = 0; y < width; y++) { 364 | if ((b1 = qrframe[x + width * y]) == b) 365 | rlens[h]++; 366 | else 367 | rlens[++h] = 1; 368 | b = b1; 369 | } 370 | thisbad += badruns(h); 371 | } 372 | return thisbad; 373 | } 374 | 375 | function genframe(instring) { 376 | var x, y, k, t, v, i, j, m; 377 | 378 | // find the smallest version that fits the string 379 | t = instring.length; 380 | version = 0; 381 | do { 382 | version++; 383 | k = (ecclevel - 1) * 4 + (version - 1) * 16; 384 | neccblk1 = eccblocks[k++]; 385 | neccblk2 = eccblocks[k++]; 386 | datablkw = eccblocks[k++]; 387 | eccblkwid = eccblocks[k]; 388 | k = datablkw * (neccblk1 + neccblk2) + neccblk2 - 3 + (version <= 9); 389 | if (t <= k) 390 | break; 391 | } while (version < 40); 392 | 393 | // FIXME - insure that it fits insted of being truncated 394 | width = 17 + 4 * version; 395 | 396 | // allocate, clear and setup data structures 397 | v = datablkw + (datablkw + eccblkwid) * (neccblk1 + neccblk2) + neccblk2; 398 | for (t = 0; t < v; t++) 399 | eccbuf[t] = 0; 400 | strinbuf = instring.slice(0); 401 | 402 | for (t = 0; t < width * width; t++) 403 | qrframe[t] = 0; 404 | 405 | for (t = 0; t < (width * (width + 1) + 1) / 2; t++) 406 | framask[t] = 0; 407 | 408 | // insert finders - black to frame, white to mask 409 | for (t = 0; t < 3; t++) { 410 | k = 0; 411 | y = 0; 412 | if (t == 1) 413 | k = (width - 7); 414 | if (t == 2) 415 | y = (width - 7); 416 | qrframe[(y + 3) + width * (k + 3)] = 1; 417 | for (x = 0; x < 6; x++) { 418 | qrframe[(y + x) + width * k] = 1; 419 | qrframe[y + width * (k + x + 1)] = 1; 420 | qrframe[(y + 6) + width * (k + x)] = 1; 421 | qrframe[(y + x + 1) + width * (k + 6)] = 1; 422 | } 423 | for (x = 1; x < 5; x++) { 424 | setmask(y + x, k + 1); 425 | setmask(y + 1, k + x + 1); 426 | setmask(y + 5, k + x); 427 | setmask(y + x + 1, k + 5); 428 | } 429 | for (x = 2; x < 4; x++) { 430 | qrframe[(y + x) + width * (k + 2)] = 1; 431 | qrframe[(y + 2) + width * (k + x + 1)] = 1; 432 | qrframe[(y + 4) + width * (k + x)] = 1; 433 | qrframe[(y + x + 1) + width * (k + 4)] = 1; 434 | } 435 | } 436 | 437 | // alignment blocks 438 | if (version > 1) { 439 | t = adelta[version]; 440 | y = width - 7; 441 | for (; ;) { 442 | x = width - 7; 443 | while (x > t - 3) { 444 | putalign(x, y); 445 | if (x < t) 446 | break; 447 | x -= t; 448 | } 449 | if (y <= t + 9) 450 | break; 451 | y -= t; 452 | putalign(6, y); 453 | putalign(y, 6); 454 | } 455 | } 456 | 457 | // single black 458 | qrframe[8 + width * (width - 8)] = 1; 459 | 460 | // timing gap - mask only 461 | for (y = 0; y < 7; y++) { 462 | setmask(7, y); 463 | setmask(width - 8, y); 464 | setmask(7, y + width - 7); 465 | } 466 | for (x = 0; x < 8; x++) { 467 | setmask(x, 7); 468 | setmask(x + width - 8, 7); 469 | setmask(x, width - 8); 470 | } 471 | 472 | // reserve mask-format area 473 | for (x = 0; x < 9; x++) 474 | setmask(x, 8); 475 | for (x = 0; x < 8; x++) { 476 | setmask(x + width - 8, 8); 477 | setmask(8, x); 478 | } 479 | for (y = 0; y < 7; y++) 480 | setmask(8, y + width - 7); 481 | 482 | // timing row/col 483 | for (x = 0; x < width - 14; x++) 484 | if (x & 1) { 485 | setmask(8 + x, 6); 486 | setmask(6, 8 + x); 487 | } 488 | else { 489 | qrframe[(8 + x) + width * 6] = 1; 490 | qrframe[6 + width * (8 + x)] = 1; 491 | } 492 | 493 | // version block 494 | if (version > 6) { 495 | t = vpat[version - 7]; 496 | k = 17; 497 | for (x = 0; x < 6; x++) 498 | for (y = 0; y < 3; y++ , k--) 499 | if (1 & (k > 11 ? version >> (k - 12) : t >> k)) { 500 | qrframe[(5 - x) + width * (2 - y + width - 11)] = 1; 501 | qrframe[(2 - y + width - 11) + width * (5 - x)] = 1; 502 | } 503 | else { 504 | setmask(5 - x, 2 - y + width - 11); 505 | setmask(2 - y + width - 11, 5 - x); 506 | } 507 | } 508 | 509 | // sync mask bits - only set above for white spaces, so add in black bits 510 | for (y = 0; y < width; y++) 511 | for (x = 0; x <= y; x++) 512 | if (qrframe[x + width * y]) 513 | setmask(x, y); 514 | 515 | // convert string to bitstream 516 | // 8 bit data to QR-coded 8 bit data (numeric or alphanum, or kanji not supported) 517 | v = strinbuf.length; 518 | 519 | // string to array 520 | for (i = 0; i < v; i++) 521 | eccbuf[i] = strinbuf.charCodeAt(i); 522 | strinbuf = eccbuf.slice(0); 523 | 524 | // calculate max string length 525 | x = datablkw * (neccblk1 + neccblk2) + neccblk2; 526 | if (v >= x - 2) { 527 | v = x - 2; 528 | if (version > 9) 529 | v--; 530 | } 531 | 532 | // shift and repack to insert length prefix 533 | i = v; 534 | if (version > 9) { 535 | strinbuf[i + 2] = 0; 536 | strinbuf[i + 3] = 0; 537 | while (i--) { 538 | t = strinbuf[i]; 539 | strinbuf[i + 3] |= 255 & (t << 4); 540 | strinbuf[i + 2] = t >> 4; 541 | } 542 | strinbuf[2] |= 255 & (v << 4); 543 | strinbuf[1] = v >> 4; 544 | strinbuf[0] = 0x40 | (v >> 12); 545 | } 546 | else { 547 | strinbuf[i + 1] = 0; 548 | strinbuf[i + 2] = 0; 549 | while (i--) { 550 | t = strinbuf[i]; 551 | strinbuf[i + 2] |= 255 & (t << 4); 552 | strinbuf[i + 1] = t >> 4; 553 | } 554 | strinbuf[1] |= 255 & (v << 4); 555 | strinbuf[0] = 0x40 | (v >> 4); 556 | } 557 | // fill to end with pad pattern 558 | i = v + 3 - (version < 10); 559 | while (i < x) { 560 | strinbuf[i++] = 0xec; 561 | // buffer has room if (i == x) break; 562 | strinbuf[i++] = 0x11; 563 | } 564 | 565 | // calculate and append ECC 566 | 567 | // calculate generator polynomial 568 | genpoly[0] = 1; 569 | for (i = 0; i < eccblkwid; i++) { 570 | genpoly[i + 1] = 1; 571 | for (j = i; j > 0; j--) 572 | genpoly[j] = genpoly[j] 573 | ? genpoly[j - 1] ^ gexp[modnn(glog[genpoly[j]] + i)] : genpoly[j - 1]; 574 | genpoly[0] = gexp[modnn(glog[genpoly[0]] + i)]; 575 | } 576 | for (i = 0; i <= eccblkwid; i++) 577 | genpoly[i] = glog[genpoly[i]]; // use logs for genpoly[] to save calc step 578 | 579 | // append ecc to data buffer 580 | k = x; 581 | y = 0; 582 | for (i = 0; i < neccblk1; i++) { 583 | appendrs(y, datablkw, k, eccblkwid); 584 | y += datablkw; 585 | k += eccblkwid; 586 | } 587 | for (i = 0; i < neccblk2; i++) { 588 | appendrs(y, datablkw + 1, k, eccblkwid); 589 | y += datablkw + 1; 590 | k += eccblkwid; 591 | } 592 | // interleave blocks 593 | y = 0; 594 | for (i = 0; i < datablkw; i++) { 595 | for (j = 0; j < neccblk1; j++) 596 | eccbuf[y++] = strinbuf[i + j * datablkw]; 597 | for (j = 0; j < neccblk2; j++) 598 | eccbuf[y++] = strinbuf[(neccblk1 * datablkw) + i + (j * (datablkw + 1))]; 599 | } 600 | for (j = 0; j < neccblk2; j++) 601 | eccbuf[y++] = strinbuf[(neccblk1 * datablkw) + i + (j * (datablkw + 1))]; 602 | for (i = 0; i < eccblkwid; i++) 603 | for (j = 0; j < neccblk1 + neccblk2; j++) 604 | eccbuf[y++] = strinbuf[x + i + j * eccblkwid]; 605 | strinbuf = eccbuf; 606 | 607 | // pack bits into frame avoiding masked area. 608 | x = y = width - 1; 609 | k = v = 1; // up, minus 610 | /* inteleaved data and ecc codes */ 611 | m = (datablkw + eccblkwid) * (neccblk1 + neccblk2) + neccblk2; 612 | for (i = 0; i < m; i++) { 613 | t = strinbuf[i]; 614 | for (j = 0; j < 8; j++ , t <<= 1) { 615 | if (0x80 & t) 616 | qrframe[x + width * y] = 1; 617 | do { // find next fill position 618 | if (v) 619 | x--; 620 | else { 621 | x++; 622 | if (k) { 623 | if (y != 0) 624 | y--; 625 | else { 626 | x -= 2; 627 | k = !k; 628 | if (x == 6) { 629 | x--; 630 | y = 9; 631 | } 632 | } 633 | } 634 | else { 635 | if (y != width - 1) 636 | y++; 637 | else { 638 | x -= 2; 639 | k = !k; 640 | if (x == 6) { 641 | x--; 642 | y -= 8; 643 | } 644 | } 645 | } 646 | } 647 | v = !v; 648 | } while (ismasked(x, y)); 649 | } 650 | } 651 | 652 | // save pre-mask copy of frame 653 | strinbuf = qrframe.slice(0); 654 | t = 0; // best 655 | y = 30000; // demerit 656 | // for instead of while since in original arduino code 657 | // if an early mask was "good enough" it wouldn't try for a better one 658 | // since they get more complex and take longer. 659 | for (k = 0; k < 8; k++) { 660 | applymask(k); // returns black-white imbalance 661 | x = badcheck(); 662 | if (x < y) { // current mask better than previous best? 663 | y = x; 664 | t = k; 665 | } 666 | if (t == 7) 667 | break; // don't increment i to a void redoing mask 668 | qrframe = strinbuf.slice(0); // reset for next pass 669 | } 670 | if (t != k) // redo best mask - none good enough, last wasn't t 671 | applymask(t); 672 | 673 | // add in final mask/ecclevel bytes 674 | y = fmtword[t + ((ecclevel - 1) << 3)]; 675 | // low byte 676 | for (k = 0; k < 8; k++ , y >>= 1) 677 | if (y & 1) { 678 | qrframe[(width - 1 - k) + width * 8] = 1; 679 | if (k < 6) 680 | qrframe[8 + width * k] = 1; 681 | else 682 | qrframe[8 + width * (k + 1)] = 1; 683 | } 684 | // high byte 685 | for (k = 0; k < 7; k++ , y >>= 1) 686 | if (y & 1) { 687 | qrframe[8 + width * (width - 7 + k)] = 1; 688 | if (k) 689 | qrframe[(6 - k) + width * 8] = 1; 690 | else 691 | qrframe[7 + width * 8] = 1; 692 | } 693 | return qrframe; 694 | } 695 | 696 | 697 | 698 | 699 | var _canvas = null; 700 | 701 | var api = { 702 | 703 | get ecclevel() { 704 | return ecclevel; 705 | }, 706 | 707 | set ecclevel(val) { 708 | ecclevel = val; 709 | }, 710 | 711 | get size() { 712 | return _size; 713 | }, 714 | 715 | set size(val) { 716 | _size = val 717 | }, 718 | 719 | get canvas() { 720 | return _canvas; 721 | }, 722 | 723 | set canvas(el) { 724 | _canvas = el; 725 | }, 726 | 727 | getFrame: function (string) { 728 | return genframe(string); 729 | }, 730 | //这里的utf16to8(str)是对Text中的字符串进行转码,让其支持中文 731 | utf16to8: function (str) { 732 | var out, i, len, c; 733 | 734 | out = ""; 735 | len = str.length; 736 | for (i = 0; i < len; i++) { 737 | c = str.charCodeAt(i); 738 | if ((c >= 0x0001) && (c <= 0x007F)) { 739 | out += str.charAt(i); 740 | } else if (c > 0x07FF) { 741 | out += String.fromCharCode(0xE0 | ((c >> 12) & 0x0F)); 742 | out += String.fromCharCode(0x80 | ((c >> 6) & 0x3F)); 743 | out += String.fromCharCode(0x80 | ((c >> 0) & 0x3F)); 744 | } else { 745 | out += String.fromCharCode(0xC0 | ((c >> 6) & 0x1F)); 746 | out += String.fromCharCode(0x80 | ((c >> 0) & 0x3F)); 747 | } 748 | } 749 | return out; 750 | }, 751 | /** 752 | * 新增$this参数,传入组件的this,兼容在组件中生成 753 | * @param bg 目前只能设置颜色值 754 | */ 755 | draw: function (str, ctx, startX, startY, cavW, cavH, bg, color, $this, ecc) { 756 | var that = this; 757 | ecclevel = ecc || ecclevel; 758 | if (!ctx) { 759 | console.warn('No canvas provided to draw QR code in!') 760 | return; 761 | } 762 | var size = Math.min(cavW, cavH); 763 | str = that.utf16to8(str);//增加中文显示 764 | 765 | var frame = that.getFrame(str); 766 | var px = size / width; 767 | if (bg) { 768 | ctx.setFillStyle(bg) 769 | ctx.fillRect(startX, startY, cavW, cavW); 770 | } 771 | ctx.setFillStyle(color || 'black'); 772 | for (var i = 0; i < width; i++) { 773 | for (var j = 0; j < width; j++) { 774 | if (frame[j * width + i]) { 775 | ctx.fillRect(startX + px * i, startY + px * j, px, px); 776 | } 777 | } 778 | } 779 | } 780 | } 781 | module.exports = { api } 782 | // exports.draw = api; 783 | 784 | })(); --------------------------------------------------------------------------------