├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | })();
--------------------------------------------------------------------------------