|
| 1 | +import type { CSSProperties } from 'vue'; |
| 2 | +import { defineComponent, ref, watch, computed, watchEffect } from 'vue'; |
| 3 | +import { qrProps } from './interface'; |
| 4 | + |
| 5 | +import qrcodegen from './qrcodegen'; |
| 6 | + |
| 7 | +type Modules = ReturnType<qrcodegen.QrCode['getModules']>; |
| 8 | +type Excavation = { x: number; y: number; w: number; h: number }; |
| 9 | + |
| 10 | +const ERROR_LEVEL_MAP: { [index: string]: qrcodegen.QrCode.Ecc } = { |
| 11 | + L: qrcodegen.QrCode.Ecc.LOW, |
| 12 | + M: qrcodegen.QrCode.Ecc.MEDIUM, |
| 13 | + Q: qrcodegen.QrCode.Ecc.QUARTILE, |
| 14 | + H: qrcodegen.QrCode.Ecc.HIGH, |
| 15 | +}; |
| 16 | + |
| 17 | +type ImageSettings = { |
| 18 | + src: string; |
| 19 | + height: number; |
| 20 | + width: number; |
| 21 | + excavate: boolean; |
| 22 | + x?: number; |
| 23 | + y?: number; |
| 24 | +}; |
| 25 | + |
| 26 | +const DEFAULT_SIZE = 128; |
| 27 | +const DEFAULT_LEVEL = 'L'; |
| 28 | +const DEFAULT_BGCOLOR = '#FFFFFF'; |
| 29 | +const DEFAULT_FGCOLOR = '#000000'; |
| 30 | +const DEFAULT_INCLUDEMARGIN = false; |
| 31 | + |
| 32 | +const SPEC_MARGIN_SIZE = 4; |
| 33 | +const DEFAULT_MARGIN_SIZE = 0; |
| 34 | + |
| 35 | +// This is *very* rough estimate of max amount of QRCode allowed to be covered. |
| 36 | +// It is "wrong" in a lot of ways (area is a terrible way to estimate, it |
| 37 | +// really should be number of modules covered), but if for some reason we don't |
| 38 | +// get an explicit height or width, I'd rather default to something than throw. |
| 39 | +const DEFAULT_IMG_SCALE = 0.1; |
| 40 | + |
| 41 | +function generatePath(modules: Modules, margin = 0): string { |
| 42 | + const ops: Array<string> = []; |
| 43 | + modules.forEach(function (row, y) { |
| 44 | + let start: number | null = null; |
| 45 | + row.forEach(function (cell, x) { |
| 46 | + if (!cell && start !== null) { |
| 47 | + // M0 0h7v1H0z injects the space with the move and drops the comma, |
| 48 | + // saving a char per operation |
| 49 | + ops.push(`M${start + margin} ${y + margin}h${x - start}v1H${start + margin}z`); |
| 50 | + start = null; |
| 51 | + return; |
| 52 | + } |
| 53 | + |
| 54 | + // end of row, clean up or skip |
| 55 | + if (x === row.length - 1) { |
| 56 | + if (!cell) { |
| 57 | + // We would have closed the op above already so this can only mean |
| 58 | + // 2+ light modules in a row. |
| 59 | + return; |
| 60 | + } |
| 61 | + if (start === null) { |
| 62 | + // Just a single dark module. |
| 63 | + ops.push(`M${x + margin},${y + margin} h1v1H${x + margin}z`); |
| 64 | + } else { |
| 65 | + // Otherwise finish the current line. |
| 66 | + ops.push(`M${start + margin},${y + margin} h${x + 1 - start}v1H${start + margin}z`); |
| 67 | + } |
| 68 | + return; |
| 69 | + } |
| 70 | + |
| 71 | + if (cell && start === null) { |
| 72 | + start = x; |
| 73 | + } |
| 74 | + }); |
| 75 | + }); |
| 76 | + return ops.join(''); |
| 77 | +} |
| 78 | + |
| 79 | +// We could just do this in generatePath, except that we want to support |
| 80 | +// non-Path2D canvas, so we need to keep it an explicit step. |
| 81 | +function excavateModules(modules: Modules, excavation: Excavation): Modules { |
| 82 | + return modules.slice().map((row, y) => { |
| 83 | + if (y < excavation.y || y >= excavation.y + excavation.h) { |
| 84 | + return row; |
| 85 | + } |
| 86 | + return row.map((cell, x) => { |
| 87 | + if (x < excavation.x || x >= excavation.x + excavation.w) { |
| 88 | + return cell; |
| 89 | + } |
| 90 | + return false; |
| 91 | + }); |
| 92 | + }); |
| 93 | +} |
| 94 | + |
| 95 | +function getImageSettings( |
| 96 | + cells: Modules, |
| 97 | + size: number, |
| 98 | + margin: number, |
| 99 | + imageSettings?: ImageSettings, |
| 100 | +): null | { |
| 101 | + x: number; |
| 102 | + y: number; |
| 103 | + h: number; |
| 104 | + w: number; |
| 105 | + excavation: Excavation | null; |
| 106 | +} { |
| 107 | + if (imageSettings == null) { |
| 108 | + return null; |
| 109 | + } |
| 110 | + const numCells = cells.length + margin * 2; |
| 111 | + const defaultSize = Math.floor(size * DEFAULT_IMG_SCALE); |
| 112 | + const scale = numCells / size; |
| 113 | + const w = (imageSettings.width || defaultSize) * scale; |
| 114 | + const h = (imageSettings.height || defaultSize) * scale; |
| 115 | + const x = imageSettings.x == null ? cells.length / 2 - w / 2 : imageSettings.x * scale; |
| 116 | + const y = imageSettings.y == null ? cells.length / 2 - h / 2 : imageSettings.y * scale; |
| 117 | + |
| 118 | + let excavation = null; |
| 119 | + if (imageSettings.excavate) { |
| 120 | + const floorX = Math.floor(x); |
| 121 | + const floorY = Math.floor(y); |
| 122 | + const ceilW = Math.ceil(w + x - floorX); |
| 123 | + const ceilH = Math.ceil(h + y - floorY); |
| 124 | + excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH }; |
| 125 | + } |
| 126 | + |
| 127 | + return { x, y, h, w, excavation }; |
| 128 | +} |
| 129 | + |
| 130 | +function getMarginSize(includeMargin: boolean, marginSize?: number): number { |
| 131 | + if (marginSize != null) { |
| 132 | + return Math.floor(marginSize); |
| 133 | + } |
| 134 | + return includeMargin ? SPEC_MARGIN_SIZE : DEFAULT_MARGIN_SIZE; |
| 135 | +} |
| 136 | + |
| 137 | +// For canvas we're going to switch our drawing mode based on whether or not |
| 138 | +// the environment supports Path2D. We only need the constructor to be |
| 139 | +// supported, but Edge doesn't actually support the path (string) type |
| 140 | +// argument. Luckily it also doesn't support the addPath() method. We can |
| 141 | +// treat that as the same thing. |
| 142 | +const SUPPORTS_PATH2D = (function () { |
| 143 | + try { |
| 144 | + new Path2D().addPath(new Path2D()); |
| 145 | + } catch (e) { |
| 146 | + return false; |
| 147 | + } |
| 148 | + return true; |
| 149 | +})(); |
| 150 | + |
| 151 | +export const QRCodeCanvas = defineComponent({ |
| 152 | + name: 'QRCodeCanvas', |
| 153 | + inheritAttrs: false, |
| 154 | + props: { ...qrProps(), level: String, bgColor: String, fgColor: String, marginSize: Number }, |
| 155 | + setup(props, { attrs, expose }) { |
| 156 | + const imgSrc = computed(() => props.imageSettings?.src); |
| 157 | + const _canvas = ref<HTMLCanvasElement>(null); |
| 158 | + const _image = ref<HTMLImageElement>(null); |
| 159 | + const isImgLoaded = ref(false); |
| 160 | + expose({ |
| 161 | + toDataURL: (type?: string, quality?: any) => { |
| 162 | + return _canvas.value?.toDataURL(type, quality); |
| 163 | + }, |
| 164 | + }); |
| 165 | + watchEffect( |
| 166 | + () => { |
| 167 | + const { |
| 168 | + value, |
| 169 | + size = DEFAULT_SIZE, |
| 170 | + level = DEFAULT_LEVEL, |
| 171 | + bgColor = DEFAULT_BGCOLOR, |
| 172 | + fgColor = DEFAULT_FGCOLOR, |
| 173 | + includeMargin = DEFAULT_INCLUDEMARGIN, |
| 174 | + marginSize, |
| 175 | + imageSettings, |
| 176 | + } = props; |
| 177 | + if (_canvas.value != null) { |
| 178 | + const canvas = _canvas.value; |
| 179 | + |
| 180 | + const ctx = canvas.getContext('2d'); |
| 181 | + if (!ctx) { |
| 182 | + return; |
| 183 | + } |
| 184 | + |
| 185 | + let cells = qrcodegen.QrCode.encodeText(value, ERROR_LEVEL_MAP[level]).getModules(); |
| 186 | + const margin = getMarginSize(includeMargin, marginSize); |
| 187 | + const numCells = cells.length + margin * 2; |
| 188 | + const calculatedImageSettings = getImageSettings(cells, size, margin, imageSettings); |
| 189 | + |
| 190 | + const image = _image.value; |
| 191 | + const haveImageToRender = |
| 192 | + isImgLoaded.value && |
| 193 | + calculatedImageSettings != null && |
| 194 | + image !== null && |
| 195 | + image.complete && |
| 196 | + image.naturalHeight !== 0 && |
| 197 | + image.naturalWidth !== 0; |
| 198 | + |
| 199 | + if (haveImageToRender) { |
| 200 | + if (calculatedImageSettings.excavation != null) { |
| 201 | + cells = excavateModules(cells, calculatedImageSettings.excavation); |
| 202 | + } |
| 203 | + } |
| 204 | + |
| 205 | + // We're going to scale this so that the number of drawable units |
| 206 | + // matches the number of cells. This avoids rounding issues, but does |
| 207 | + // result in some potentially unwanted single pixel issues between |
| 208 | + // blocks, only in environments that don't support Path2D. |
| 209 | + const pixelRatio = window.devicePixelRatio || 1; |
| 210 | + canvas.height = canvas.width = size * pixelRatio; |
| 211 | + const scale = (size / numCells) * pixelRatio; |
| 212 | + ctx.scale(scale, scale); |
| 213 | + |
| 214 | + // Draw solid background, only paint dark modules. |
| 215 | + ctx.fillStyle = bgColor; |
| 216 | + ctx.fillRect(0, 0, numCells, numCells); |
| 217 | + |
| 218 | + ctx.fillStyle = fgColor; |
| 219 | + if (SUPPORTS_PATH2D) { |
| 220 | + // $FlowFixMe: Path2D c'tor doesn't support args yet. |
| 221 | + ctx.fill(new Path2D(generatePath(cells, margin))); |
| 222 | + } else { |
| 223 | + cells.forEach(function (row, rdx) { |
| 224 | + row.forEach(function (cell, cdx) { |
| 225 | + if (cell) { |
| 226 | + ctx.fillRect(cdx + margin, rdx + margin, 1, 1); |
| 227 | + } |
| 228 | + }); |
| 229 | + }); |
| 230 | + } |
| 231 | + |
| 232 | + if (haveImageToRender) { |
| 233 | + ctx.drawImage( |
| 234 | + image, |
| 235 | + calculatedImageSettings.x + margin, |
| 236 | + calculatedImageSettings.y + margin, |
| 237 | + calculatedImageSettings.w, |
| 238 | + calculatedImageSettings.h, |
| 239 | + ); |
| 240 | + } |
| 241 | + } |
| 242 | + }, |
| 243 | + { flush: 'post' }, |
| 244 | + ); |
| 245 | + watch(imgSrc, () => { |
| 246 | + isImgLoaded.value = false; |
| 247 | + }); |
| 248 | + |
| 249 | + return () => { |
| 250 | + const size = props.size ?? DEFAULT_SIZE; |
| 251 | + const canvasStyle = { height: size, width: size }; |
| 252 | + |
| 253 | + let img = null; |
| 254 | + if (imgSrc.value != null) { |
| 255 | + img = ( |
| 256 | + <img |
| 257 | + src={imgSrc.value} |
| 258 | + key={imgSrc.value} |
| 259 | + style={{ display: 'none' }} |
| 260 | + onLoad={() => { |
| 261 | + isImgLoaded.value = true; |
| 262 | + }} |
| 263 | + ref={_image} |
| 264 | + /> |
| 265 | + ); |
| 266 | + } |
| 267 | + return ( |
| 268 | + <> |
| 269 | + <canvas {...attrs} style={[canvasStyle, attrs.style as CSSProperties]} ref={_canvas} />; |
| 270 | + {img} |
| 271 | + </> |
| 272 | + ); |
| 273 | + }; |
| 274 | + }, |
| 275 | +}); |
0 commit comments