├── .npmrc ├── vitest.setup.ts ├── pnpm-workspace.yaml ├── playground ├── src │ ├── vite-env.d.ts │ ├── assets │ │ ├── logo.png │ │ └── style.css │ ├── main.ts │ ├── components │ │ └── Nav.vue │ └── App.vue ├── .gitignore ├── public │ └── favicon.ico ├── postcss.config.js ├── tailwind.config.js ├── tsconfig.node.json ├── vite.config.ts ├── index.html ├── package.json ├── tsconfig.json └── README.md ├── eslint.config.js ├── src ├── index.ts ├── utils │ ├── constants.ts │ ├── get-svg-path-from-stroke.ts │ └── h-demi.ts └── components │ └── VPerfectSignature.ts ├── tsup.config.ts ├── .gitignore ├── tsconfig.json ├── vitest.config.ts ├── LICENSE.md ├── package.json ├── test ├── VPerfectSignature.test.ts └── mock.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'vitest-canvas-mock' 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | -------------------------------------------------------------------------------- /playground/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | 3 | export default antfu() 4 | -------------------------------------------------------------------------------- /playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wobsoriano/v-perfect-signature/HEAD/playground/public/favicon.ico -------------------------------------------------------------------------------- /playground/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wobsoriano/v-perfect-signature/HEAD/playground/src/assets/logo.png -------------------------------------------------------------------------------- /playground/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /playground/src/assets/style.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './assets/style.css' 3 | 4 | import App from './App.vue' 5 | 6 | createApp(App).mount('#app') 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import VPerfectSignature from './components/VPerfectSignature' 2 | 3 | export { 4 | VPerfectSignature, 5 | } 6 | 7 | export default VPerfectSignature 8 | -------------------------------------------------------------------------------- /playground/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'jit', 3 | content: ['./index.html', './src/**/*.{js,ts,vue}'], 4 | darkMode: 'class', 5 | variants: {}, 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_BACKGROUND_COLOR = 'rgba(0,0,0,0)' 2 | export const DEFAULT_PEN_COLOR = '#000' 3 | export const DEFAULT_WIDTH = '100%' 4 | export const DEFAULT_HEIGHT = '100%' 5 | export const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml'] 6 | -------------------------------------------------------------------------------- /playground/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "skipLibCheck": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | splitting: false, 6 | sourcemap: true, 7 | clean: true, 8 | format: ['cjs', 'esm', 'iife'], 9 | globalName: 'VPerfectSignature', 10 | dts: true, 11 | }) 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | // eslint-disable-next-line node/prefer-global/process 8 | base: process.env.NODE_ENV === 'production' ? '/v-perfect-signature/' : './', 9 | }) 10 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | v-perfect-signature 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/utils/get-svg-path-from-stroke.ts: -------------------------------------------------------------------------------- 1 | export default function getSvgPathFromStroke(stroke: number[][]) { 2 | if (!stroke.length) 3 | return '' 4 | 5 | const d = stroke.reduce( 6 | (acc, [x0, y0], i, arr) => { 7 | const [x1, y1] = arr[(i + 1) % arr.length] 8 | acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2) 9 | return acc 10 | }, 11 | ['M', ...stroke[0], 'Q'], 12 | ) 13 | 14 | d.push('Z') 15 | return d.join(' ') 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "baseUrl": ".", 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "types": ["vitest/globals"], 9 | "allowJs": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noEmit": true, 14 | "outDir": "dist", 15 | "esModuleInterop": true, 16 | "skipLibCheck": true 17 | }, 18 | "include": [ 19 | "src/**/*.ts", 20 | "src/**/*.vue", 21 | "shims-vue.d.ts" 22 | ], 23 | "exclude": [ 24 | "node_modules" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /playground/src/components/Nav.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | setupFiles: ['./vitest.setup.ts'], 6 | environment: 'jsdom', 7 | deps: { 8 | optimizer: { 9 | web: { 10 | include: ['vitest-canvas-mock'], 11 | }, 12 | }, 13 | }, 14 | // For this config, check https://github.com/vitest-dev/vitest/issues/740 15 | poolOptions: { 16 | threads: { 17 | singleThread: true, 18 | }, 19 | }, 20 | environmentOptions: { 21 | jsdom: { 22 | resources: 'usable', 23 | }, 24 | }, 25 | }, 26 | }) 27 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vue-tsc --noEmit && vite build", 8 | "serve": "vite preview", 9 | "deploy": "gh-pages -d dist", 10 | "clean": "rm -rf node_modules && rm -rf dist" 11 | }, 12 | "dependencies": { 13 | "v-perfect-signature": "*", 14 | "vue": "^3.3.11" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^20.10.4", 18 | "@vitejs/plugin-vue": "^4.5.2", 19 | "autoprefixer": "^10.4.16", 20 | "gh-pages": "^6.1.0", 21 | "postcss": "^8.4.32", 22 | "tailwindcss": "^3.3.6", 23 | "typescript": "^5.3.3", 24 | "vite": "^5.0.8", 25 | "vue-tsc": "^1.8.25" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "jsx": "preserve", 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "useDefineForClassFields": true, 7 | "module": "ESNext", 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "allowImportingTsExtensions": true, 13 | 14 | /* Linting */ 15 | "strict": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noEmit": true, 20 | "isolatedModules": true, 21 | "skipLibCheck": true 22 | }, 23 | "references": [{ "path": "./tsconfig.node.json" }], 24 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/h-demi.ts: -------------------------------------------------------------------------------- 1 | import { h as hDemi, isVue2 } from 'vue-demi' 2 | 3 | interface Options { 4 | props?: Record 5 | domProps?: Record 6 | on?: Record 7 | } 8 | 9 | function adaptOnsV3(ons: Record) { 10 | if (!ons) 11 | return null 12 | return Object.entries(ons).reduce((ret, [key, handler]) => { 13 | key = key.charAt(0).toUpperCase() + key.slice(1) 14 | key = `on${key}` 15 | return { ...ret, [key]: handler } 16 | }, {}) 17 | } 18 | 19 | function h(type: string | Record, options: Options & any = {}, children?: any) { 20 | if (isVue2) 21 | return hDemi(type, options, children) 22 | 23 | const { props, domProps, on, ...extraOptions } = options 24 | 25 | const ons = adaptOnsV3(on) 26 | const params = { ...extraOptions, ...props, ...domProps, ...ons } 27 | return hDemi(type, params, children) 28 | } 29 | 30 | export default h 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robert Soriano 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /playground/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + Typescript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and Typescript in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur). Make sure to enable `vetur.experimental.templateInterpolationService` in settings! 8 | 9 | ### If Using ` 35 | 36 | 59 | 60 | 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "v-perfect-signature", 3 | "type": "module", 4 | "version": "1.4.0", 5 | "description": "Perfect pressure-sensitive signature drawing for Vue 2 and 3", 6 | "license": "MIT", 7 | "homepage": "https://github.com/wobsoriano/v-perfect-signature#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/wobsoriano/v-perfect-signature.git" 11 | }, 12 | "keywords": [ 13 | "vue", 14 | "signature", 15 | "pad", 16 | "freehand" 17 | ], 18 | "exports": { 19 | ".": { 20 | "types": { 21 | "import": "./dist/index.d.ts", 22 | "require": "./dist/index.d.cts" 23 | }, 24 | "import": "./dist/index.js", 25 | "require": "./dist/index.cjs" 26 | } 27 | }, 28 | "main": "./dist/index.cjs", 29 | "module": "./dist/index.js", 30 | "unpkg": "dist/index.global.js", 31 | "jsdelivr": "dist/index.global.js", 32 | "types": "./dist/index.d.ts", 33 | "typesVersions": { 34 | "*": { 35 | "*": [ 36 | "./dist/*" 37 | ] 38 | } 39 | }, 40 | "files": [ 41 | "dist", 42 | "nuxt.mjs" 43 | ], 44 | "scripts": { 45 | "dev": "tsup --watch --onSuccess \"pnpm --filter playground dev\"", 46 | "build": "tsup", 47 | "prepublishOnly": "pnpm run build", 48 | "test": "vitest run", 49 | "release": "bumpp && pnpm publish", 50 | "lint": "eslint .", 51 | "lint:fix": "eslint . --fix" 52 | }, 53 | "peerDependencies": { 54 | "@vue/composition-api": "^1.7.1", 55 | "vue": "^2.6.14 || ^3.2.0" 56 | }, 57 | "peerDependenciesMeta": { 58 | "@vue/composition-api": { 59 | "optional": true 60 | } 61 | }, 62 | "dependencies": { 63 | "perfect-freehand": "^1.2.0", 64 | "vue-demi": "^0.14.6" 65 | }, 66 | "devDependencies": { 67 | "@antfu/eslint-config": "^2.4.5", 68 | "@vue/test-utils": "2.4.1", 69 | "bumpp": "^9.2.1", 70 | "canvas": "^2.11.2", 71 | "eslint": "^8.55.0", 72 | "jsdom": "^23.0.1", 73 | "tslib": "^2.6.2", 74 | "tsup": "^8.0.1", 75 | "typescript": "^5.3.3", 76 | "vitest": "^1.0.4", 77 | "vitest-canvas-mock": "^0.3.3", 78 | "vue": "^3.3.11" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/VPerfectSignature.test.ts: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | 3 | import { describe, expect, it, vi } from 'vitest' 4 | import VPerfectSignature from '../src' 5 | import { inputPointsMockData, mockDataURL } from './mock' 6 | 7 | describe('#props', () => { 8 | it('should receive default props', () => { 9 | const wrapper = shallowMount(VPerfectSignature) 10 | 11 | const expectedWidth = '100%' 12 | const expectedHeight = '100%' 13 | const expectedPenColor = '#000' 14 | const expectedBackgroundColor = 'rgba(0,0,0,0)' 15 | const expectedStrokeOptions = {} 16 | 17 | expect(wrapper.props().width).toBe(expectedWidth) 18 | expect(wrapper.props().height).toBe(expectedHeight) 19 | expect(wrapper.props().penColor).toBe(expectedPenColor) 20 | expect(wrapper.props().backgroundColor).toBe(expectedBackgroundColor) 21 | expect(wrapper.props().strokeOptions).toEqual(expectedStrokeOptions) 22 | }) 23 | }) 24 | 25 | describe('#toDataURL', () => { 26 | it('throws error if invalid type', () => { 27 | const wrapper = shallowMount(VPerfectSignature) 28 | 29 | expect(() => wrapper.vm.toDataURL('text/html')).toThrow() 30 | }) 31 | 32 | it('returns undefined if pad is empty', () => { 33 | const wrapper = shallowMount(VPerfectSignature) 34 | 35 | expect(wrapper.vm.toDataURL()).toBeUndefined() 36 | }) 37 | 38 | // TODO: Returns incorrect data url. Bug? 39 | it.todo('should return data uri', async () => { 40 | const wrapper = shallowMount(VPerfectSignature) 41 | 42 | await wrapper.setData({ 43 | allInputPoints: inputPointsMockData, 44 | }) 45 | 46 | expect(wrapper.vm.toDataURL()).toBe(mockDataURL) 47 | }) 48 | }) 49 | 50 | describe('#fromDataURL', () => { 51 | it('should set signature from data uri', async () => { 52 | const wrapper = shallowMount(VPerfectSignature) 53 | await expect(wrapper.vm.fromDataURL(mockDataURL)).resolves.toBe(true) 54 | }) 55 | 56 | vi.spyOn(console, 'error').mockImplementation(() => {}) 57 | 58 | it('fails if data uri is incorrect', async () => { 59 | const wrapper = shallowMount(VPerfectSignature) 60 | 61 | await expect(wrapper.vm.fromDataURL('random string')).rejects.toThrow( 62 | 'Incorrect data uri provided', 63 | ) 64 | }) 65 | }) 66 | 67 | describe('#toData', () => { 68 | it('returns array of array input points', () => { 69 | const wrapper = shallowMount(VPerfectSignature) 70 | 71 | wrapper.setData({ 72 | allInputPoints: inputPointsMockData, 73 | }) 74 | 75 | expect(wrapper.vm.toData()).toEqual(inputPointsMockData) 76 | }) 77 | 78 | it('should set signature from array of array of input points', () => { 79 | const wrapper = shallowMount(VPerfectSignature) 80 | 81 | expect(wrapper.vm.fromData(inputPointsMockData)).toBeUndefined() 82 | expect(wrapper.vm.toData()).toEqual(inputPointsMockData) 83 | }) 84 | }) 85 | 86 | describe('#clear', () => { 87 | it('clears data structures and pad', () => { 88 | const wrapper = shallowMount(VPerfectSignature) 89 | 90 | wrapper.setData({ 91 | allInputPoints: inputPointsMockData, 92 | }) 93 | wrapper.vm.clear() 94 | 95 | expect(wrapper.vm.toData()).toEqual([]) 96 | }) 97 | }) 98 | 99 | describe('#isEmpty', () => { 100 | it('returns true if pad is empty', () => { 101 | const wrapper = shallowMount(VPerfectSignature) 102 | 103 | expect(wrapper.vm.isEmpty()).toBe(true) 104 | }) 105 | 106 | it('returns false if pad is not empty', () => { 107 | const wrapper = shallowMount(VPerfectSignature) 108 | 109 | wrapper.setData({ 110 | allInputPoints: inputPointsMockData, 111 | }) 112 | 113 | expect(wrapper.vm.isEmpty()).toBe(false) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # v-perfect-signature 2 | 3 | Pressure-sensitive signature drawing for Vue 2 and 3 built on top of [perfect-freehand](https://github.com/steveruizok/perfect-freehand). 4 | 5 | Demo: https://wobsoriano.github.io/v-perfect-signature 6 | 7 | ## Install 8 | 9 | ```bash 10 | pnpm add v-perfect-signature 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```vue 16 | 33 | 34 | 37 | ``` 38 | 39 | ## Props 40 | 41 | | Name | Type | Default | Description | 42 | | ----------------- | ------ | -------------------------------------------------------------------- | ------------------------ | 43 | | `width` | String | `100%` | Set canvas width | 44 | | `height` | String | `100%` | Set canvas height | 45 | | `backgroundColor` | String | `rgba(0,0,0,0)` | Canvas background color | 46 | | `penColor` | String | `#000` | Canvas pen color | 47 | | `strokeOptions` | Object | [Reference](https://github.com/steveruizok/perfect-freehand#options) | Perfect freehand options | 48 | 49 | ## Methods 50 | 51 | | Name | Argument Type | Description | 52 | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | 53 | | `toDataURL(type)` | [String](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL) | Returns signature image as data URL | 54 | | `fromDataURL(dataUri)` | [String](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) | Draws signature image from data URL | 55 | | `toData` | - | Returns signature image as an array of array of input points | 56 | | `fromData(data)` | [Array](https://github.com/wobsoriano/v-perfect-signature/blob/master/packages/lib/src/components/__tests__/mock.ts#L1) | Draws signature image from array of array of input points | 57 | | `clear()` | - | Clears the canvas | 58 | | `isEmpty()` | - | Returns true if canvas is empty | 59 | | `resizeCanvas(shouldClear)` | `Boolean` | Resizes and recalculate dimensions | 60 | 61 | Note: Like [signature_pad](https://github.com/szimek/signature_pad), `fromDataURL` does not populate internal data structure. Thus, after using `fromDataURL`, `toData` won't work properly. 62 | 63 | ## Events 64 | 65 | | Name | Type | Default | Description | 66 | | --------- | -------- | ------- | ----------------------- | 67 | | `onBegin` | Function | - | Fired when stroke begin | 68 | | `onEnd` | Function | - | Fired when stroke end | 69 | 70 | ## Nuxt 71 | 72 | ```js 73 | export default defineNuxtConfig({ 74 | build: { 75 | transpile: ['v-perfect-signature'] 76 | } 77 | }) 78 | ``` 79 | 80 | ## License 81 | 82 | MIT 83 | -------------------------------------------------------------------------------- /src/components/VPerfectSignature.ts: -------------------------------------------------------------------------------- 1 | import type { PropType } from 'vue-demi' 2 | import { defineComponent } from 'vue-demi' 3 | import type { StrokeOptions } from 'perfect-freehand' 4 | import * as PerfectFreehand from 'perfect-freehand' 5 | 6 | import h from '../utils/h-demi' 7 | import getSvgPathFromStroke from '../utils/get-svg-path-from-stroke' 8 | import { 9 | DEFAULT_BACKGROUND_COLOR, 10 | DEFAULT_HEIGHT, 11 | DEFAULT_PEN_COLOR, 12 | DEFAULT_WIDTH, 13 | IMAGE_TYPES, 14 | } from '../utils/constants' 15 | 16 | const { getStroke } = PerfectFreehand 17 | 18 | type InputPoints = number[] 19 | 20 | export default defineComponent({ 21 | props: { 22 | width: { 23 | type: String, 24 | required: false, 25 | default: DEFAULT_WIDTH, 26 | }, 27 | height: { 28 | type: String, 29 | required: false, 30 | default: DEFAULT_HEIGHT, 31 | }, 32 | backgroundColor: { 33 | type: String, 34 | required: false, 35 | default: DEFAULT_BACKGROUND_COLOR, 36 | }, 37 | penColor: { 38 | type: String, 39 | required: false, 40 | default: DEFAULT_PEN_COLOR, 41 | }, 42 | strokeOptions: { 43 | type: Object as PropType, 44 | required: false, 45 | default: () => ({}), 46 | }, 47 | }, 48 | emits: ['onBegin', 'onEnd'], 49 | data: () => ({ 50 | allInputPoints: [] as InputPoints[][], 51 | currentInputPoints: null as InputPoints[] | null, 52 | isDrawing: false, 53 | isLocked: false, 54 | cachedImages: [] as HTMLImageElement[], 55 | ctx: null as null | CanvasRenderingContext2D, 56 | }), 57 | watch: { 58 | backgroundColor() { 59 | this.setBackgroundAndPenColor() 60 | }, 61 | penColor(color: string) { 62 | const ctx = this.getCanvasContext() 63 | if (ctx) 64 | ctx.fillStyle = color 65 | }, 66 | allInputPoints: { 67 | deep: true, 68 | handler() { 69 | this.inputPointsHandler() 70 | }, 71 | }, 72 | currentInputPoints: { 73 | deep: true, 74 | handler() { 75 | this.inputPointsHandler() 76 | }, 77 | }, 78 | }, 79 | mounted() { 80 | this.resizeCanvas() 81 | }, 82 | methods: { 83 | _drawImage(image: HTMLImageElement) { 84 | const canvas = this.getCanvasElement() 85 | const ctx = this.getCanvasContext() 86 | const dpr = window.devicePixelRatio || 1 87 | 88 | ctx?.scale(1 / dpr, 1 / dpr) // To allow proper scaling of the image on HiDPI, we need to reset the scaling before calling `drawImage` 89 | ctx?.drawImage(image, 0, 0, canvas.width, canvas.height) 90 | ctx?.scale(dpr, dpr) // Set scaling back to original value 91 | }, 92 | handlePointerDown(e: PointerEvent) { 93 | if (this.isLocked) 94 | return 95 | 96 | e.preventDefault() 97 | 98 | const canvas = e.composedPath()[0] as HTMLCanvasElement 99 | const rect = canvas.getBoundingClientRect() 100 | const x = e.clientX - rect.left 101 | const y = e.clientY - rect.top 102 | 103 | this.currentInputPoints = [[x, y, e.pressure]] 104 | this.isDrawing = true 105 | this.$emit('onBegin', e) 106 | }, 107 | handlePointerMove(e: PointerEvent) { 108 | if (this.isLocked) 109 | return 110 | if (!this.isDrawing) 111 | return 112 | 113 | if (e.buttons === 1) { 114 | e.preventDefault() 115 | 116 | const canvas = e.composedPath()[0] as HTMLCanvasElement 117 | const rect = canvas.getBoundingClientRect() 118 | const x = e.clientX - rect.left 119 | const y = e.clientY - rect.top 120 | 121 | this.currentInputPoints = [ 122 | ...(this.currentInputPoints ?? []), 123 | [x, y, e.pressure], 124 | ] 125 | } 126 | }, 127 | handlePointerUp(e: PointerEvent) { 128 | if (this.isLocked) 129 | return 130 | 131 | e.preventDefault() 132 | this.isDrawing = false 133 | 134 | if (!this.currentInputPoints) 135 | return 136 | 137 | this.allInputPoints = [...this.allInputPoints, this.currentInputPoints] 138 | this.currentInputPoints = null 139 | 140 | this.$emit('onEnd', e) 141 | }, 142 | handlePointerEnter(e: PointerEvent) { 143 | if (this.isLocked) 144 | return 145 | 146 | if (e.buttons === 1) 147 | this.handlePointerDown(e) 148 | }, 149 | handlePointerLeave(e: PointerEvent) { 150 | if (this.isLocked) 151 | return 152 | if (!this.isDrawing) 153 | return 154 | this.handlePointerUp(e) 155 | }, 156 | isEmpty() { 157 | return !this.allInputPoints.length && !this.cachedImages.length 158 | }, 159 | clear() { 160 | this.cachedImages = [] 161 | this.allInputPoints = [] 162 | this.currentInputPoints = null 163 | }, 164 | fromData(data: InputPoints[][]) { 165 | this.allInputPoints = [...this.allInputPoints, ...data] 166 | }, 167 | lock() { 168 | this.isLocked = true 169 | }, 170 | unlock() { 171 | this.isLocked = false 172 | }, 173 | toData() { 174 | return this.allInputPoints 175 | }, 176 | fromDataURL(data: string) { 177 | return new Promise((resolve, reject) => { 178 | const image = new Image() 179 | 180 | image.onload = () => { 181 | this._drawImage(image) 182 | this.cachedImages.push(image) 183 | resolve(true) 184 | } 185 | 186 | image.onerror = () => { 187 | reject(new Error('Incorrect data uri provided')) 188 | } 189 | 190 | image.crossOrigin = 'anonymous' 191 | image.src = data 192 | }) 193 | }, 194 | toDataURL(type?: string) { 195 | if (type && !IMAGE_TYPES.includes(type)) { 196 | throw new Error( 197 | `Incorrect image type. Must be one of ${IMAGE_TYPES.join(', ')}.`, 198 | ) 199 | } 200 | 201 | if (this.isEmpty()) 202 | return 203 | 204 | const canvas = this.getCanvasElement() 205 | return canvas.toDataURL(type ?? 'image/png') 206 | }, 207 | getCanvasElement() { 208 | return this.$refs.signaturePad as HTMLCanvasElement 209 | }, 210 | getCanvasContext() { 211 | if (!this.ctx) { 212 | const canvas = this.getCanvasElement() 213 | this.ctx = canvas.getContext('2d') 214 | } 215 | return this.ctx 216 | }, 217 | setBackgroundAndPenColor() { 218 | const canvas = this.getCanvasElement() 219 | const ctx = canvas.getContext('2d') 220 | ctx!.fillStyle = this.backgroundColor 221 | ctx?.fillRect(0, 0, canvas.width, canvas.height) 222 | ctx!.fillStyle = this.penColor 223 | }, 224 | resizeCanvas(clearCanvas = true) { 225 | const canvas = this.getCanvasElement() 226 | const rect = canvas.getBoundingClientRect() 227 | const dpr = window.devicePixelRatio || 1 228 | 229 | canvas.width = rect.width * dpr 230 | canvas.height = rect.height * dpr 231 | const ctx = this.getCanvasContext() 232 | ctx?.scale(dpr, dpr) 233 | 234 | canvas.style.width = `${rect.width}px` 235 | canvas.style.height = `${rect.height}px` 236 | 237 | if (clearCanvas) { 238 | ctx?.clearRect(0, 0, canvas.width, canvas.height) 239 | this.clear() 240 | } 241 | 242 | this.setBackgroundAndPenColor() 243 | }, 244 | inputPointsHandler() { 245 | const canvas = this.getCanvasElement() 246 | const ctx = this.getCanvasContext() 247 | 248 | // Makes smooth lines 249 | ctx?.clearRect(0, 0, canvas.width, canvas.height) 250 | 251 | this.cachedImages.forEach(image => this._drawImage(image)) 252 | this.setBackgroundAndPenColor() 253 | 254 | this.allInputPoints.forEach((point: InputPoints[]) => { 255 | const pathData = getSvgPathFromStroke( 256 | getStroke(point, this.strokeOptions), 257 | ) 258 | const myPath = new Path2D(pathData) 259 | ctx?.fill(myPath) 260 | }) 261 | 262 | if (!this.currentInputPoints) 263 | return 264 | const pathData = getSvgPathFromStroke( 265 | getStroke(this.currentInputPoints!, this.strokeOptions), 266 | ) 267 | const myPath = new Path2D(pathData) 268 | ctx?.fill(myPath) 269 | }, 270 | }, 271 | render() { 272 | const { width, height } = this 273 | 274 | const handlers = { 275 | pointerdown: this.handlePointerDown, 276 | pointerup: this.handlePointerUp, 277 | pointermove: this.handlePointerMove, 278 | pointerenter: this.handlePointerEnter, 279 | pointerleave: this.handlePointerLeave, 280 | } 281 | 282 | return h('canvas', { 283 | ref: 'signaturePad', 284 | style: { 285 | height, 286 | width, 287 | touchAction: 'none', 288 | }, 289 | on: handlers, 290 | }) 291 | }, 292 | }) 293 | -------------------------------------------------------------------------------- /test/mock.ts: -------------------------------------------------------------------------------- 1 | export const inputPointsMockData = [ 2 | [ 3 | [213.0234375, 415.578125, 0.5], 4 | [213.25, 415.578125, 0.5], 5 | [213.25, 415.34765625, 0.5], 6 | [213.4765625, 415.34765625, 0.5], 7 | [215.1015625, 415.34765625, 0.5], 8 | [218.99609375, 414.234375, 0.5], 9 | [228.234375, 410.93359375, 0.5], 10 | [235.30078125, 407.07421875, 0.5], 11 | [251.10546875, 397.015625, 0.5], 12 | [272.53125, 381.5859375, 0.5], 13 | [293.80078125, 363.859375, 0.5], 14 | [299.58203125, 358.07421875, 0.5], 15 | [315.0078125, 336.64453125, 0.5], 16 | [323.46875, 320.41796875, 0.5], 17 | [329.80859375, 299.01171875, 0.5], 18 | [331.8984375, 283.6640625, 0.5], 19 | [332.59765625, 266.7890625, 0.5], 20 | [330.44140625, 248.109375, 0.5], 21 | [326.25390625, 232.76171875, 0.5], 22 | [322.25, 223.42578125, 0.5], 23 | [318.53515625, 217.85546875, 0.5], 24 | [316.4453125, 215.2421875, 0.5], 25 | [314.0625, 213.8125, 0.5], 26 | [313.1875, 213.8125, 0.5], 27 | [312.95703125, 213.8125, 0.5], 28 | [312.7265625, 214.0390625, 0.5], 29 | [311.91015625, 215.6640625, 0.5], 30 | [306.484375, 225.8359375, 0.5], 31 | [302.38671875, 238.125, 0.5], 32 | [297.62890625, 260.3203125, 0.5], 33 | [294.66796875, 294.828125, 0.5], 34 | [294.66796875, 310.93359375, 0.5], 35 | [296.31640625, 334.88671875, 0.5], 36 | [300.625, 353.5625, 0.5], 37 | [305.50390625, 368.20703125, 0.5], 38 | [308.59375, 373.7734375, 0.5], 39 | [313.15625, 381.59375, 0.5], 40 | [315.9375, 384.9296875, 0.5], 41 | [317.15625, 386.1484375, 0.5], 42 | [317.15625, 386.375, 0.5], 43 | [317.15625, 386.14453125, 0.5], 44 | [317.15625, 385.9140625, 0.5], 45 | [316.74609375, 384.28515625, 0.5], 46 | [316.74609375, 381.15234375, 0.5], 47 | [316.74609375, 374.203125, 0.5], 48 | [317.38671875, 366.48828125, 0.5], 49 | [319.95703125, 358.7734375, 0.5], 50 | [325.171875, 351.6015625, 0.5], 51 | [326.390625, 350.37890625, 0.5], 52 | [335.72265625, 345.7109375, 0.5], 53 | [342.66796875, 345.078125, 0.5], 54 | [346.5625, 346.1875, 0.5], 55 | [351.5078125, 349.8984375, 0.5], 56 | [353.41015625, 351.80078125, 0.5], 57 | [357.625, 356.015625, 0.5], 58 | [364.69140625, 359.87109375, 0.5], 59 | [377.89453125, 362.23828125, 0.5], 60 | [383.3125, 362.23828125, 0.5], 61 | [387.20703125, 361.125, 0.5], 62 | [395.02734375, 357.2109375, 0.5], 63 | [403.02734375, 351.20703125, 0.5], 64 | [408.80859375, 345.421875, 0.5], 65 | [417.73828125, 335.11328125, 0.5], 66 | [424.60546875, 323.4296875, 0.5], 67 | [427.26953125, 313.42578125, 0.5], 68 | [427.87109375, 308.00390625, 0.5], 69 | [427.2109375, 298.765625, 0.5], 70 | [425.78125, 296.859375, 0.5], 71 | [425.55078125, 296.62890625, 0.5], 72 | [425.08984375, 296.62890625, 0.5], 73 | [424.859375, 296.85546875, 0.5], 74 | [422.4765625, 298.28125, 0.5], 75 | [418.76171875, 303.2265625, 0.5], 76 | [415.046875, 308.79296875, 0.5], 77 | [411.1328125, 316.61328125, 0.5], 78 | [408.65625, 322.796875, 0.5], 79 | [405.96484375, 333.55859375, 0.5], 80 | [405.28515625, 345.0859375, 0.5], 81 | [406.5859375, 353.55859375, 0.5], 82 | [412.046875, 365.1640625, 0.5], 83 | [415.7578125, 370.109375, 0.5], 84 | [423.828125, 377.5078125, 0.5], 85 | [433.16015625, 381.5078125, 0.5], 86 | [450.03125, 383.61328125, 0.5], 87 | [456.21484375, 383.61328125, 0.5], 88 | [469.26953125, 379.48828125, 0.5], 89 | [481.765625, 371.84765625, 0.5], 90 | [491.484375, 360.734375, 0.5], 91 | [498.45703125, 346.78125, 0.5], 92 | [499.69140625, 340.59375, 0.5], 93 | [500.37890625, 326.7734375, 0.5], 94 | [497.57421875, 310.6640625, 0.5], 95 | [492.6328125, 293.02734375, 0.5], 96 | [487.1328125, 280.65625, 0.5], 97 | [485.32421875, 275.234375, 0.5], 98 | [480.54296875, 263.625, 0.5], 99 | [477.17578125, 252.859375, 0.5], 100 | [475.31640625, 246.671875, 0.5], 101 | [474.79296875, 243.5390625, 0.5], 102 | [474.79296875, 243.30859375, 0.5], 103 | [474.5625, 243.765625, 0.5], 104 | [474.5625, 244.63671875, 0.5], 105 | [474.5625, 253.875, 0.5], 106 | [474.5625, 261.5859375, 0.5], 107 | [475.265625, 279.21875, 0.5], 108 | [478.0859375, 296.8515625, 0.5], 109 | [484.42578125, 318.25390625, 0.5], 110 | [488.21484375, 324.5703125, 0.5], 111 | [496.50390625, 336.3125, 0.5], 112 | [506.12109375, 345.9296875, 0.5], 113 | [513.37890625, 351.8671875, 0.5], 114 | [522.7109375, 356.53125, 0.5], 115 | [525.83984375, 357.05078125, 0.5], 116 | [532.0234375, 357.05078125, 0.5], 117 | [539.84375, 353.13671875, 0.5], 118 | [547.9140625, 345.0625, 0.5], 119 | [555.51171875, 333.31640625, 0.5], 120 | [560.17578125, 323.98046875, 0.5], 121 | [567.30859375, 302.57421875, 0.5], 122 | [570.1171875, 285.69921875, 0.5], 123 | [571.55078125, 267.01953125, 0.5], 124 | [571.55078125, 246.578125, 0.5], 125 | [570.8671875, 234.28515625, 0.5], 126 | [569.5, 221.9921875, 0.5], 127 | [566.765625, 209.69921875, 0.5], 128 | [564.95703125, 204.27734375, 0.5], 129 | [564.546875, 202.6484375, 0.5], 130 | [564.31640625, 202.6484375, 0.5], 131 | [564.31640625, 202.875, 0.5], 132 | [563.1484375, 207.53125, 0.5], 133 | [561.7890625, 219.05859375, 0.5], 134 | [561.7890625, 235.9296875, 0.5], 135 | [562.48046875, 250.5078125, 0.5], 136 | [566.0703125, 269.18359375, 0.5], 137 | [571.09765625, 287.859375, 0.5], 138 | [576.62109375, 300.984375, 0.5], 139 | [582.1171875, 313.3515625, 0.5], 140 | [585.2734375, 319.66796875, 0.5], 141 | [590.48828125, 326.8359375, 0.5], 142 | [594.703125, 331.05078125, 0.5], 143 | [597.83203125, 332.61328125, 0.5], 144 | [598.703125, 332.90234375, 0.5], 145 | [599.57421875, 332.90234375, 0.5], 146 | [602.703125, 332.37890625, 0.5], 147 | [606.91796875, 328.76171875, 0.5], 148 | [612.05859375, 322.9765625, 0.5], 149 | [618.88671875, 312.73046875, 0.5], 150 | [625.828125, 299.53515625, 0.5], 151 | [630.74609375, 283.36328125, 0.5], 152 | [633.5234375, 268.78125, 0.5], 153 | [633.5234375, 258.015625, 0.5], 154 | [633.5234375, 250.30078125, 0.5], 155 | [632.35546875, 245.640625, 0.5], 156 | [632.125, 245.1796875, 0.5], 157 | [632.125, 245.40625, 0.5], 158 | [632.125, 245.86328125, 0.5], 159 | [631.6015625, 248.9921875, 0.5], 160 | [630.921875, 260.51953125, 0.5], 161 | [630.921875, 274.3359375, 0.5], 162 | [630.921875, 281.28125, 0.5], 163 | [634.35546875, 294.3359375, 0.5], 164 | [638.97265625, 302.9140625, 0.5], 165 | [645.69921875, 311.66015625, 0.5], 166 | [649.19140625, 315.15234375, 0.5], 167 | [659.36328125, 320.57421875, 0.5], 168 | [671.65234375, 323.3046875, 0.5], 169 | [680.890625, 323.9609375, 0.5], 170 | [692.41796875, 323.28125, 0.5], 171 | [697.07421875, 322.11328125, 0.5], 172 | [701.890625, 319.09765625, 0.5], 173 | [704.671875, 315.7578125, 0.5], 174 | [705.2890625, 309.5703125, 0.5], 175 | [704.61328125, 298.8046875, 0.5], 176 | [701.3515625, 290.98046875, 0.5], 177 | [694.51953125, 280.05078125, 0.5], 178 | [687.18359375, 272.71484375, 0.5], 179 | [676.12890625, 264.421875, 0.5], 180 | [666.79296875, 260.41796875, 0.5], 181 | [664.41015625, 259.46484375, 0.5], 182 | [659.75, 258.87890625, 0.5], 183 | [654.328125, 258.87890625, 0.5], 184 | [648.140625, 260.11328125, 0.5], 185 | [646.234375, 261.5390625, 0.5], 186 | [645.7734375, 261.99609375, 0.5], 187 | [645.48046875, 262.8671875, 0.5], 188 | ], 189 | ] 190 | 191 | export const mockDataURL 192 | = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA1MAAAGpCAYAAABoLmzKAAAcOUlEQVR4Xu3XoREAAAjEMNh/aWagOvg3OUx3HAECBAgQIECAAAECBAi8Bfa9MCBAgAABAgQIECBAgACBEVOegAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCIipgGZCgAABAgQIECBAgAABMeUHCBAgQIAAAQIECBAgEATEVEAzIUCAAAECBAgQIECAgJjyAwQIECBAgAABAgQIEAgCYiqgmRAgQIAAAQIECBAgQEBM+QECBAgQIECAAAECBAgEATEV0EwIECBAgAABAgQIECAgpvwAAQIECBAgQIAAAQIEgoCYCmgmBAgQIECAAAECBAgQEFN+gAABAgQIECBAgAABAkFATAU0EwIECBAgQIAAAQIECIgpP0CAAAECBAgQIECAAIEgIKYCmgkBAgQIECBAgAABAgTElB8gQIAAAQIECBAgQIBAEBBTAc2EAAECBAgQIECAAAECYsoPECBAgAABAgQIECBAIAiIqYBmQoAAAQIECBAgQIAAATHlBwgQIECAAAECBAgQIBAExFRAMyFAgAABAgQIECBAgICY8gMECBAgQIAAAQIECBAIAmIqoJkQIECAAAECBAgQIEBATPkBAgQIECBAgAABAgQIBAExFdBMCBAgQIAAAQIECBAgIKb8AAECBAgQIECAAAECBIKAmApoJgQIECBAgAABAgQIEBBTfoAAAQIECBAgQIAAAQJBQEwFNBMCBAgQIECAAAECBAiIKT9AgAABAgQIECBAgACBICCmApoJAQIECBAgQIAAAQIExJQfIECAAAECBAgQIECAQBAQUwHNhAABAgQIECBAgAABAmLKDxAgQIAAAQIECBAgQCAIiKmAZkKAAAECBAgQIECAAAEx5QcIECBAgAABAgQIECAQBMRUQDMhQIAAAQIECBAgQICAmPIDBAgQIECAAAECBAgQCAJiKqCZECBAgAABAgQIECBAQEz5AQIECBAgQIAAAQIECAQBMRXQTAgQIECAAAECBAgQICCm/AABAgQIECBAgAABAgSCgJgKaCYECBAgQIAAAQIECBAQU36AAAECBAgQIECAAAECQUBMBTQTAgQIECBAgAABAgQIiCk/QIAAAQIECBAgQIAAgSAgpgKaCQECBAgQIECAAAECBMSUHyBAgAABAgQIECBAgEAQEFMBzYQAAQIECBAgQIAAAQJiyg8QIECAAAECBAgQIEAgCBx++AGq6N2iaAAAAABJRU5ErkJggg==' 193 | --------------------------------------------------------------------------------