From 355c41b4aa637d314496088642803ffa2a021290 Mon Sep 17 00:00:00 2001 From: tangjinzhou <415800467@qq.com> Date: Fri, 3 Mar 2023 14:56:23 +0800 Subject: [PATCH] refactor: qrcode #6315 --- components/qrcode/QRCodeCanvas.tsx | 275 ++++++++ components/qrcode/demo/download.vue | 2 +- components/qrcode/demo/errorLevel.vue | 9 +- components/qrcode/index.tsx | 200 ++---- components/qrcode/interface.ts | 59 +- components/qrcode/qrcodegen.ts | 972 ++++++++++++++++++++++++++ components/qrcode/style/index.ts | 2 +- package.json | 1 - 8 files changed, 1363 insertions(+), 157 deletions(-) create mode 100644 components/qrcode/QRCodeCanvas.tsx create mode 100644 components/qrcode/qrcodegen.ts diff --git a/components/qrcode/QRCodeCanvas.tsx b/components/qrcode/QRCodeCanvas.tsx new file mode 100644 index 000000000..65cd48054 --- /dev/null +++ b/components/qrcode/QRCodeCanvas.tsx @@ -0,0 +1,275 @@ +import type { CSSProperties } from 'vue'; +import { defineComponent, ref, watch, computed, watchEffect } from 'vue'; +import { qrProps } from './interface'; + +import qrcodegen from './qrcodegen'; + +type Modules = ReturnType; +type Excavation = { x: number; y: number; w: number; h: number }; + +const ERROR_LEVEL_MAP: { [index: string]: qrcodegen.QrCode.Ecc } = { + L: qrcodegen.QrCode.Ecc.LOW, + M: qrcodegen.QrCode.Ecc.MEDIUM, + Q: qrcodegen.QrCode.Ecc.QUARTILE, + H: qrcodegen.QrCode.Ecc.HIGH, +}; + +type ImageSettings = { + src: string; + height: number; + width: number; + excavate: boolean; + x?: number; + y?: number; +}; + +const DEFAULT_SIZE = 128; +const DEFAULT_LEVEL = 'L'; +const DEFAULT_BGCOLOR = '#FFFFFF'; +const DEFAULT_FGCOLOR = '#000000'; +const DEFAULT_INCLUDEMARGIN = false; + +const SPEC_MARGIN_SIZE = 4; +const DEFAULT_MARGIN_SIZE = 0; + +// This is *very* rough estimate of max amount of QRCode allowed to be covered. +// It is "wrong" in a lot of ways (area is a terrible way to estimate, it +// really should be number of modules covered), but if for some reason we don't +// get an explicit height or width, I'd rather default to something than throw. +const DEFAULT_IMG_SCALE = 0.1; + +function generatePath(modules: Modules, margin = 0): string { + const ops: Array = []; + modules.forEach(function (row, y) { + let start: number | null = null; + row.forEach(function (cell, x) { + if (!cell && start !== null) { + // M0 0h7v1H0z injects the space with the move and drops the comma, + // saving a char per operation + ops.push(`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`); + start = null; + return; + } + + // end of row, clean up or skip + if (x === row.length - 1) { + if (!cell) { + // We would have closed the op above already so this can only mean + // 2+ light modules in a row. + return; + } + if (start === null) { + // Just a single dark module. + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); + } else { + // Otherwise finish the current line. + ops.push(`M${start + margin},${y + margin} h${x + 1 - start}v1H${start + margin}z`); + } + return; + } + + if (cell && start === null) { + start = x; + } + }); + }); + return ops.join(''); +} + +// We could just do this in generatePath, except that we want to support +// non-Path2D canvas, so we need to keep it an explicit step. +function excavateModules(modules: Modules, excavation: Excavation): Modules { + return modules.slice().map((row, y) => { + if (y < excavation.y || y >= excavation.y + excavation.h) { + return row; + } + return row.map((cell, x) => { + if (x < excavation.x || x >= excavation.x + excavation.w) { + return cell; + } + return false; + }); + }); +} + +function getImageSettings( + cells: Modules, + size: number, + margin: number, + imageSettings?: ImageSettings, +): null | { + x: number; + y: number; + h: number; + w: number; + excavation: Excavation | null; +} { + if (imageSettings == null) { + return null; + } + const numCells = cells.length + margin * 2; + const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE); + const scale = numCells / size; + const w = (imageSettings.width || defaultSize) * scale; + const h = (imageSettings.height || defaultSize) * scale; + const x = imageSettings.x == null ? cells.length / 2 - w / 2 : imageSettings.x * scale; + const y = imageSettings.y == null ? cells.length / 2 - h / 2 : imageSettings.y * scale; + + let excavation = null; + if (imageSettings.excavate) { + const floorX = Math.floor(x); + const floorY = Math.floor(y); + const ceilW = Math.ceil(w + x - floorX); + const ceilH = Math.ceil(h + y - floorY); + excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH }; + } + + return { x, y, h, w, excavation }; +} + +function getMarginSize(includeMargin: boolean, marginSize?: number): number { + if (marginSize != null) { + return Math.floor(marginSize); + } + return includeMargin ? SPEC_MARGIN_SIZE : DEFAULT_MARGIN_SIZE; +} + +// For canvas we're going to switch our drawing mode based on whether or not +// the environment supports Path2D. We only need the constructor to be +// supported, but Edge doesn't actually support the path (string) type +// argument. Luckily it also doesn't support the addPath() method. We can +// treat that as the same thing. +const SUPPORTS_PATH2D = (function () { + try { + new Path2D().addPath(new Path2D()); + } catch (e) { + return false; + } + return true; +})(); + +export const QRCodeCanvas = defineComponent({ + name: 'QRCodeCanvas', + inheritAttrs: false, + props: { ...qrProps(), level: String, bgColor: String, fgColor: String, marginSize: Number }, + setup(props, { attrs, expose }) { + const imgSrc = computed(() => props.imageSettings?.src); + const _canvas = ref(null); + const _image = ref(null); + const isImgLoaded = ref(false); + expose({ + toDataURL: (type?: string, quality?: any) => { + return _canvas.value?.toDataURL(type, quality); + }, + }); + watchEffect( + () => { + const { + value, + size = DEFAULT_SIZE, + level = DEFAULT_LEVEL, + bgColor = DEFAULT_BGCOLOR, + fgColor = DEFAULT_FGCOLOR, + includeMargin = DEFAULT_INCLUDEMARGIN, + marginSize, + imageSettings, + } = props; + if (_canvas.value != null) { + const canvas = _canvas.value; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + let cells = qrcodegen.QrCode.encodeText(value, ERROR_LEVEL_MAP[level]).getModules(); + const margin = getMarginSize(includeMargin, marginSize); + const numCells = cells.length + margin * 2; + const calculatedImageSettings = getImageSettings(cells, size, margin, imageSettings); + + const image = _image.value; + const haveImageToRender = + isImgLoaded.value && + calculatedImageSettings != null && + image !== null && + image.complete && + image.naturalHeight !== 0 && + image.naturalWidth !== 0; + + if (haveImageToRender) { + if (calculatedImageSettings.excavation != null) { + cells = excavateModules(cells, calculatedImageSettings.excavation); + } + } + + // We're going to scale this so that the number of drawable units + // matches the number of cells. This avoids rounding issues, but does + // result in some potentially unwanted single pixel issues between + // blocks, only in environments that don't support Path2D. + const pixelRatio = window.devicePixelRatio || 1; + canvas.height = canvas.width = size * pixelRatio; + const scale = (size / numCells) * pixelRatio; + ctx.scale(scale, scale); + + // Draw solid background, only paint dark modules. + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, numCells, numCells); + + ctx.fillStyle = fgColor; + if (SUPPORTS_PATH2D) { + // $FlowFixMe: Path2D c'tor doesn't support args yet. + ctx.fill(new Path2D(generatePath(cells, margin))); + } else { + cells.forEach(function (row, rdx) { + row.forEach(function (cell, cdx) { + if (cell) { + ctx.fillRect(cdx + margin, rdx + margin, 1, 1); + } + }); + }); + } + + if (haveImageToRender) { + ctx.drawImage( + image, + calculatedImageSettings.x + margin, + calculatedImageSettings.y + margin, + calculatedImageSettings.w, + calculatedImageSettings.h, + ); + } + } + }, + { flush: 'post' }, + ); + watch(imgSrc, () => { + isImgLoaded.value = false; + }); + + return () => { + const size = props.size ?? DEFAULT_SIZE; + const canvasStyle = { height: size, width: size }; + + let img = null; + if (imgSrc.value != null) { + img = ( + { + isImgLoaded.value = true; + }} + ref={_image} + /> + ); + } + return ( + <> + ; + {img} + + ); + }; + }, +}); diff --git a/components/qrcode/demo/download.vue b/components/qrcode/demo/download.vue index ee7e5bd01..9381fb0fd 100644 --- a/components/qrcode/demo/download.vue +++ b/components/qrcode/demo/download.vue @@ -28,7 +28,7 @@ export default defineComponent({ setup() { const qrcodeCanvasRef = ref(); const dowloadChange = async () => { - const url = await qrcodeCanvasRef.value.toDataUrl(); + const url = await qrcodeCanvasRef.value.toDataURL(); const a = document.createElement('a'); a.download = 'QRCode.png'; a.href = url; diff --git a/components/qrcode/demo/errorLevel.vue b/components/qrcode/demo/errorLevel.vue index 9b7142cc4..96472d018 100644 --- a/components/qrcode/demo/errorLevel.vue +++ b/components/qrcode/demo/errorLevel.vue @@ -15,18 +15,21 @@ set Error Level.