├── src ├── vite-env.d.ts ├── main.ts ├── App.vue ├── assets │ └── vue.svg ├── style.css ├── SimpleDemo.vue ├── DrawStampUtilsDemo.vue └── DrawStampUtils.ts ├── public ├── seal.png ├── designer.png └── vite.svg ├── tsconfig.json ├── vite.config.ts ├── index.html ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── package.json └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/seal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctfang/drawstamputils/master/public/seal.png -------------------------------------------------------------------------------- /public/designer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ctfang/drawstamputils/master/public/designer.png -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import './style.css' 3 | import App from './App.vue' 4 | 5 | createApp(App).mount('#app') 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /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 | }) 8 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + Vue + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 23 | -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules/ 3 | 4 | # Logs 5 | logs/ 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Build output 12 | dist/ 13 | build/ 14 | 15 | # Dependency directories 16 | jspm_packages/ 17 | 18 | # TypeScript 19 | *.tsbuildinfo 20 | 21 | # Vite 22 | .vite/ 23 | 24 | # Local environment files 25 | .env 26 | .env.local 27 | .env.*.local 28 | 29 | # Editor directories and files 30 | .idea/ 31 | .vscode/ 32 | *.suo 33 | *.ntvs* 34 | *.njsproj 35 | *.sln 36 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drawstamputils", 3 | "private": false, 4 | "version": "0.0.4", 5 | "description": "drawstamputils, stamp maker, manual aging stamp", 6 | "keywords": ["stamp.js", "drawStampUtils.js", "stamp", "drawstamp", "印章制作", "电子印章", "stamp maker", "digital seal", "印章生成", "电子印章生成", "印章生成器", "电子印章生成器"], 7 | "type": "module", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/xxss0903/drawstamputils" 11 | }, 12 | "license": "Apache-2.0", 13 | "homepage": "https://github.com/xxss0903/drawstamputils", 14 | "author": "xxss0903", 15 | "main": "src/DrawStampUtils.ts", 16 | "scripts": { 17 | "dev": "vite", 18 | "build": "vue-tsc -b && vite build", 19 | "preview": "vite preview" 20 | }, 21 | "dependencies": { 22 | "vue": "^3.4.37" 23 | }, 24 | "devDependencies": { 25 | "@vitejs/plugin-vue": "^5.1.2", 26 | "typescript": "^5.5.3", 27 | "vite": "^5.4.1", 28 | "vue-tsc": "^2.0.29" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | .card { 58 | padding: 2em; 59 | } 60 | 61 | #app { 62 | max-width: 1280px; 63 | margin: 0 auto; 64 | padding: 2rem; 65 | text-align: center; 66 | } 67 | 68 | @media (prefers-color-scheme: light) { 69 | :root { 70 | color: #213547; 71 | background-color: #ffffff; 72 | } 73 | a:hover { 74 | color: #747bff; 75 | } 76 | button { 77 | background-color: #f9f9f9; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/SimpleDemo.vue: -------------------------------------------------------------------------------- 1 | 6 | 43 | 143 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DrawStampUtils.js 2 | 3 | 简介 4 | 5 | DrawStampUtils.js 是一个使用 JavaScript 制作电子印章的工具。该项目使用 Vue 3 和 TypeScript 构建,并通过 Vite 进行开发和构建。 6 | 7 | 目录 8 | 9 | - 安装 10 | - 使用 11 | - DrawStampUtils.ts 使用说明 12 | - 贡献 13 | - 许可证 14 | 15 | 安装 16 | 17 | 在已有项目使用`drawstamputils`,使用如下命令安装: 18 | 19 | ```bash 20 | npm install drawstamputils 21 | ``` 22 | 23 | 如果要查看示例程序,可以如下方式: 24 | ```bash 25 | git clone https://github.com/xxss0903/drawstamputils.git 26 | cd drawstamputils 27 | npm install 28 | ``` 29 | 30 | 使用 31 | 32 | 开发 33 | 34 | 启动开发服务器: 35 | 36 | ```bash 37 | npm run dev 38 | ``` 39 | 40 | 构建 41 | 42 | 构建项目: 43 | 44 | ```bash 45 | npm run build 46 | ``` 47 | 48 | 预览 49 | 50 | 预览构建结果: 51 | 52 | ```bash 53 | npm run preview 54 | ``` 55 | 效果展示 56 | 57 | 以下是使用 DrawStampUtils.js 生成的电子印章示例: 58 | 59 | ![Stamp Example](public/seal.png) 60 | 61 | ![Stamp Designer](public/designer.png) 62 | 63 | 64 | DrawStampUtils.ts 使用说明 65 | 66 | DrawStampUtils.ts 是该项目的核心文件之一,用于生成电子印章。以下是如何使用 DrawStampUtils.ts 的示例: 67 | 68 | 导入 DrawStampUtils 69 | 70 | 首先,在你的 Vue 组件或其他 TypeScript 文件中导入 DrawStampUtils: 71 | 72 | ```typescript 73 | import { DrawStampUtils } from './DrawStampUtils'; 74 | ``` 75 | 76 | 创建印章 77 | 78 | 使用 DrawStampUtils 创建一个新的印章: 79 | 80 | ```typescript 81 | // 将canvasRef替换为你的canvas元素,MM_PER_PIXEL替换为你的毫米换算像素,根据需要修改 82 | const drawStampUtils = new DrawStampUtils(canvasRef, MM_PER_PIXEL) 83 | drawStampUtils.refreshStamp() 84 | ``` 85 | 86 | 配置选项
87 | 详细的配置请参考Demo文件[`DrawStampUtilsDemo.vue`](src/DrawStampUtilsDemo.vue)中的配置方法 88 | 89 | DrawStampUtils 支持以下配置选项: 90 | 91 | 以下是 DrawStampUtils 支持的主要配置选项及其功能: 92 | 93 | | 配置选项 | 功能描述 | 94 | |---------|--------| 95 | | ISecurityPattern | 控制防伪纹路的相关参数 | 96 | | - openSecurityPattern | 是否启用防伪纹路 | 97 | | - securityPatternWidth | 设置防伪纹路的宽度 | 98 | | - securityPatternLength | 设置防伪纹路的长度 | 99 | | - securityPatternCount | 设置防伪纹路的数量 | 100 | | - securityPatternAngleRange | 设置防伪纹路的角度范围 | 101 | | ICompany | 控制印章公司相关的参数 | 102 | | - companyName | 设置公司名称 | 103 | | - compression | 控制公司名称的压缩比例 | 104 | | - borderOffset | 设置边框偏移量 | 105 | | - textDistributionFactor | 控制文字分布因子 | 106 | | - fontFamily | 设置字体 | 107 | | - fontHeight | 设置字体高度 | 108 | | ICode | 控制印章编码相关的参数 | 109 | | - code | 设置编码内容 | 110 | | - compression | 控制编码的压缩比例 | 111 | | - fontHeight | 设置编码字体大小 | 112 | | - fontFamily | 设置编码字体 | 113 | | - borderOffset | 设置编码边框偏移量 | 114 | | - fontWidth | 设置编码字体宽度 | 115 | | - textDistributionFactor | 控制编码文字分布因子 | 116 | | ITaxNumber | 控制税号相关的参数 | 117 | | - code | 设置税号内容 | 118 | | - compression | 控制税号的压缩比例 | 119 | | - fontHeight | 设置税号字体大小 | 120 | | - fontFamily | 设置税号字体 | 121 | | - fontWidth | 设置税号字体宽度 | 122 | | - letterSpacing | 控制税号字符间距 | 123 | | - positionY | 设置税号文字垂直位置 | 124 | | - totalWidth | 设置税号文字总宽度 | 125 | | IAgingEffectParams | 控制做旧效果的相关参数 | 126 | | - x | 设置做旧效果的 x 轴位置 | 127 | | - y | 设置做旧效果的 y 轴位置 | 128 | | - noiseSize | 控制噪声大小 | 129 | | - noise | 控制噪声强度 | 130 | | - strongNoiseSize | 控制强噪声大小 | 131 | | - strongNoise | 控制强噪声强度 | 132 | | - fade | 控制淡化强度 | 133 | | - seed | 设置随机种子 | 134 | 135 | 136 | 137 | 138 | 下面是配置参数 139 | ``` 140 | 141 | // 防伪纹路 142 | export type ISecurityPattern = { 143 | openSecurityPattern: boolean // 是否启用防伪纹路 144 | securityPatternWidth: number // 防伪纹路宽度 145 | securityPatternLength: number // 防伪纹路长度 146 | securityPatternCount: number // 防伪纹路数量 147 | securityPatternAngleRange: number // 防伪纹路角度范围 148 | securityPatternParams: Array<{ angle: number; lineAngle: number }> // 保存防伪纹路的参数数组 149 | } 150 | 151 | // 绘制印章的公司 152 | export type ICompany = { 153 | companyName: string // 公司名称 154 | compression: number // 公司名称压缩比例 155 | borderOffset: number // 边框偏移量 156 | textDistributionFactor: number // 文字分布因子 157 | fontFamily: string // 字体 158 | fontHeight: number // 字体高度 159 | } 160 | 161 | // 印章编码 162 | export type ICode = { 163 | code: string // 编码 164 | compression: number // 编码压缩比例 165 | fontHeight: number // 编码字体大小 166 | fontFamily: string // 编码字体 167 | borderOffset: number // 编码边框偏移量 168 | fontWidth: number // 编码字体宽度 169 | textDistributionFactor: number // 文字分布因子 170 | } 171 | 172 | export type ITaxNumber = { 173 | code: string // 税号 174 | compression: number // 税号压缩比例 175 | fontHeight: number // 税号字体大小 176 | fontFamily: string // 编码字体 177 | fontWidth: number // 编码字体宽度 178 | letterSpacing: number // 编码字符间距 179 | positionY: number // 编码文字位置 180 | totalWidth: number // 编码文字总宽度 181 | } 182 | 183 | // 做旧效果参数 184 | export type IAgingEffectParams = { 185 | x: number // x轴位置 186 | y: number // y轴位置 187 | noiseSize: number // 噪声大小 188 | noise: number // 噪声强度 189 | strongNoiseSize: number // 强噪声大小 190 | strongNoise: number // 强噪声强度 191 | fade: number // 淡化强度 192 | seed: number // 随机种子 193 | } 194 | 195 | // 做旧效果 196 | export type IAgingEffect = { 197 | applyAging: boolean // 是否应用做旧效果 198 | agingIntensity: number // 做旧效果强度 199 | agingEffectParams: IAgingEffectParams[] // 保存做旧效果的参数数组 200 | } 201 | 202 | // 绘制五角星 203 | export type IDrawStar = { 204 | svgPath: string // svg路径 205 | drawStar: boolean // 是否绘制五角星 206 | starDiameter: number // 五角星直径 207 | starPositionY: number // 五角星位置 208 | scaleToSmallStar: boolean // 是否缩放为小五角星 209 | } 210 | 211 | // 印章类型 212 | export type IStampType = { 213 | stampType: string // 印章类型 214 | fontHeight: number // 字体高度 215 | compression: number // 压缩比例 216 | letterSpacing: number // 字符间距 217 | positionY: number // 位置 218 | fontWidth: number // 字体宽度 219 | } 220 | 221 | // 内圈圆 222 | export type IInnerCircle = { 223 | drawInnerCircle: boolean // 是否绘制内圈圆 224 | innerCircleLineWidth: number // 内圈圆线宽 225 | innerCircleLineRadiusX: number // x轴半径 226 | innerCircleLineRadiusY: number // y轴半径 227 | } 228 | 229 | // 是否绘制标尺 230 | export type IShowRuler = { 231 | showRuler: boolean // 是否绘制标尺 232 | showFullRuler: boolean // 是否绘制全标尺 233 | } 234 | 235 | // 绘制印章的参数 236 | export type IDrawStampConfig = { 237 | agingEffect: IAgingEffect // 做旧效果 238 | ruler: IShowRuler // 是否绘制标尺 239 | drawStar: IDrawStar // 是否绘制五角星 240 | securityPattern: ISecurityPattern 241 | company: ICompany // 公司 242 | stampCode: ICode // 印章编码 243 | taxNumber: ITaxNumber // 税号 244 | stampType: IStampType // 印章类型 245 | width: number // 印章宽度 246 | height: number // 印章高度 247 | borderWidth: number // 印章边框宽度 248 | primaryColor: string // 印章主色 249 | refreshSecurityPattern: boolean // 是否刷新防伪纹路 250 | refreshOld: boolean // 是否刷新做旧效果 251 | shouldDrawRuler: boolean // 是否绘制标尺 252 | innerCircle: IInnerCircle // 内圈圆 253 | outThinCircle: IInnerCircle // 比外圈细的稍微内圈 254 | openManualAging: boolean // 是否开启手动做旧效果 255 | } 256 | 257 | 258 | ``` 259 | 260 | 完整示例 261 | DrawStampUtilsDemo.vue中的方法作为参考 262 | 263 | 264 | 贡献 265 | 266 | 欢迎贡献代码!请先 fork 本仓库,然后提交 pull request。 267 | 268 | 许可证 269 | 270 | 本项目使用 Apache 许可证。 -------------------------------------------------------------------------------- /src/DrawStampUtilsDemo.vue: -------------------------------------------------------------------------------- 1 | 275 | 578 | 678 | -------------------------------------------------------------------------------- /src/DrawStampUtils.ts: -------------------------------------------------------------------------------- 1 | // 防伪纹路 2 | export type ISecurityPattern = { 3 | openSecurityPattern: boolean // 是否启用防伪纹路 4 | securityPatternWidth: number // 防伪纹路宽度 5 | securityPatternLength: number // 防伪纹路长度 6 | securityPatternCount: number // 防伪纹路数量 7 | securityPatternAngleRange: number // 防伪纹路角度范围 8 | securityPatternParams: Array<{ angle: number; lineAngle: number }> // 保存防伪纹路的参数数组 9 | } 10 | 11 | // 绘制印章的公司 12 | export type ICompany = { 13 | companyName: string // 公司名称 14 | compression: number // 公司名称压缩比例 15 | borderOffset: number // 边框偏移量 16 | textDistributionFactor: number // 文字分布因子 17 | fontFamily: string // 字体 18 | fontHeight: number // 字体高度 19 | } 20 | 21 | // 印章编码 22 | export type ICode = { 23 | code: string // 编码 24 | compression: number // 编码压缩比例 25 | fontHeight: number // 编码字体大小 26 | fontFamily: string // 编码字体 27 | borderOffset: number // 编码边框偏移量 28 | fontWidth: number // 编码字体宽度 29 | textDistributionFactor: number // 文字分布因子 30 | } 31 | 32 | export type ITaxNumber = { 33 | code: string // 税号 34 | compression: number // 税号压缩比例 35 | fontHeight: number // 税号字体大小 36 | fontFamily: string // 编码字体 37 | fontWidth: number // 编码字体宽度 38 | letterSpacing: number // 编码字符间距 39 | positionY: number // 编码文字位置 40 | totalWidth: number // 编码文字总宽度 41 | } 42 | 43 | // 做旧效果参数 44 | export type IAgingEffectParams = { 45 | x: number // x轴位置 46 | y: number // y轴位置 47 | noiseSize: number // 噪声大小 48 | noise: number // 噪声强度 49 | strongNoiseSize: number // 强噪声大小 50 | strongNoise: number // 强噪声强度 51 | fade: number // 淡化强度 52 | seed: number // 随机种子 53 | } 54 | 55 | // 做旧效果 56 | export type IAgingEffect = { 57 | applyAging: boolean // 是否应用做旧效果 58 | agingIntensity: number // 做旧效果强度 59 | agingEffectParams: IAgingEffectParams[] // 保存做旧效果的参数数组 60 | } 61 | 62 | // 绘制五角星 63 | export type IDrawStar = { 64 | svgPath: string // svg路径 65 | drawStar: boolean // 是否绘制五角星 66 | starDiameter: number // 五角星直径 67 | starPositionY: number // 五角星位置 68 | scaleToSmallStar: boolean // 是否缩放为小五角星 69 | } 70 | 71 | // 印章类型 72 | export type IStampType = { 73 | stampType: string // 印章类型 74 | fontHeight: number // 字体高度 75 | compression: number // 压缩比例 76 | letterSpacing: number // 字符间距 77 | positionY: number // 位置 78 | fontWidth: number // 字体宽度 79 | } 80 | 81 | // 内圈圆 82 | export type IInnerCircle = { 83 | drawInnerCircle: boolean // 是否绘制内圈圆 84 | innerCircleLineWidth: number // 内圈圆线宽 85 | innerCircleLineRadiusX: number // x轴半径 86 | innerCircleLineRadiusY: number // y轴半径 87 | } 88 | 89 | // 是否绘制标尺 90 | export type IShowRuler = { 91 | showRuler: boolean // 是否绘制标尺 92 | showFullRuler: boolean // 是否绘制全标尺 93 | } 94 | 95 | // 绘制印章的参数 96 | export type IDrawStampConfig = { 97 | agingEffect: IAgingEffect // 做旧效果 98 | ruler: IShowRuler // 是否绘制标尺 99 | drawStar: IDrawStar // 是否绘制五角星 100 | securityPattern: ISecurityPattern 101 | company: ICompany // 公司 102 | stampCode: ICode // 印章编码 103 | taxNumber: ITaxNumber // 税号 104 | stampType: IStampType // 印章类型 105 | width: number // 印章宽度 106 | height: number // 印章高度 107 | borderWidth: number // 印章边框宽度 108 | primaryColor: string // 印章主色 109 | refreshSecurityPattern: boolean // 是否刷新防伪纹路 110 | refreshOld: boolean // 是否刷新做旧效果 111 | shouldDrawRuler: boolean // 是否绘制标尺 112 | innerCircle: IInnerCircle // 内圈圆 113 | outThinCircle: IInnerCircle // 比外圈细的稍微内圈 114 | openManualAging: boolean // 是否开启手动做旧效果 115 | } 116 | 117 | // 标尺宽度 118 | const RULER_WIDTH = 80 119 | // 标尺高度 120 | const RULER_HEIGHT = 80 121 | 122 | /** 123 | * 绘制印章工具类 124 | */ 125 | export class DrawStampUtils { 126 | // 缩放参数 127 | private scale: number = 1; 128 | private offsetX: number = 0; 129 | private offsetY: number = 0; 130 | // 主色 131 | private primaryColor: string = '#ff0000' 132 | // 毫米到像素的 133 | private mmToPixel: number 134 | // 主canvas的context 135 | private canvasCtx: CanvasRenderingContext2D 136 | // 离屏的canvas 137 | private offscreenCanvas: HTMLCanvasElement 138 | // 主canvas 139 | private canvas: HTMLCanvasElement 140 | private stampOffsetX: number = 0 141 | private stampOffsetY: number = 0 142 | private agingIntensity: number = 50 143 | private ruler: IShowRuler = { 144 | showRuler: true, 145 | showFullRuler: true 146 | } 147 | private drawStar: IDrawStar = { 148 | svgPath: 'M 0 -1 L 0.588 0.809 L -0.951 -0.309 L 0.951 -0.309 L -0.588 0.809 Z', 149 | drawStar: false, 150 | starDiameter: 14, 151 | starPositionY: 0, 152 | scaleToSmallStar: false 153 | } 154 | // 防伪纹路 155 | private securityPattern: ISecurityPattern = { 156 | openSecurityPattern: true, 157 | securityPatternWidth: 0.15, 158 | securityPatternLength: 3, 159 | securityPatternCount: 5, 160 | securityPatternAngleRange: 40, 161 | securityPatternParams: [] 162 | } 163 | private company: ICompany = { 164 | companyName: '印章绘制有限责任公司', 165 | compression: 1, 166 | borderOffset: 1, 167 | textDistributionFactor: 20, 168 | fontFamily: 'SimSun', 169 | fontHeight: 4.2 170 | } 171 | private taxNumber: ITaxNumber = { 172 | code: '000000000000000000', 173 | compression: 0.7, 174 | fontHeight: 3.7, 175 | fontFamily: 'Arial', 176 | fontWidth: 1.3, 177 | letterSpacing: 8, 178 | positionY: 0, 179 | totalWidth: 26 180 | } 181 | private stampCode: ICode = { 182 | code: '1234567890', 183 | compression: 1, 184 | fontHeight: 1.2, 185 | fontFamily: 'Arial', 186 | borderOffset: 1, 187 | fontWidth: 1.2, 188 | textDistributionFactor: 50 189 | } 190 | private stampType: IStampType = { 191 | stampType: '发票专用章', 192 | fontHeight: 4.6, 193 | fontWidth: 3, 194 | compression: 0.75, 195 | letterSpacing: 0, 196 | positionY: -3 197 | } 198 | // 做旧效果 199 | private agingEffect: IAgingEffect = { 200 | applyAging: false, 201 | agingIntensity: 50, 202 | agingEffectParams: [] 203 | } 204 | 205 | // 内圈圆 206 | private innerCircle: IInnerCircle = { 207 | drawInnerCircle: true, 208 | innerCircleLineWidth: 0.5, 209 | innerCircleLineRadiusX: 16, 210 | innerCircleLineRadiusY: 12 211 | } 212 | // 比外圈细的稍微内圈 213 | private outThinCircle: IInnerCircle = { 214 | drawInnerCircle: true, 215 | innerCircleLineWidth: 0.2, 216 | innerCircleLineRadiusX: 36, 217 | innerCircleLineRadiusY: 27 218 | } 219 | // 总的印章绘制参数 220 | private drawStampConfigs: IDrawStampConfig = { 221 | ruler: this.ruler, 222 | drawStar: this.drawStar, 223 | securityPattern: this.securityPattern, 224 | company: this.company, 225 | stampCode: this.stampCode, 226 | width: 40, 227 | height: 30, 228 | stampType: this.stampType, 229 | primaryColor: this.primaryColor, 230 | borderWidth: 1, 231 | refreshSecurityPattern: false, 232 | refreshOld: false, 233 | taxNumber: this.taxNumber, 234 | agingEffect: this.agingEffect, 235 | shouldDrawRuler: true, 236 | innerCircle: this.innerCircle, 237 | outThinCircle: this.outThinCircle, 238 | openManualAging: false 239 | } 240 | 241 | /** 242 | * 构造函数 243 | * @param canvas 画布 244 | * @param mmToPixel 毫米到像素的转换比例 245 | */ 246 | constructor(canvas: HTMLCanvasElement | null, mmToPixel: number) { 247 | if (!canvas) { 248 | throw new Error('Canvas is null') 249 | } 250 | const ctx = canvas.getContext('2d') 251 | if (!ctx) { 252 | throw new Error('Failed to get canvas context') 253 | } 254 | this.canvasCtx = ctx 255 | this.mmToPixel = mmToPixel 256 | this.canvas = canvas 257 | // 创建离屏canvas 258 | this.offscreenCanvas = document.createElement('canvas') 259 | 260 | if (this.canvas && this.offscreenCanvas) { 261 | this.offscreenCanvas.width = canvas.width 262 | this.offscreenCanvas.height = canvas.height 263 | } 264 | this.addCanvasListener() 265 | } 266 | 267 | private isDragging = false 268 | private dragStartX = 0 269 | private dragStartY = 0 270 | 271 | // 获取绘制印章的配置 272 | getDrawConfigs() { 273 | return this.drawStampConfigs 274 | } 275 | 276 | /** 277 | * 手动做旧效果 278 | * @param x 279 | * @param y 280 | * @param intensity 281 | */ 282 | addManualAgingEffect(x: number, y: number, intensityFactor: number) { 283 | console.log('手动做旧 1', x, y, this.drawStampConfigs.agingEffect.agingEffectParams) 284 | const radius = 1 * this.mmToPixel; // 直径3mm,半径1.5mm 285 | 286 | // 考虑印章偏移量 287 | const adjustedX = x - this.stampOffsetX * this.mmToPixel; 288 | const adjustedY = y - this.stampOffsetY * this.mmToPixel; 289 | 290 | for(let i = 0; i < 10; i++) { 291 | // 将点击的地方增加一个做旧数据到做旧的列表里面 292 | this.drawStampConfigs.agingEffect.agingEffectParams.push({ 293 | x: adjustedX, 294 | y: adjustedY, 295 | noiseSize: Math.random() * 3 + 1, 296 | noise: Math.random() * 200 * intensityFactor, 297 | strongNoiseSize: Math.random() * 5 + 2, 298 | strongNoise: Math.random() * 250 * intensityFactor + 5, 299 | fade: Math.random() * 50 * intensityFactor, 300 | seed: Math.random() 301 | }) 302 | } 303 | 304 | // 立即刷新画布以显示效果 305 | this.refreshStamp(false, false); 306 | 307 | // 绘制鼠标点击效果 308 | this.canvasCtx.save(); 309 | this.canvasCtx.globalCompositeOperation = 'destination-out'; // 改变这里 310 | this.canvasCtx.beginPath(); 311 | this.canvasCtx.arc(x, y, radius, 0, Math.PI * 2, true); 312 | this.canvasCtx.fillStyle = 'rgba(255, 255, 255, 0.5)'; // 使用白色,但透明度降低 313 | this.canvasCtx.fill(); 314 | this.canvasCtx.restore(); 315 | } 316 | 317 | // 设置绘制印章的配置,比如可以保存某些印章的配置,然后保存之后直接设置绘制,更加方便 318 | setDrawConfigs(drawConfigs: IDrawStampConfig) { 319 | this.drawStampConfigs = drawConfigs 320 | } 321 | 322 | private addCanvasListener() { 323 | this.canvas.addEventListener('mousemove', (event) => { 324 | if(this.drawStampConfigs.openManualAging && event.buttons === 1) { 325 | const rect = this.canvas.getBoundingClientRect() 326 | const x = event.clientX - rect.left; 327 | const y = event.clientY - rect.top; 328 | const agingIntensity = this.drawStampConfigs.agingEffect.agingIntensity / 100; 329 | this.addManualAgingEffect(x, y, agingIntensity); 330 | } else { 331 | this.onMouseMove(event) 332 | } 333 | }) 334 | this.canvas.addEventListener('mouseleave', (event) => { 335 | this.onMouseLeave(event) 336 | }) 337 | this.canvas.addEventListener('mousedown', (event) => { 338 | this.onMouseDown(event) 339 | if(this.drawStampConfigs.openManualAging) { 340 | // 添加手动做旧效果 341 | const rect = this.canvas.getBoundingClientRect() 342 | const x = event.clientX - rect.left; 343 | const y = event.clientY - rect.top; 344 | // 增加做旧效果的强度 345 | const agingIntensity = this.drawStampConfigs.agingEffect.agingIntensity / 100; // 将强度调整为原来的2倍 346 | this.addManualAgingEffect(x, y, agingIntensity); 347 | } 348 | }) 349 | this.canvas.addEventListener('mouseup', (event) => { 350 | this.onMouseUp() 351 | }) 352 | this.canvas.addEventListener('click', (event) => { 353 | this.onCanvasClick(event) 354 | }) 355 | // 添加鼠标滚轮事件监听器 356 | this.canvas.addEventListener('wheel', (event: WheelEvent) => { 357 | if (event.ctrlKey) { 358 | event.preventDefault(); 359 | const zoom = event.deltaY > 0 ? 0.9 : 1.1; 360 | this.zoomCanvas(event.offsetX, event.offsetY, zoom); 361 | } 362 | }); 363 | // 添加双击事件监听器来重置缩放 364 | // this.canvas.addEventListener('dblclick', (event: MouseEvent) => { 365 | // this.resetZoom(); 366 | // }); 367 | } 368 | 369 | private zoomCanvas(mouseX: number, mouseY: number, zoom: number) { 370 | const oldScale = this.scale; 371 | this.scale *= zoom; 372 | this.scale = Math.max(0.1, Math.min(5, this.scale)); // 限制缩放范围 373 | 374 | // 调整偏移量以保持鼠标位置不变 375 | this.offsetX = mouseX - (mouseX - this.offsetX) * (this.scale / oldScale); 376 | this.offsetY = mouseY - (mouseY - this.offsetY) * (this.scale / oldScale); 377 | 378 | this.refreshStamp(); 379 | } 380 | 381 | private onMouseUp = () => { 382 | this.isDragging = false 383 | this.refreshStamp(false, false); 384 | } 385 | 386 | // 点击印章区域,比如五角星等位置然后进行相应的跳转之类的 387 | private onCanvasClick = (event: MouseEvent) => { 388 | const canvas = this.canvas 389 | if (!canvas) return 390 | } 391 | 392 | private onMouseLeave = (event: MouseEvent) => { 393 | this.isDragging = false 394 | this.refreshStamp() 395 | } 396 | 397 | private onMouseDown = (event: MouseEvent) => { 398 | this.isDragging = true 399 | this.dragStartX = event.clientX - this.stampOffsetX * this.mmToPixel 400 | this.dragStartY = event.clientY - this.stampOffsetY * this.mmToPixel 401 | } 402 | 403 | private onMouseMove = (event: MouseEvent) => { 404 | if(this.drawStampConfigs.openManualAging) { 405 | return 406 | } 407 | if (this.isDragging) { 408 | const newOffsetX = (event.clientX - this.dragStartX) / this.mmToPixel 409 | const newOffsetY = (event.clientY - this.dragStartY) / this.mmToPixel 410 | this.stampOffsetX = Math.round(newOffsetX * 10) / 10 // 四舍五入到小数点后一位 411 | this.stampOffsetY = Math.round(newOffsetY * 10) / 10 412 | this.refreshStamp() 413 | } else { 414 | // 原有的鼠标移动逻辑 415 | const rect = this.canvas.getBoundingClientRect() 416 | const x = event.clientX - rect.left 417 | const y = event.clientY - rect.top 418 | const mmX = Math.round(((x - RULER_WIDTH) / this.mmToPixel) * 10) / 10 419 | const mmY = Math.round(((y - RULER_HEIGHT) / this.mmToPixel) * 10) / 10 420 | 421 | this.refreshStamp() 422 | this.highlightRulerPosition(this.canvasCtx, mmX, mmY) 423 | this.drawCrossLines(x, y) 424 | } 425 | } 426 | 427 | private highlightRulerPosition = (ctx: CanvasRenderingContext2D, mmX: number, mmY: number) => { 428 | const x = mmX * this.mmToPixel + RULER_WIDTH 429 | const y = mmY * this.mmToPixel + RULER_HEIGHT 430 | 431 | // 高亮水平标尺 432 | ctx.fillStyle = this.drawStampConfigs.primaryColor 433 | ctx.fillRect(RULER_WIDTH, y - 1, this.canvas.width - RULER_WIDTH, 2) 434 | 435 | // 高亮垂直标尺 436 | ctx.fillRect(x - 1, RULER_HEIGHT, 2, this.canvas.height - RULER_HEIGHT) 437 | 438 | // 显示坐标 439 | ctx.fillStyle = 'black'; 440 | ctx.font = 'bold 12px Arial'; 441 | ctx.textAlign = 'left'; 442 | ctx.textBaseline = 'top'; 443 | const showPositionX = mmX / this.scale 444 | const showPositionY = mmY / this.scale 445 | ctx.fillText(`${showPositionX.toFixed(1)}mm, ${showPositionY.toFixed(1)}mm, scale: ${this.scale.toFixed(2)}`, RULER_WIDTH + 5, RULER_HEIGHT + 5); 446 | 447 | } 448 | 449 | private drawCrossLines = (x: number, y: number) => { 450 | const canvas = this.offscreenCanvas 451 | if (!canvas) return 452 | const ctx = canvas.getContext('2d') 453 | if (!ctx) return 454 | 455 | // 清除之前绘制的内容 456 | ctx.clearRect(0, 0, canvas.width, canvas.height) 457 | 458 | ctx.beginPath() 459 | ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)' 460 | ctx.lineWidth = 1 461 | 462 | // 绘制水平线 463 | ctx.moveTo(RULER_WIDTH, y) 464 | ctx.lineTo(canvas.width, y) 465 | 466 | // 绘制垂直线 467 | ctx.moveTo(x, RULER_HEIGHT) 468 | ctx.lineTo(x, canvas.height) 469 | 470 | ctx.stroke() 471 | 472 | // 将离屏canvas的内容绘制到主canvas上 473 | const mainCanvas = this.canvas 474 | if (mainCanvas) { 475 | const mainCtx = mainCanvas.getContext('2d') 476 | if (mainCtx) { 477 | mainCtx.drawImage(canvas, 0, 0) 478 | } 479 | } 480 | } 481 | 482 | /** 483 | * 解析SVG路径数据 484 | * @param svgPath SVG路径字符串 485 | * @returns 解析后的路径命令数组 486 | */ 487 | private parseSVGPath(svgPath: string): Array<{ command: string; params: number[] }> { 488 | const commands: Array<{ command: string; params: number[] }> = []; 489 | const regex = /([MmLlHhVvCcSsQqTtAaZz])|(-?\d*\.?\d+)/g; 490 | let match; 491 | let currentCommand = ''; 492 | let currentParams: number[] = []; 493 | 494 | while ((match = regex.exec(svgPath)) !== null) { 495 | if (match[1]) { 496 | // 如果匹配到命令 497 | if (currentCommand) { 498 | // 保存前一个命令 499 | commands.push({ command: currentCommand, params: currentParams }); 500 | currentParams = []; 501 | } 502 | currentCommand = match[1]; 503 | } else if (match[2]) { 504 | // 如果匹配到数字 505 | currentParams.push(parseFloat(match[2])); 506 | } 507 | } 508 | 509 | // 添加最后一个命令 510 | if (currentCommand) { 511 | commands.push({ command: currentCommand, params: currentParams }); 512 | } 513 | 514 | return commands; 515 | } 516 | 517 | private scaleSVGPathTo10mm(svgPath: string): string { 518 | const pathData = this.parseSVGPath(svgPath); 519 | 520 | // 计算SVG的边界框 521 | let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 522 | let currentX = 0, currentY = 0; 523 | 524 | pathData.forEach(({ command, params }) => { 525 | switch (command) { 526 | case 'M': 527 | case 'L': 528 | case 'C': 529 | case 'S': 530 | case 'Q': 531 | case 'T': 532 | for (let i = 0; i < params.length; i += 2) { 533 | minX = Math.min(minX, params[i]); 534 | maxX = Math.max(maxX, params[i]); 535 | minY = Math.min(minY, params[i + 1]); 536 | maxY = Math.max(maxY, params[i + 1]); 537 | } 538 | break; 539 | case 'm': 540 | case 'l': 541 | case 'c': 542 | case 's': 543 | case 'q': 544 | case 't': 545 | for (let i = 0; i < params.length; i += 2) { 546 | currentX += params[i]; 547 | currentY += params[i + 1]; 548 | minX = Math.min(minX, currentX); 549 | maxX = Math.max(maxX, currentX); 550 | minY = Math.min(minY, currentY); 551 | maxY = Math.max(maxY, currentY); 552 | } 553 | break; 554 | case 'H': 555 | minX = Math.min(minX, params[0]); 556 | maxX = Math.max(maxX, params[0]); 557 | break; 558 | case 'h': 559 | currentX += params[0]; 560 | minX = Math.min(minX, currentX); 561 | maxX = Math.max(maxX, currentX); 562 | break; 563 | case 'V': 564 | minY = Math.min(minY, params[0]); 565 | maxY = Math.max(maxY, params[0]); 566 | break; 567 | case 'v': 568 | currentY += params[0]; 569 | minY = Math.min(minY, currentY); 570 | maxY = Math.max(maxY, currentY); 571 | break; 572 | } 573 | }); 574 | 575 | // 计算原始SVG的宽度和高度 576 | const width = maxX - minX; 577 | const height = maxY - minY; 578 | 579 | // 计算缩放比例 580 | const scale = 5 / Math.max(width, height); 581 | 582 | // 缩放路径数据 583 | const scaledPathData = pathData.map(({ command, params }) => { 584 | const scaledParams = params.map(param => param * scale); 585 | return { command, params: scaledParams }; 586 | }); 587 | 588 | // 将缩放后的路径数据转换回字符串 589 | return this.convertPathDataToString(scaledPathData); 590 | } 591 | 592 | /** 593 | * 将解析后的路径数据转换为字符串 594 | * @param pathData 解析后的路径数据 595 | * @returns SVG路径字符串 596 | */ 597 | private convertPathDataToString(pathData: Array<{ command: string; params: number[] }>): string { 598 | return pathData.map(({ command, params }) => { 599 | return command + params.map(p => p.toFixed(2)).join(' '); 600 | }).join(' '); 601 | } 602 | 603 | private drawSVGPath( 604 | ctx: CanvasRenderingContext2D, 605 | svgPath: string, 606 | x: number, 607 | y: number, 608 | scale: number = 1 609 | ) { 610 | ctx.save(); 611 | ctx.translate(x, y); 612 | ctx.scale(scale, scale); 613 | 614 | // 创建 Path2D 对象 615 | const path = new Path2D(svgPath); 616 | 617 | // 填充路径 618 | ctx.fillStyle = this.primaryColor; 619 | ctx.fill(path); 620 | 621 | // 如果需要描边,可以添加以下代码 622 | // ctx.strokeStyle = 'black'; 623 | // ctx.lineWidth = 1; 624 | // ctx.stroke(path); 625 | 626 | ctx.restore(); 627 | } 628 | 629 | /** 630 | * 根据解析的SVG路径数据绘制图形 631 | * @param ctx 画布上下文 632 | * @param path 解析后的SVG路径数据 633 | * @param x 绘制的x坐标 634 | * @param y 绘制的y坐标 635 | * @param scale 缩放比例 636 | */ 637 | private drawSVGPath2( 638 | ctx: CanvasRenderingContext2D, 639 | path: Array<{ command: string; params: number[] }>, 640 | x: number, 641 | y: number, 642 | scale: number = 1 643 | ) { 644 | ctx.save(); 645 | ctx.translate(x, y); 646 | ctx.scale(scale, scale); 647 | ctx.beginPath(); 648 | 649 | let currentX = 0; 650 | let currentY = 0; 651 | let startX = 0; 652 | let startY = 0; 653 | let controlX = 0; 654 | let controlY = 0; 655 | 656 | path.forEach(({ command, params }) => { 657 | const paramCount = params.length; 658 | switch (command) { 659 | case 'M': 660 | case 'm': 661 | if (command === 'M') { 662 | currentX = params[0]; 663 | currentY = params[1]; 664 | } else { 665 | currentX += params[0]; 666 | currentY += params[1]; 667 | } 668 | ctx.moveTo(currentX, currentY); 669 | startX = currentX; 670 | startY = currentY; 671 | break; 672 | case 'L': 673 | case 'l': 674 | for (let i = 0; i < paramCount; i += 2) { 675 | if (command === 'L') { 676 | currentX = params[i]; 677 | currentY = params[i + 1]; 678 | } else { 679 | currentX += params[i]; 680 | currentY += params[i + 1]; 681 | } 682 | ctx.lineTo(currentX, currentY); 683 | } 684 | break; 685 | case 'H': 686 | case 'h': 687 | for (let i = 0; i < paramCount; i++) { 688 | if (command === 'H') { 689 | currentX = params[i]; 690 | } else { 691 | currentX += params[i]; 692 | } 693 | ctx.lineTo(currentX, currentY); 694 | } 695 | break; 696 | case 'V': 697 | case 'v': 698 | for (let i = 0; i < paramCount; i++) { 699 | if (command === 'V') { 700 | currentY = params[i]; 701 | } else { 702 | currentY += params[i]; 703 | } 704 | ctx.lineTo(currentX, currentY); 705 | } 706 | break; 707 | case 'C': 708 | case 'c': 709 | for (let i = 0; i < paramCount; i += 6) { 710 | const [cp1x, cp1y, cp2x, cp2y, x, y] = command === 'C' 711 | ? params.slice(i, i + 6) 712 | : params.slice(i, i + 6).map((p, index) => index % 2 === 0 ? p + currentX : p + currentY); 713 | ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); 714 | controlX = cp2x; 715 | controlY = cp2y; 716 | currentX = x; 717 | currentY = y; 718 | } 719 | break; 720 | case 'S': 721 | case 's': 722 | for (let i = 0; i < paramCount; i += 4) { 723 | let [cp2x, cp2y, x, y] = command === 'S' 724 | ? params.slice(i, i + 4) 725 | : params.slice(i, i + 4).map((p, index) => index % 2 === 0 ? p + currentX : p + currentY); 726 | const cp1x = currentX + (currentX - controlX); 727 | const cp1y = currentY + (currentY - controlY); 728 | ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y); 729 | controlX = cp2x; 730 | controlY = cp2y; 731 | currentX = x; 732 | currentY = y; 733 | } 734 | break; 735 | case 'Q': 736 | case 'q': 737 | for (let i = 0; i < paramCount; i += 4) { 738 | const [cpx, cpy, x, y] = command === 'Q' 739 | ? params.slice(i, i + 4) 740 | : params.slice(i, i + 4).map((p, index) => index % 2 === 0 ? p + currentX : p + currentY); 741 | ctx.quadraticCurveTo(cpx, cpy, x, y); 742 | controlX = cpx; 743 | controlY = cpy; 744 | currentX = x; 745 | currentY = y; 746 | } 747 | break; 748 | case 'T': 749 | case 't': 750 | for (let i = 0; i < paramCount; i += 2) { 751 | const [x, y] = command === 'T' 752 | ? params.slice(i, i + 2) 753 | : [params[i] + currentX, params[i + 1] + currentY]; 754 | const cpx = currentX + (currentX - controlX); 755 | const cpy = currentY + (currentY - controlY); 756 | ctx.quadraticCurveTo(cpx, cpy, x, y); 757 | controlX = cpx; 758 | controlY = cpy; 759 | currentX = x; 760 | currentY = y; 761 | } 762 | break; 763 | case 'A': 764 | case 'a': 765 | for (let i = 0; i < paramCount; i += 7) { 766 | const [rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x, y] = command === 'A' 767 | ? params.slice(i, i + 7) 768 | : [...params.slice(i, i + 5), params[i + 5] + currentX, params[i + 6] + currentY]; 769 | // 这里应该使用更复杂的弧线绘制逻辑,目前使用简化版本 770 | ctx.ellipse(x, y, rx, ry, xAxisRotation, 0, 2 * Math.PI); 771 | currentX = x; 772 | currentY = y; 773 | } 774 | break; 775 | case 'Z': 776 | case 'z': 777 | ctx.closePath(); 778 | currentX = startX; 779 | currentY = startY; 780 | break; 781 | } 782 | }); 783 | 784 | ctx.fillStyle = this.primaryColor; 785 | ctx.fill(); 786 | 787 | ctx.restore(); 788 | } 789 | 790 | /** 791 | * 绘制SVG路径数据 792 | * @param ctx Canvas上下文 793 | * @param svgData SVG路径数据 794 | * @param x 绘制中心的x坐标 795 | * @param y 绘制中心的y坐标 796 | * @param size 绘制大小 797 | */ 798 | private drawSVGData(ctx: CanvasRenderingContext2D, svgData: string, x: number, y: number, size: number) { 799 | ctx.save(); 800 | ctx.translate(x, y); 801 | 802 | const path = new Path2D(svgData); 803 | 804 | // 计算缩放比例 805 | const svgViewBox = [0, 0, 24, 24]; // 假设原始viewBox为24x24 806 | const scale = size / Math.max(svgViewBox[2], svgViewBox[3]); 807 | 808 | ctx.scale(scale, scale); 809 | 810 | // 将绘制原点移到中心 811 | ctx.translate(-svgViewBox[2] / 2, -svgViewBox[3] / 2); 812 | 813 | ctx.fillStyle = this.primaryColor; 814 | ctx.fill(path); 815 | 816 | ctx.strokeStyle = this.primaryColor; 817 | ctx.lineWidth = 1.5 / scale; // 保持线宽一致 818 | ctx.stroke(path); 819 | 820 | ctx.restore(); 821 | } 822 | 823 | /** 824 | * 绘制五角星 825 | * @param canvasCtx 画笔 826 | * @param x 圆心x坐标 827 | * @param y 圆心y坐标 828 | * @param r 半径 829 | */ 830 | private drawStarShape(ctx: CanvasRenderingContext2D, starSvgData: IDrawStar, x: number, y: number) { 831 | const drawStarDia = starSvgData.starDiameter / 2 * this.mmToPixel 832 | if(starSvgData.svgPath.startsWith(' { 858 | console.log("svg content img loaded", x, y, svgWidth, svgHeight, img); 859 | ctx.save(); 860 | ctx.translate(x, y); 861 | ctx.scale(scale, scale); 862 | ctx.drawImage(img, -svgWidth / 2, -svgHeight / 2, svgWidth, svgHeight); 863 | ctx.restore(); 864 | 865 | // 清理 URL 对象 866 | URL.revokeObjectURL(url); 867 | }; 868 | 869 | // 设置图片源为 SVG 的 data URL 870 | img.src = url; 871 | 872 | // 添加错误处理 873 | img.onerror = (error) => { 874 | console.error("加载SVG图像时出错:", error); 875 | }; 876 | } 877 | 878 | /** 879 | * 绘制印章类型文字 880 | * @param centerX 圆心x坐标 881 | * @param centerY 圆心y坐标 882 | * @param radius 半径 883 | * @param text 文字 884 | * @param fontSize 字体大小 885 | * @param letterSpacing 字符间距 886 | * @param positionY 文字位置 887 | * @param fillColor 填充颜色 888 | */ 889 | private drawStampType( 890 | ctx: CanvasRenderingContext2D, 891 | stampType: IStampType, 892 | centerX: number, 893 | centerY: number, 894 | radiusX: number 895 | ) { 896 | const fontSize = stampType.fontHeight * this.mmToPixel 897 | const letterSpacing = stampType.letterSpacing 898 | const positionY = stampType.positionY 899 | 900 | ctx.save() 901 | ctx.font = `${fontSize}px SimSun` 902 | ctx.fillStyle = this.primaryColor 903 | ctx.textAlign = 'center' 904 | ctx.textBaseline = 'middle' 905 | 906 | // 计算文字位置(在五角星正下方) 907 | const textY = centerY + radiusX * 0.5 + positionY * this.mmToPixel 908 | 909 | ctx.save() 910 | ctx.translate(centerX, textY) 911 | 912 | const chars = stampType.stampType.split('') 913 | const charWidths = chars.map((char) => ctx.measureText(char).width) 914 | const totalWidth = 915 | charWidths.reduce((sum, width) => sum + width, 0) + 916 | (chars.length - 1) * letterSpacing * this.mmToPixel 917 | 918 | let currentX = -totalWidth / 2 // 从文本的左边缘开始 919 | 920 | ctx.scale(this.drawStampConfigs.stampType.compression, 1) 921 | chars.forEach((char, index) => { 922 | ctx.fillText(char, currentX + charWidths[index] / 2, 0) // 绘制在字符的中心 923 | currentX += charWidths[index] + letterSpacing * this.mmToPixel 924 | }) 925 | 926 | ctx.restore() 927 | } 928 | 929 | /** 930 | * 绘制防伪纹路 931 | * @param centerX 圆心x坐标 932 | * @param centerY 圆心y坐标 933 | * @param radiusX 半径x 934 | * @param radiusY 半径y 935 | * @param securityPatternWidth 纹路宽度 936 | * @param securityPatternLength 纹路长度 937 | */ 938 | private drawSecurityPattern( 939 | ctx: CanvasRenderingContext2D, 940 | centerX: number, 941 | centerY: number, 942 | radiusX: number, 943 | radiusY: number, 944 | forceRefresh: boolean 945 | ) { 946 | if (!this.securityPattern.openSecurityPattern) return 947 | 948 | ctx.save() 949 | ctx.strokeStyle = '#FFFFFF' 950 | ctx.lineWidth = this.securityPattern.securityPatternWidth * this.mmToPixel 951 | ctx.globalCompositeOperation = 'destination-out' 952 | 953 | const angleRangeRad = (this.securityPattern.securityPatternAngleRange * Math.PI) / 180 954 | 955 | // 如果需要刷新或者参数数组为空,则重新生成参数 956 | if (forceRefresh || this.drawStampConfigs.securityPattern.securityPatternParams.length === 0) { 957 | this.drawStampConfigs.securityPattern.securityPatternParams = [] 958 | for (let i = 0; i < this.securityPattern.securityPatternCount; i++) { 959 | const angle = Math.random() * Math.PI * 2 960 | const normalAngle = Math.atan2(radiusY * Math.cos(angle), radiusX * Math.sin(angle)) 961 | const lineAngle = normalAngle + (Math.random() - 0.5) * angleRangeRad 962 | this.drawStampConfigs.securityPattern.securityPatternParams.push({ angle, lineAngle }) 963 | } 964 | } 965 | 966 | // 使用保存的参数绘制纹路 967 | this.drawStampConfigs.securityPattern.securityPatternParams.forEach(({ angle, lineAngle }) => { 968 | const x = centerX + radiusX * Math.cos(angle) 969 | const y = centerY + radiusY * Math.sin(angle) 970 | 971 | const length = this.securityPattern.securityPatternLength * this.mmToPixel 972 | const startX = x - (length / 2) * Math.cos(lineAngle) 973 | const startY = y - (length / 2) * Math.sin(lineAngle) 974 | const endX = x + (length / 2) * Math.cos(lineAngle) 975 | const endY = y + (length / 2) * Math.sin(lineAngle) 976 | 977 | ctx.beginPath() 978 | ctx.moveTo(startX, startY) 979 | ctx.lineTo(endX, endY) 980 | ctx.stroke() 981 | }) 982 | 983 | ctx.restore() 984 | } 985 | 986 | /** 987 | * 绘制椭圆 988 | * @param x 圆心x坐标 989 | * @param y 圆心y坐标 990 | * @param radiusX 半径x 991 | * @param radiusY 半径y 992 | * @param borderWidth 边框宽度 993 | * @param borderColor 边框颜色 994 | */ 995 | private drawEllipse( 996 | ctx: CanvasRenderingContext2D, 997 | x: number, 998 | y: number, 999 | radiusX: number, 1000 | radiusY: number, 1001 | borderWidth: number, 1002 | borderColor: string 1003 | ) { 1004 | ctx.beginPath() 1005 | ctx.ellipse(x, y, radiusX, radiusY, 0, 0, Math.PI * 2) 1006 | ctx.strokeStyle = borderColor 1007 | ctx.lineWidth = borderWidth 1008 | ctx.stroke() 1009 | } 1010 | 1011 | /** 1012 | * 绘制公司名称 1013 | * @param centerX 圆心x坐标 1014 | * @param centerY 圆心y坐标 1015 | * @param radiusX 椭圆长轴半径 1016 | * @param radiusY 椭圆短轴半径 1017 | * @param text 公司名称文本 1018 | * @param fontSize 字体大小 1019 | */ 1020 | private drawCompanyName( 1021 | ctx: CanvasRenderingContext2D, 1022 | company: ICompany, 1023 | centerX: number, 1024 | centerY: number, 1025 | radiusX: number, 1026 | radiusY: number 1027 | ) { 1028 | const fontSize = company.fontHeight * this.mmToPixel 1029 | ctx.save() 1030 | ctx.font = `${fontSize}px ${company.fontFamily}` 1031 | ctx.fillStyle = this.primaryColor 1032 | ctx.textAlign = 'center' 1033 | ctx.textBaseline = 'bottom' 1034 | 1035 | const characters = company.companyName.split('') 1036 | const characterCount = characters.length 1037 | const borderOffset = company.borderOffset * this.mmToPixel 1038 | 1039 | // 调整起始和结束角度,使文字均匀分布在椭圆上半部分 1040 | const totalAngle = Math.PI * (1 + characterCount / company.textDistributionFactor) 1041 | const startAngle = Math.PI + (Math.PI - totalAngle) / 2 1042 | const anglePerChar = totalAngle / characterCount 1043 | 1044 | characters.forEach((char, index) => { 1045 | const angle = startAngle + anglePerChar * (index + 0.5) 1046 | const x = centerX + Math.cos(angle) * (radiusX - fontSize - borderOffset) 1047 | const y = centerY + Math.sin(angle) * (radiusY - fontSize - borderOffset) 1048 | 1049 | ctx.save() 1050 | ctx.translate(x, y) 1051 | ctx.rotate(angle + Math.PI / 2) 1052 | ctx.scale(company.compression, 1) // 应用压缩 1053 | ctx.fillText(char, 0, 0) 1054 | ctx.restore() 1055 | }) 1056 | 1057 | ctx.restore() 1058 | } 1059 | 1060 | /** 1061 | * 绘制印章编码 1062 | * @param centerX 圆心x坐标 1063 | * @param centerY 圆心y坐标 1064 | * @param radiusX 椭圆长轴半径 1065 | * @param radiusY 椭圆短轴半径 1066 | * @param text 编码文本 1067 | * @param fontSize 字体大小 1068 | */ 1069 | private drawCode( 1070 | ctx: CanvasRenderingContext2D, 1071 | code: ICode, 1072 | centerX: number, 1073 | centerY: number, 1074 | radiusX: number, 1075 | radiusY: number 1076 | ) { 1077 | const fontSize = code.fontHeight * this.mmToPixel 1078 | const text = code.code 1079 | 1080 | ctx.save() 1081 | ctx.font = `${fontSize}px ${code.fontFamily}` 1082 | ctx.fillStyle = this.primaryColor 1083 | ctx.textAlign = 'center' 1084 | ctx.textBaseline = 'middle' 1085 | 1086 | const characters = text.split('') 1087 | const characterCount = characters.length 1088 | 1089 | // 动态调整总角度 1090 | // const totalAngle = Math.PI * (characterCount / 20) * 0.5 1091 | const totalAngle = Math.PI * ((1 + characterCount) / code.textDistributionFactor) 1092 | const startAngle = Math.PI / 2 + totalAngle / 2 1093 | const anglePerChar = totalAngle / (characterCount - 1) 1094 | 1095 | characters.forEach((char, index) => { 1096 | const angle = startAngle - anglePerChar * index 1097 | const x = 1098 | centerX + Math.cos(angle) * (radiusX - fontSize / 2 - code.borderOffset * this.mmToPixel) 1099 | const y = 1100 | centerY + Math.sin(angle) * (radiusY - fontSize / 2 - code.borderOffset * this.mmToPixel) 1101 | 1102 | ctx.save() 1103 | ctx.translate(x, y) 1104 | ctx.rotate(angle - Math.PI / 2) // 逆时针旋转文字 1105 | ctx.scale(code.compression, 1) // 应用压缩 1106 | ctx.fillText(char, 0, 0) 1107 | ctx.restore() 1108 | }) 1109 | 1110 | ctx.restore() 1111 | } 1112 | 1113 | /** 1114 | * 绘制税号 1115 | * @param ctx 画布上下文 1116 | * @param centerX 圆心x坐标 1117 | * @param centerY 圆心y坐标 1118 | */ 1119 | private drawTaxNumber( 1120 | ctx: CanvasRenderingContext2D, 1121 | taxNumber: ITaxNumber, 1122 | centerX: number, 1123 | centerY: number 1124 | ) { 1125 | const fontSize = taxNumber.fontHeight * this.mmToPixel 1126 | const totalWidth = taxNumber.totalWidth * this.mmToPixel 1127 | const positionY = taxNumber.positionY * this.mmToPixel + 0.3 1128 | 1129 | ctx.save() 1130 | ctx.font = `${fontSize}px ${taxNumber.fontFamily}` 1131 | ctx.fillStyle = this.primaryColor 1132 | ctx.textAlign = 'center' 1133 | ctx.textBaseline = 'middle' 1134 | 1135 | const characters = taxNumber.code.split('') 1136 | const charCount = characters.length 1137 | const letterSpacing = this.drawStampConfigs.taxNumber.letterSpacing * this.mmToPixel 1138 | 1139 | // 计算压缩后的总宽度 1140 | const compressedTotalWidth = totalWidth * this.drawStampConfigs.taxNumber.compression 1141 | 1142 | // 计算单个字符的宽度(考虑压缩) 1143 | const charWidth = (compressedTotalWidth - (charCount - 1) * letterSpacing) / charCount 1144 | 1145 | // 计算整个文本的实际宽度 1146 | const actualWidth = charCount * charWidth + (charCount - 1) * letterSpacing 1147 | 1148 | // 计算起始位置,确保文字居中 1149 | const startX = centerX - actualWidth / 2 + charWidth / 2 1150 | const adjustedCenterY = centerY + positionY * this.mmToPixel 1151 | 1152 | characters.forEach((char, index) => { 1153 | const x = startX + index * (charWidth + letterSpacing) 1154 | ctx.save() 1155 | ctx.translate(x, adjustedCenterY) 1156 | ctx.scale(this.drawStampConfigs.taxNumber.compression, 1.35) 1157 | ctx.fillText(char, 0, 0) 1158 | ctx.restore() 1159 | }) 1160 | ctx.restore() 1161 | 1162 | // // 绘制包含税号的矩形 1163 | // const rectWidth = 26 * this.mmToPixel // 26mm 转换为像素 1164 | // const rectHeight = fontSize * 1.2 // 矩形高度略大于字体大小 1165 | // const rectX = centerX - rectWidth / 2 1166 | // const rectY = adjustedCenterY - rectHeight / 2 1167 | 1168 | // ctx.save() 1169 | // ctx.strokeStyle = this.primaryColor 1170 | // ctx.lineWidth = 1 1171 | // ctx.strokeRect(rectX, rectY, rectWidth, rectHeight) 1172 | // ctx.restore() 1173 | } 1174 | 1175 | 1176 | /** 1177 | * 添加做旧效果 1178 | * @param width 画布宽度 1179 | * @param height 画布高度 1180 | * @param forceRefresh 是否强制刷新 1181 | */ 1182 | private addAgingEffect( 1183 | ctx: CanvasRenderingContext2D, 1184 | width: number, 1185 | height: number, 1186 | forceRefresh: boolean = false 1187 | ) { 1188 | if (!this.drawStampConfigs.agingEffect.applyAging) return; 1189 | const imageData = ctx.getImageData(0, 0, width, height); 1190 | const data = imageData.data; 1191 | 1192 | const centerX = width / (2 * this.scale) + this.stampOffsetX * this.mmToPixel / this.scale; 1193 | const centerY = height / (2 * this.scale) + this.stampOffsetY * this.mmToPixel / this.scale; 1194 | const radius = (Math.max(width, height) / 2) * this.mmToPixel / this.scale; 1195 | 1196 | 1197 | // 如果需要刷新或者参数数组为空,则重新生成参数 1198 | if (forceRefresh || this.drawStampConfigs.agingEffect.agingEffectParams.length === 0) { 1199 | this.drawStampConfigs.agingEffect.agingEffectParams = [] 1200 | for (let y = 0; y < height; y++) { 1201 | for (let x = 0; x < width; x++) { 1202 | const index = (y * width + x) * 4 1203 | const distanceFromCenter = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)) 1204 | if ( 1205 | distanceFromCenter <= radius && 1206 | data[index] > 200 && 1207 | data[index + 1] < 50 && 1208 | data[index + 2] < 50 1209 | ) { 1210 | const intensityFactor = this.drawStampConfigs.agingEffect.agingIntensity / 100 1211 | const seed = Math.random() 1212 | this.drawStampConfigs.agingEffect.agingEffectParams.push({ 1213 | x: x - this.stampOffsetX * this.mmToPixel, 1214 | y: y - this.stampOffsetY * this.mmToPixel, 1215 | noiseSize: Math.random() * 3 + 1, 1216 | noise: Math.random() * 200 * intensityFactor, 1217 | strongNoiseSize: Math.random() * 5 + 2, 1218 | strongNoise: Math.random() * 250 * intensityFactor + 5, 1219 | fade: Math.random() * 50 * intensityFactor, 1220 | seed: seed 1221 | }) 1222 | } 1223 | } 1224 | } 1225 | } 1226 | 1227 | // 使用保存的参数应用做旧效果 1228 | this.drawStampConfigs.agingEffect.agingEffectParams.forEach((param) => { 1229 | const { x, y, noiseSize, noise, strongNoiseSize, strongNoise, fade, seed } = param 1230 | const adjustedX = x + this.stampOffsetX * this.mmToPixel 1231 | const adjustedY = y + this.stampOffsetY * this.mmToPixel 1232 | const index = (Math.round(adjustedY) * width + Math.round(adjustedX)) * 4 1233 | 1234 | if (seed < 0.4) { 1235 | this.addCircularNoise(data, width, adjustedX, adjustedY, noiseSize, noise, true) 1236 | } 1237 | 1238 | if (seed < 0.05) { 1239 | this.addCircularNoise(data, width, adjustedX, adjustedY, strongNoiseSize, strongNoise, true) 1240 | } 1241 | 1242 | if (seed < 0.2) { 1243 | data[index + 3] = Math.max(0, data[index + 3] - fade) // 修改这里,只改变透明度 1244 | } 1245 | }) 1246 | 1247 | ctx.putImageData(imageData, 0, 0) 1248 | } 1249 | 1250 | private addCircularNoise( 1251 | data: Uint8ClampedArray, 1252 | width: number, 1253 | x: number, 1254 | y: number, 1255 | size: number, 1256 | intensity: number, 1257 | transparent: boolean = false 1258 | ) { 1259 | const radiusSquared = (size * size) / 4 1260 | for (let dy = -size / 2; dy < size / 2; dy++) { 1261 | for (let dx = -size / 2; dx < size / 2; dx++) { 1262 | if (dx * dx + dy * dy <= radiusSquared) { 1263 | const nx = Math.round(x + dx) 1264 | const ny = Math.round(y + dy) 1265 | const nIndex = (ny * width + nx) * 4 1266 | if (nIndex >= 0 && nIndex < data.length) { 1267 | if (transparent) { 1268 | data[nIndex + 3] = Math.max(0, data[nIndex + 3] - intensity) // 只改变透明度 1269 | } else { 1270 | data[nIndex] = Math.min(255, data[nIndex] + intensity) 1271 | data[nIndex + 1] = Math.min(255, data[nIndex + 1] + intensity) 1272 | data[nIndex + 2] = Math.min(255, data[nIndex + 2] + intensity) 1273 | } 1274 | } 1275 | } 1276 | } 1277 | } 1278 | } 1279 | 1280 | /** 1281 | * 绘制全尺寸标尺 1282 | * @param width 画布宽度 1283 | * @param height 画布高度 1284 | */ 1285 | private drawFullRuler(ctx: CanvasRenderingContext2D, width: number, height: number) { 1286 | if (!this.ruler.showFullRuler) return; 1287 | 1288 | ctx.save(); 1289 | ctx.strokeStyle = '#bbbbbb'; // 浅灰色 1290 | ctx.lineWidth = 1; // 保持线宽不变 1291 | ctx.setLineDash([5, 5]); // 保持虚线样式不变 1292 | 1293 | const step = this.mmToPixel * 5; // 5mm的像素长度 1294 | 1295 | // 绘制垂直线 1296 | for (let x = RULER_WIDTH; x < width; x += step * this.scale) { 1297 | ctx.beginPath(); 1298 | ctx.moveTo(x, RULER_HEIGHT); 1299 | ctx.lineTo(x, height); 1300 | ctx.stroke(); 1301 | } 1302 | 1303 | // 绘制水平线 1304 | for (let y = RULER_HEIGHT; y < height; y += step * this.scale) { 1305 | ctx.beginPath(); 1306 | ctx.moveTo(RULER_WIDTH, y); 1307 | ctx.lineTo(width, y); 1308 | ctx.stroke(); 1309 | } 1310 | 1311 | ctx.restore(); 1312 | } 1313 | 1314 | /** 1315 | * 绘制标尺 1316 | * @param rulerLength 标尺长度 1317 | * @param rulerSize 标尺宽度 1318 | * @param isHorizontal 是否为水平标尺 1319 | */ 1320 | private drawRuler( 1321 | ctx: CanvasRenderingContext2D, 1322 | rulerLength: number, 1323 | rulerSize: number, 1324 | isHorizontal: boolean 1325 | ) { 1326 | if (!this.ruler.showRuler) return; 1327 | 1328 | const mmPerPixel = 1 / this.mmToPixel; 1329 | 1330 | ctx.save(); 1331 | ctx.fillStyle = 'lightgray'; 1332 | if (isHorizontal) { 1333 | ctx.fillRect(0, 0, rulerLength, rulerSize); 1334 | } else { 1335 | ctx.fillRect(0, 0, rulerSize, rulerLength); 1336 | } 1337 | 1338 | ctx.fillStyle = 'black'; 1339 | ctx.font = '10px Arial'; // 保持字体大小不变 1340 | ctx.textAlign = 'center'; 1341 | ctx.textBaseline = 'top'; 1342 | 1343 | const step = this.mmToPixel; // 1mm的像素长度 1344 | const maxMM = Math.ceil((rulerLength - rulerSize) * mmPerPixel / this.scale); 1345 | 1346 | for (let mm = 0; mm <= maxMM; mm++) { 1347 | const pos = mm * step * this.scale + rulerSize; 1348 | 1349 | if (mm % 5 === 0) { 1350 | ctx.beginPath(); 1351 | if (isHorizontal) { 1352 | ctx.moveTo(pos, 0); 1353 | ctx.lineTo(pos, rulerSize * 0.8); 1354 | } else { 1355 | ctx.moveTo(0, pos); 1356 | ctx.lineTo(rulerSize * 0.8, pos); 1357 | } 1358 | ctx.lineWidth = 1; // 保持线宽不变 1359 | ctx.stroke(); 1360 | 1361 | ctx.save(); 1362 | if (isHorizontal) { 1363 | ctx.fillText(mm.toString(), pos, rulerSize * 0.8); 1364 | } else { 1365 | ctx.translate(rulerSize * 0.8, pos); 1366 | ctx.rotate(-Math.PI / 2); 1367 | ctx.fillText(mm.toString(), 0, 0); 1368 | } 1369 | ctx.restore(); 1370 | } else { 1371 | ctx.beginPath(); 1372 | if (isHorizontal) { 1373 | ctx.moveTo(pos, 0); 1374 | ctx.lineTo(pos, rulerSize * 0.6); 1375 | } else { 1376 | ctx.moveTo(0, pos); 1377 | ctx.lineTo(rulerSize * 0.6, pos); 1378 | } 1379 | ctx.lineWidth = 0.5; // 保持线宽不变 1380 | ctx.stroke(); 1381 | } 1382 | } 1383 | 1384 | ctx.restore(); 1385 | } 1386 | 1387 | /** 1388 | * 将印章保存为PNG图片 1389 | * @param outputSize 输出图片的尺寸 1390 | */ 1391 | saveStampAsPNG(outputSize: number = 512) { 1392 | // 首先隐藏虚线 1393 | this.drawStampConfigs.shouldDrawRuler = false 1394 | this.refreshStamp() 1395 | setTimeout(() => { 1396 | // 创建一个新的 canvas 元素,大小为 outputSize x outputSize 1397 | const saveCanvas = document.createElement('canvas') 1398 | saveCanvas.width = outputSize 1399 | saveCanvas.height = outputSize 1400 | const saveCtx = saveCanvas.getContext('2d') 1401 | if (!saveCtx) return 1402 | 1403 | // 清除画布,使背景透明 1404 | saveCtx.clearRect(0, 0, outputSize, outputSize) 1405 | 1406 | // 计算原始 canvas 中印章的位置和大小 1407 | const originalStampSize = 1408 | (Math.max(this.drawStampConfigs.width, this.drawStampConfigs.height) + 2) * this.mmToPixel 1409 | const sourceX = 1410 | (this.canvas.width - originalStampSize) / 2 + this.stampOffsetX * this.mmToPixel 1411 | const sourceY = 1412 | (this.canvas.height - originalStampSize) / 2 + this.stampOffsetY * this.mmToPixel 1413 | 1414 | // 设置2%的边距 1415 | const margin = outputSize * 0.01 1416 | const drawSize = outputSize - 2 * margin 1417 | 1418 | // 将原始 canvas 中的印章部分绘制到新的 canvas 上,并调整大小 1419 | saveCtx.drawImage( 1420 | this.canvas, 1421 | sourceX, 1422 | sourceY, 1423 | originalStampSize, 1424 | originalStampSize, 1425 | margin, 1426 | margin, 1427 | drawSize, 1428 | drawSize 1429 | ) 1430 | 1431 | // 如果启用了做旧效果,在新的 canvas 上应用做旧效果 1432 | if (this.drawStampConfigs.agingEffect.applyAging) { 1433 | this.addAgingEffect(saveCtx, outputSize, outputSize, false) 1434 | } 1435 | 1436 | // 将新的 canvas 转换为 PNG 数据 URL 1437 | const dataURL = saveCanvas.toDataURL('image/png') 1438 | 1439 | // 创建一个临时的 元素来触发下载 1440 | const link = document.createElement('a') 1441 | link.href = dataURL 1442 | link.download = '印章.png' 1443 | document.body.appendChild(link) 1444 | link.click() 1445 | document.body.removeChild(link) 1446 | 1447 | // 首先隐藏虚线 1448 | this.drawStampConfigs.shouldDrawRuler = true 1449 | this.refreshStamp() 1450 | }, 50) 1451 | } 1452 | 1453 | // 刷新印章绘制 1454 | refreshStamp(refreshSecurityPattern: boolean = false, refreshOld: boolean = false) { 1455 | // 清除整个画布 1456 | this.canvasCtx.clearRect(0, 0, this.canvas.width, this.canvas.height); 1457 | 1458 | // 保存当前状态 1459 | this.canvasCtx.save(); 1460 | 1461 | // 应用缩放和平移 1462 | this.canvasCtx.translate(this.offsetX, this.offsetY); 1463 | this.canvasCtx.scale(this.scale, this.scale); 1464 | 1465 | // 计算画布中心点 1466 | const x = this.canvas.width / 2 / this.scale; 1467 | const y = this.canvas.height / 2 / this.scale; 1468 | const mmToPixel = this.mmToPixel; 1469 | const drawRadiusX = (this.drawStampConfigs.width - this.drawStampConfigs.borderWidth) / 2; 1470 | const drawRadiusY = (this.drawStampConfigs.height - this.drawStampConfigs.borderWidth) / 2; 1471 | const offsetX = this.stampOffsetX * this.mmToPixel; 1472 | const offsetY = this.stampOffsetY * this.mmToPixel; 1473 | const centerX = x + offsetX; 1474 | const centerY = y + offsetY; 1475 | 1476 | 1477 | this.drawStamp( 1478 | this.canvasCtx, 1479 | centerX, 1480 | centerY, 1481 | drawRadiusX * mmToPixel, 1482 | drawRadiusY * mmToPixel, 1483 | this.drawStampConfigs.borderWidth * mmToPixel, 1484 | this.drawStampConfigs.primaryColor, 1485 | refreshSecurityPattern, 1486 | refreshOld 1487 | ) 1488 | 1489 | // 恢复状态 1490 | this.canvasCtx.restore(); 1491 | 1492 | // 绘制标尺(如果需要) 1493 | if (this.drawStampConfigs.shouldDrawRuler) { 1494 | this.drawRuler(this.canvasCtx, this.canvas.width, RULER_HEIGHT, true); 1495 | this.drawRuler(this.canvasCtx, this.canvas.height, RULER_HEIGHT, false); 1496 | this.drawFullRuler(this.canvasCtx, this.canvas.width, this.canvas.height); 1497 | } 1498 | } 1499 | 1500 | /** 1501 | * 重置缩放比例为100% 1502 | */ 1503 | resetZoom() { 1504 | this.scale = 1; 1505 | this.offsetX = 0; 1506 | this.offsetY = 0; 1507 | this.refreshStamp(); 1508 | } 1509 | 1510 | 1511 | /** 1512 | * 绘制印章 1513 | * @param x 圆心x坐标 1514 | * @param y 圆心y坐标 1515 | * @param radiusX 半径x 1516 | * @param radiusY 半径y 1517 | * @param borderWidth 边框宽度 1518 | * @param borderColor 边框颜色 1519 | */ 1520 | drawStamp( 1521 | ctx: CanvasRenderingContext2D, 1522 | centerX: number, 1523 | centerY: number, 1524 | radiusX: number, 1525 | radiusY: number, 1526 | borderWidth: number, 1527 | borderColor: string, 1528 | refreshSecurityPattern: boolean = false, 1529 | refreshOld: boolean = false 1530 | ) { 1531 | // 清除整个画布 1532 | ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) 1533 | 1534 | // 在离屏 canvas 上绘制椭圆边框 1535 | const offscreenCanvas = this.offscreenCanvas 1536 | offscreenCanvas.width = this.canvas.width 1537 | offscreenCanvas.height = this.canvas.height 1538 | const offscreenCtx = offscreenCanvas.getContext('2d') 1539 | if (!offscreenCtx) return 1540 | offscreenCtx.beginPath() 1541 | offscreenCtx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2) 1542 | offscreenCtx.strokeStyle = 'white' // 使用白色,稍后会变成红色 1543 | offscreenCtx.lineWidth = borderWidth 1544 | offscreenCtx.stroke() 1545 | 1546 | // // 设置填充颜色为白色 1547 | offscreenCtx.fillStyle = 'white' 1548 | 1549 | // 设置画布背景 1550 | ctx.fillStyle = 'white' 1551 | ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) 1552 | 1553 | // 绘制椭圆 1554 | this.drawEllipse(offscreenCtx, centerX, centerY, radiusX, radiusY, borderWidth, borderColor) 1555 | 1556 | if (this.drawStampConfigs.innerCircle.drawInnerCircle) { 1557 | const innerCircle = this.drawStampConfigs.innerCircle 1558 | const innerCircleWidth = 1559 | (innerCircle.innerCircleLineRadiusX - innerCircle.innerCircleLineWidth) / 2 1560 | const innerCircleHeight = 1561 | (innerCircle.innerCircleLineRadiusY - innerCircle.innerCircleLineWidth) / 2 1562 | // 绘制内圈椭圆 1563 | this.drawEllipse( 1564 | offscreenCtx, 1565 | centerX, 1566 | centerY, 1567 | innerCircleWidth * this.mmToPixel, 1568 | innerCircleHeight * this.mmToPixel, 1569 | innerCircle.innerCircleLineWidth * this.mmToPixel, 1570 | this.drawStampConfigs.primaryColor 1571 | ) 1572 | } 1573 | 1574 | // 内部细圈 1575 | if (this.drawStampConfigs.outThinCircle.drawInnerCircle) { 1576 | const outThinCircle = this.drawStampConfigs.outThinCircle 1577 | const outThinCircleWidth = 1578 | (outThinCircle.innerCircleLineRadiusX - outThinCircle.innerCircleLineWidth) / 2 1579 | const outThinCircleHeight = 1580 | (outThinCircle.innerCircleLineRadiusY - outThinCircle.innerCircleLineWidth) / 2 1581 | // 绘制外部细圈椭圆 1582 | this.drawEllipse( 1583 | offscreenCtx, 1584 | centerX, 1585 | centerY, 1586 | outThinCircleWidth * this.mmToPixel, 1587 | outThinCircleHeight * this.mmToPixel, 1588 | outThinCircle.innerCircleLineWidth * this.mmToPixel, 1589 | this.drawStampConfigs.primaryColor 1590 | ) 1591 | } 1592 | 1593 | // 在椭圆边框上绘制防伪纹路 1594 | this.drawSecurityPattern( 1595 | offscreenCtx!!, 1596 | centerX, 1597 | centerY, 1598 | radiusX, 1599 | radiusY, 1600 | refreshSecurityPattern 1601 | ) 1602 | 1603 | // 绘制五角星 1604 | if (this.drawStampConfigs.drawStar.drawStar) { 1605 | this.drawStarShape(offscreenCtx, this.drawStampConfigs.drawStar, centerX, centerY) 1606 | } 1607 | 1608 | // 绘制公司名称 1609 | this.drawCompanyName( 1610 | offscreenCtx, 1611 | this.drawStampConfigs.company, 1612 | centerX, 1613 | centerY, 1614 | radiusX, 1615 | radiusY 1616 | ) 1617 | 1618 | // 绘制印章类型 1619 | this.drawStampType(offscreenCtx, this.drawStampConfigs.stampType, centerX, centerY, radiusX) 1620 | 1621 | // 绘制印章编码 1622 | this.drawCode(offscreenCtx, this.drawStampConfigs.stampCode, centerX, centerY, radiusX, radiusY) 1623 | 1624 | // 绘制纳税识别号 1625 | this.drawTaxNumber(offscreenCtx, this.drawStampConfigs.taxNumber, centerX, centerY) 1626 | 1627 | // 将离屏 canvas 的内容作为蒙版应用到主 canvas 1628 | ctx.save() 1629 | ctx.globalCompositeOperation = 'source-over' 1630 | ctx.fillStyle = borderColor 1631 | ctx.fillRect(0, 0, this.canvas.width, this.canvas.height) 1632 | ctx.globalCompositeOperation = 'destination-in' 1633 | ctx.drawImage(offscreenCanvas, 0, 0) 1634 | ctx.restore() 1635 | 1636 | // 在绘制完所有内容后,添加做旧效果 1637 | this.addAgingEffect(ctx, this.canvas.width, this.canvas.height, refreshOld) 1638 | 1639 | // if (this.drawStampConfigs.shouldDrawRuler) { 1640 | // // 绘制标尺 1641 | // this.drawRuler(ctx, this.canvas.width, RULER_HEIGHT, true) 1642 | // this.drawRuler(ctx, this.canvas.height, RULER_HEIGHT, false) 1643 | // // 将全尺寸标尺绘制到离屏canvas上 1644 | // this.drawFullRuler(ctx, this.canvas.width, this.canvas.height) 1645 | // } 1646 | } 1647 | } 1648 | --------------------------------------------------------------------------------