draw.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  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在矩阵中占用的点数
  193. const logoPoints = Math.ceil(logoSize / px);
  194. // 添加缓冲区,logo周围额外空出1个点的距离
  195. const buffer = 1;
  196. const totalLogoPoints = logoPoints + buffer * 2;
  197. // 计算logo区域在矩阵中的中心位置
  198. const centerI = Math.floor(width / 2);
  199. const centerJ = Math.floor(width / 2);
  200. // 计算logo区域的边界
  201. const halfSize = Math.floor(totalLogoPoints / 2);
  202. const minI = centerI - halfSize;
  203. const maxI = centerI + halfSize;
  204. const minJ = centerJ - halfSize;
  205. const maxJ = centerJ + halfSize;
  206. // 判断当前点是否在logo区域内
  207. return i >= minI && i <= maxI && j >= minJ && j <= maxJ;
  208. }
  209. // 先绘制定位图案
  210. const pdColor = options.pdColor ?? options.foreground;
  211. const radius = options.pdRadius;
  212. // 绘制三个定位图案
  213. // 左上角 (0, 0)
  214. drawPositionPattern(ctx, offsetX, offsetY, px, pdColor, options.background, radius);
  215. // 右上角 (width-7, 0)
  216. drawPositionPattern(
  217. ctx,
  218. offsetX + (width - 7) * px,
  219. offsetY,
  220. px,
  221. pdColor,
  222. options.background,
  223. radius
  224. );
  225. // 左下角 (0, width-7)
  226. drawPositionPattern(
  227. ctx,
  228. offsetX,
  229. offsetY + (width - 7) * px,
  230. px,
  231. pdColor,
  232. options.background,
  233. radius
  234. );
  235. // 点的间距,用于圆形和小方块模式
  236. const dot = px * 0.1;
  237. // 遍历绘制数据点(跳过定位图案区域和logo区域)
  238. for (let i = 0; i < width; i++) {
  239. for (let j = 0; j < width; j++) {
  240. if (points[j * width + i] > 0) {
  241. // 跳过定位图案区域
  242. if (isPositionDetectionPattern(i, j, width)) {
  243. continue;
  244. }
  245. // 跳过logo区域(包含缓冲区)
  246. if (options.logo != "" && isInLogoArea(i, j, width, options.logoSize, px)) {
  247. continue;
  248. }
  249. // 绘制数据点
  250. ctx.fillStyle = options.foreground;
  251. const x = offsetX + px * i;
  252. const y = offsetY + px * j;
  253. // 根据不同模式绘制数据点
  254. switch (options.mode) {
  255. case "line": // 线条模式 - 绘制水平线条
  256. ctx.fillRect(x, y, px, px / 2);
  257. break;
  258. case "circular": // 圆形模式 - 绘制圆点
  259. ctx.beginPath();
  260. const rx = x + px / 2 - dot;
  261. const ry = y + px / 2 - dot;
  262. ctx.arc(rx, ry, px / 2 - dot, 0, 2 * Math.PI);
  263. ctx.fill();
  264. ctx.closePath();
  265. break;
  266. case "rectSmall": // 小方块模式 - 绘制小一号的方块
  267. ctx.fillRect(x + dot, y + dot, px - dot * 2, px - dot * 2);
  268. break;
  269. default: // 默认实心方块模式
  270. ctx.fillRect(x, y, px, px);
  271. }
  272. }
  273. }
  274. }
  275. // 绘制 Logo
  276. if (options.logo != "") {
  277. let img: Image;
  278. // 微信小程序环境创建图片
  279. // #ifdef MP-WEIXIN || APP-HARMONY
  280. img = context.createImage();
  281. // #endif
  282. // 其他环境创建图片
  283. // #ifndef MP-WEIXIN || APP-HARMONY
  284. img = new Image(options.logoSize, options.logoSize);
  285. // #endif
  286. // 设置图片源并在加载完成后绘制
  287. img.src = options.logo;
  288. img.onload = () => {
  289. drawLogo(ctx, options, img);
  290. };
  291. }
  292. }
  293. /**
  294. * 在二维码中心绘制Logo
  295. * 在二维码中心位置绘制Logo图片,并添加白色背景
  296. * @param ctx Canvas上下文
  297. * @param options 二维码配置
  298. * @param img Logo图片对象
  299. */
  300. function drawLogo(ctx: CanvasRenderingContext2D, options: QrcodeOptions, img: Image) {
  301. ctx.save(); // 保存当前绘图状态
  302. // 计算二维码内容区域的中心位置(考虑padding)
  303. const contentSize = options.size - options.padding * 2;
  304. const contentCenterX = options.padding + contentSize / 2;
  305. const contentCenterY = options.padding + contentSize / 2;
  306. // 添加额外的背景边距,与数据点避让区域保持一致
  307. const backgroundPadding = 6; // 背景比logo大6px
  308. const backgroundSize = options.logoSize + backgroundPadding * 2;
  309. // 绘制白色背景作为Logo的底色(稍大于logo)
  310. ctx.fillStyle = "#fff";
  311. const backgroundX = contentCenterX - backgroundSize / 2;
  312. const backgroundY = contentCenterY - backgroundSize / 2;
  313. ctx.fillRect(backgroundX, backgroundY, backgroundSize, backgroundSize);
  314. // 获取图片信息后绘制Logo
  315. uni.getImageInfo({
  316. src: options.logo,
  317. success: (imgInfo) => {
  318. // 计算logo的精确位置
  319. const logoX = contentCenterX - options.logoSize / 2;
  320. const logoY = contentCenterY - options.logoSize / 2;
  321. // 绘制Logo图片,四周留出3px边距
  322. // #ifdef APP-HARMONY
  323. ctx.drawImage(
  324. img,
  325. logoX + 3,
  326. logoY + 3,
  327. options.logoSize - 6,
  328. options.logoSize - 6,
  329. 0,
  330. 0,
  331. imgInfo.width,
  332. imgInfo.height
  333. );
  334. // #endif
  335. // #ifndef APP-HARMONY
  336. ctx.drawImage(img, logoX + 3, logoY + 3, options.logoSize - 6, options.logoSize - 6);
  337. // #endif
  338. ctx.restore(); // 恢复之前的绘图状态
  339. },
  340. fail(err) {
  341. console.error(err);
  342. }
  343. });
  344. }