draw.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /**
  2. * 导入所需的工具函数和依赖
  3. */
  4. import { isNull } from "@/cool";
  5. import { generateFrame } from "./qrcode";
  6. import type { ClQrcodeMode } from "../../types";
  7. /**
  8. * 二维码生成配置选项接口
  9. * 定义了生成二维码所需的所有参数
  10. */
  11. export type QrcodeOptions = {
  12. ecc: string; // 纠错级别,可选 L/M/Q/H,纠错能力依次增强
  13. text: string; // 二维码内容,要编码的文本
  14. size: number; // 二维码尺寸,单位px
  15. foreground: string; // 前景色,二维码数据点的颜色
  16. background: string; // 背景色,二维码背景的颜色
  17. padding: number; // 内边距,二维码四周留白的距离
  18. logo: string; // logo图片地址,可以在二维码中心显示logo
  19. logoSize: number; // logo尺寸,logo图片的显示大小
  20. mode: ClQrcodeMode; // 二维码样式模式,支持矩形、圆形、线条、小方块
  21. pdColor: string | null; // 定位点颜色,三个角上定位图案的颜色,为null时使用前景色
  22. pdRadius: number; // 定位图案圆角半径,为0时绘制直角矩形
  23. };
  24. /**
  25. * 获取当前设备的像素比
  26. * 用于处理高分屏显示
  27. * @returns 设备像素比
  28. */
  29. function getRatio() {
  30. // #ifdef APP || MP-WEIXIN
  31. return uni.getWindowInfo().pixelRatio; // App和小程序环境
  32. // #endif
  33. // #ifdef H5
  34. return window.devicePixelRatio; // H5环境
  35. // #endif
  36. }
  37. /**
  38. * 绘制圆角矩形
  39. * 兼容不同平台的圆角矩形绘制方法
  40. * @param ctx Canvas上下文
  41. * @param x 矩形左上角x坐标
  42. * @param y 矩形左上角y坐标
  43. * @param width 矩形宽度
  44. * @param height 矩形高度
  45. * @param radius 圆角半径
  46. */
  47. function drawRoundedRect(
  48. ctx: CanvasRenderingContext2D,
  49. x: number,
  50. y: number,
  51. width: number,
  52. height: number,
  53. radius: number
  54. ) {
  55. if (radius <= 0) {
  56. // 圆角半径为0时直接绘制矩形
  57. ctx.fillRect(x, y, width, height);
  58. return;
  59. }
  60. // 限制圆角半径不超过矩形的一半
  61. const maxRadius = Math.min(width, height) / 2;
  62. const r = Math.min(radius, maxRadius);
  63. ctx.beginPath();
  64. ctx.moveTo(x + r, y);
  65. ctx.lineTo(x + width - r, y);
  66. ctx.arcTo(x + width, y, x + width, y + r, r);
  67. ctx.lineTo(x + width, y + height - r);
  68. ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
  69. ctx.lineTo(x + r, y + height);
  70. ctx.arcTo(x, y + height, x, y + height - r, r);
  71. ctx.lineTo(x, y + r);
  72. ctx.arcTo(x, y, x + r, y, r);
  73. ctx.closePath();
  74. ctx.fill();
  75. }
  76. /**
  77. * 绘制定位图案
  78. * 绘制7x7的定位图案,包含外框、内框和中心点
  79. * @param ctx Canvas上下文
  80. * @param startX 定位图案起始X坐标
  81. * @param startY 定位图案起始Y坐标
  82. * @param px 单个像素点大小
  83. * @param pdColor 定位图案颜色
  84. * @param background 背景颜色
  85. * @param radius 圆角半径
  86. */
  87. function drawPositionPattern(
  88. ctx: CanvasRenderingContext2D,
  89. startX: number,
  90. startY: number,
  91. px: number,
  92. pdColor: string,
  93. background: string,
  94. radius: number
  95. ) {
  96. const patternSize = px * 7; // 定位图案总尺寸 7x7
  97. // 绘制外层边框 (7x7)
  98. ctx.fillStyle = pdColor;
  99. drawRoundedRect(ctx, startX, startY, patternSize, patternSize, radius);
  100. // 绘制内层空心区域 (5x5)
  101. ctx.fillStyle = background;
  102. const innerStartX = startX + px;
  103. const innerStartY = startY + px;
  104. const innerSize = px * 5;
  105. const innerRadius = Math.max(0, radius - px); // 内层圆角适当减小
  106. drawRoundedRect(ctx, innerStartX, innerStartY, innerSize, innerSize, innerRadius);
  107. // 绘制中心实心区域 (3x3)
  108. ctx.fillStyle = pdColor;
  109. const centerStartX = startX + px * 2;
  110. const centerStartY = startY + px * 2;
  111. const centerSize = px * 3;
  112. const centerRadius = Math.max(0, radius - px * 2); // 中心圆角适当减小
  113. drawRoundedRect(ctx, centerStartX, centerStartY, centerSize, centerSize, centerRadius);
  114. }
  115. /**
  116. * 绘制二维码到Canvas上下文
  117. * 主要的二维码绘制函数,处理不同平台的兼容性
  118. * @param context Canvas上下文对象
  119. * @param options 二维码配置选项
  120. */
  121. export function drawQrcode(context: CanvasContext, options: QrcodeOptions) {
  122. // 获取2D绘图上下文
  123. const ctx = context.getContext("2d")!;
  124. if (isNull(ctx)) return;
  125. // 获取设备像素比,用于高清适配
  126. const ratio = getRatio();
  127. // App和小程序平台的画布初始化
  128. // #ifdef APP || MP-WEIXIN
  129. const c1 = ctx.canvas;
  130. // 清空画布并设置尺寸
  131. ctx.clearRect(0, 0, c1.offsetWidth, c1.offsetHeight);
  132. c1.width = c1.offsetWidth * ratio;
  133. c1.height = c1.offsetHeight * ratio;
  134. // #endif
  135. // #ifdef APP
  136. ctx.reset();
  137. // #endif
  138. // H5平台的画布初始化
  139. // #ifdef H5
  140. const c2 = context as HTMLCanvasElement;
  141. c2.width = c2.offsetWidth * ratio;
  142. c2.height = c2.offsetHeight * ratio;
  143. // #endif
  144. // 缩放画布以适配高分屏
  145. ctx.scale(ratio, ratio);
  146. // 生成二维码数据矩阵
  147. const frame = generateFrame(options.text, options.ecc);
  148. const points = frame.frameBuffer; // 点阵数据
  149. const width = frame.width; // 矩阵宽度
  150. // 计算二维码内容区域大小(减去四周的padding)
  151. const contentSize = options.size - options.padding * 2;
  152. // 计算每个数据点的实际像素大小
  153. const px = contentSize / width;
  154. // 二维码内容的起始位置(考虑padding)
  155. const offsetX = options.padding;
  156. const offsetY = options.padding;
  157. // 绘制整个画布背景
  158. ctx.fillStyle = options.background;
  159. ctx.fillRect(0, 0, options.size, options.size);
  160. /**
  161. * 判断坐标点是否在定位图案区域内
  162. * 二维码三个角上的定位图案是7x7的方块
  163. * @param i 横坐标
  164. * @param j 纵坐标
  165. * @param width 二维码宽度
  166. * @returns 是否是定位点
  167. */
  168. function isPositionDetectionPattern(i: number, j: number, width: number): boolean {
  169. // 判断三个角的定位图案(7x7)
  170. if (i < 7 && j < 7) return true; // 左上角
  171. if (i > width - 8 && j < 7) return true; // 右上角
  172. if (i < 7 && j > width - 8) return true; // 左下角
  173. return false;
  174. }
  175. /**
  176. * 判断坐标点是否在Logo区域内(包含缓冲区)
  177. * @param i 横坐标
  178. * @param j 纵坐标
  179. * @param width 二维码宽度
  180. * @param logoSize logo尺寸(像素)
  181. * @param px 单个数据点像素大小
  182. * @returns 是否在logo区域内
  183. */
  184. function isInLogoArea(
  185. i: number,
  186. j: number,
  187. width: number,
  188. logoSize: number,
  189. px: number
  190. ): boolean {
  191. if (logoSize <= 0) return false;
  192. // 计算logo在矩阵中占用的点数,限制最大不超过二维码总宽度的25%
  193. // 根据二维码标准,中心区域最多可以遮挡约30%的数据,但为了确保识别率,我们限制在20%
  194. const maxLogoRatio = 0.2; // 20%的区域用于logo
  195. const maxLogoPoints = Math.floor(width * maxLogoRatio);
  196. const logoPoints = Math.min(Math.ceil(logoSize / px), maxLogoPoints);
  197. // 减少缓冲区,只保留必要的边距,避免过度遮挡数据
  198. // 当logo较小时不需要缓冲区,当logo较大时才添加最小缓冲区
  199. const buffer = logoPoints > width * 0.1 ? 1 : 0;
  200. const totalLogoPoints = logoPoints + buffer * 2;
  201. // 计算logo区域在矩阵中的中心位置
  202. const centerI = Math.floor(width / 2);
  203. const centerJ = Math.floor(width / 2);
  204. // 计算logo区域的边界
  205. const halfSize = Math.floor(totalLogoPoints / 2);
  206. const minI = centerI - halfSize;
  207. const maxI = centerI + halfSize;
  208. const minJ = centerJ - halfSize;
  209. const maxJ = centerJ + halfSize;
  210. // 判断当前点是否在logo区域内
  211. return i >= minI && i <= maxI && j >= minJ && j <= maxJ;
  212. }
  213. // 先绘制定位图案
  214. const pdColor = options.pdColor ?? options.foreground;
  215. const radius = options.pdRadius;
  216. // 绘制三个定位图案
  217. // 左上角 (0, 0)
  218. drawPositionPattern(ctx, offsetX, offsetY, px, pdColor, options.background, radius);
  219. // 右上角 (width-7, 0)
  220. drawPositionPattern(
  221. ctx,
  222. offsetX + (width - 7) * px,
  223. offsetY,
  224. px,
  225. pdColor,
  226. options.background,
  227. radius
  228. );
  229. // 左下角 (0, width-7)
  230. drawPositionPattern(
  231. ctx,
  232. offsetX,
  233. offsetY + (width - 7) * px,
  234. px,
  235. pdColor,
  236. options.background,
  237. radius
  238. );
  239. // 点的间距,用于圆形和小方块模式
  240. const dot = px * 0.1;
  241. // 遍历绘制数据点(跳过定位图案区域和logo区域)
  242. for (let i = 0; i < width; i++) {
  243. for (let j = 0; j < width; j++) {
  244. if (points[j * width + i] > 0) {
  245. // 跳过定位图案区域
  246. if (isPositionDetectionPattern(i, j, width)) {
  247. continue;
  248. }
  249. // 跳过logo区域(包含缓冲区)
  250. if (options.logo != "" && isInLogoArea(i, j, width, options.logoSize, px)) {
  251. continue;
  252. }
  253. // 绘制数据点
  254. ctx.fillStyle = options.foreground;
  255. const x = offsetX + px * i;
  256. const y = offsetY + px * j;
  257. // 根据不同模式绘制数据点
  258. switch (options.mode) {
  259. case "line": // 线条模式 - 绘制水平线条
  260. ctx.fillRect(x, y, px, px / 2);
  261. break;
  262. case "circular": // 圆形模式 - 绘制圆点
  263. ctx.beginPath();
  264. const rx = x + px / 2 - dot;
  265. const ry = y + px / 2 - dot;
  266. ctx.arc(rx, ry, px / 2 - dot, 0, 2 * Math.PI);
  267. ctx.fill();
  268. ctx.closePath();
  269. break;
  270. case "rectSmall": // 小方块模式 - 绘制小一号的方块
  271. ctx.fillRect(x + dot, y + dot, px - dot * 2, px - dot * 2);
  272. break;
  273. default: // 默认实心方块模式
  274. ctx.fillRect(x, y, px, px);
  275. }
  276. }
  277. }
  278. }
  279. // 绘制 Logo
  280. if (options.logo != "") {
  281. let img: Image;
  282. // 微信小程序环境创建图片
  283. // #ifdef MP-WEIXIN || APP-HARMONY
  284. img = context.createImage();
  285. // #endif
  286. // 其他环境创建图片
  287. // #ifndef MP-WEIXIN || APP-HARMONY
  288. img = new Image(options.logoSize, options.logoSize);
  289. // #endif
  290. // 设置图片加载完成后的回调,然后设置图片源
  291. img.onload = () => {
  292. drawLogo(ctx, options, img);
  293. };
  294. img.src = options.logo;
  295. }
  296. }
  297. /**
  298. * 在二维码中心绘制Logo
  299. * 在二维码中心位置绘制Logo图片,优化背景处理以减少对二维码数据的影响
  300. * @param ctx Canvas上下文
  301. * @param options 二维码配置
  302. * @param img Logo图片对象
  303. */
  304. function drawLogo(ctx: CanvasRenderingContext2D, options: QrcodeOptions, img: Image) {
  305. ctx.save(); // 保存当前绘图状态
  306. // 计算二维码内容区域的中心位置(考虑padding)
  307. const contentSize = options.size - options.padding * 2;
  308. const contentCenterX = options.padding + contentSize / 2;
  309. const contentCenterY = options.padding + contentSize / 2;
  310. // 优化背景处理:减少背景边距,最小化对二维码数据的影响
  311. // 背景边距从6px减少到3px,降低对数据点的遮挡
  312. const backgroundPadding = 3; // 背景比logo大3px
  313. const backgroundSize = options.logoSize + backgroundPadding * 2;
  314. // 绘制白色背景作为Logo的底色(适当大于logo以确保可读性)
  315. ctx.fillStyle = options.background; // 使用二维码背景色而不是固定白色,保持一致性
  316. const backgroundX = contentCenterX - backgroundSize / 2;
  317. const backgroundY = contentCenterY - backgroundSize / 2;
  318. // 绘制圆角背景,让logo与二维码更好融合
  319. const cornerRadius = Math.min(backgroundSize * 0.1, 6); // 背景圆角半径
  320. drawRoundedRect(ctx, backgroundX, backgroundY, backgroundSize, backgroundSize, cornerRadius);
  321. // 获取图片信息后绘制Logo
  322. uni.getImageInfo({
  323. src: options.logo,
  324. success: (imgInfo) => {
  325. // 计算logo的精确位置
  326. const logoX = contentCenterX - options.logoSize / 2;
  327. const logoY = contentCenterY - options.logoSize / 2;
  328. // 绘制Logo图片,减少边距从3px到1.5px,让logo更大一些
  329. const logoPadding = 1.5;
  330. const actualLogoSize = options.logoSize - logoPadding * 2;
  331. // #ifdef APP-HARMONY
  332. ctx.drawImage(
  333. img,
  334. logoX + logoPadding,
  335. logoY + logoPadding,
  336. actualLogoSize,
  337. actualLogoSize,
  338. 0,
  339. 0,
  340. imgInfo.width,
  341. imgInfo.height
  342. );
  343. // #endif
  344. // #ifndef APP-HARMONY
  345. ctx.drawImage(img, logoX + logoPadding, logoY + logoPadding, actualLogoSize, actualLogoSize);
  346. // #endif
  347. ctx.restore(); // 恢复之前的绘图状态
  348. },
  349. fail(err) {
  350. console.error(err);
  351. }
  352. });
  353. }