From b17dbd071edda7d9b50cc81823ed3893fca10404 Mon Sep 17 00:00:00 2001 From: caub Date: Wed, 20 Apr 2022 21:27:36 +0200 Subject: [PATCH] separate components in different files --- src/canvas.js | 145 ++++++++++++++++++++ src/helpers.js | 148 +++++++++++++++++++++ src/index.tsx | 353 +------------------------------------------------ src/svg.js | 66 +++++++++ 4 files changed, 363 insertions(+), 349 deletions(-) create mode 100644 src/canvas.js create mode 100644 src/helpers.js create mode 100644 src/svg.js diff --git a/src/canvas.js b/src/canvas.js new file mode 100644 index 0000000..e4ab313 --- /dev/null +++ b/src/canvas.js @@ -0,0 +1,145 @@ +import React, {useRef, useEffect} from 'react'; +import qrcodegen from './third-party/qrcodegen'; +import { QRProps, DEFAULT_PROPS, MARGIN_SIZE, ERROR_LEVEL_MAP, generatePath, excavateModules, getImageSettings } from './helpers'; + + +// 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 default function QRCodeCanvas(props: QRProps) { + const _canvas = useRef(null); + const _image = useRef(null); + + function update() { + const {value, size, level, bgColor, fgColor, includeMargin} = props; + + if (_canvas.current != null) { + const canvas = _canvas.current; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + let cells = qrcodegen.QrCode.encodeText( + value, + ERROR_LEVEL_MAP[level] + ).getModules(); + + const margin = includeMargin ? MARGIN_SIZE : 0; + const numCells = cells.length + margin * 2; + const calculatedImageSettings = getImageSettings(props, cells); + + const image = _image.current; + const haveImageToRender = + 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 + ); + } + } + } + + useEffect(() => { + // Always update the canvas. It's cheap enough and we want to be correct + // with the current state. + update(); + }); + + const { + value, + size, + level, + bgColor, + fgColor, + style, + includeMargin, + imageSettings, + ...otherProps + } = props; + const canvasStyle = {height: size, width: size, ...style}; + let img = null; + let imgSrc = imageSettings?.src; + if (imgSrc != null) { + img = ( + { + update(); + }} + ref={_image} + /> + ); + } + return ( + <> + + {img} + + ); +} + +QRCodeCanvas.defaultProps = DEFAULT_PROPS; diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..64d61af --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,148 @@ +import qrcodegen from './third-party/qrcodegen'; +import type {CSSProperties} from 'react'; + +type Modules = ReturnType; +type Excavation = {x: number; y: number; w: number; h: number}; + +export 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, +}; + +export type QRProps = { + value: string; + size: number; + // Should be a real enum, but doesn't seem to be compatible with real code. + level: string; + bgColor: string; + fgColor: string; + style?: CSSProperties; + includeMargin: boolean; + imageSettings?: { + src: string; + height: number; + width: number; + excavate: boolean; + x?: number; + y?: number; + }; +}; + +export const DEFAULT_PROPS = { + size: 128, + level: 'L', + bgColor: '#FFFFFF', + fgColor: '#000000', + includeMargin: false, +}; + +export const MARGIN_SIZE = 4; + +// 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; + +export function generatePath(modules: Modules, margin: number = 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. +export 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; + }); + }); +} + +export function getImageSettings( + props: QRProps, + cells: Modules +): null | { + x: number; + y: number; + h: number; + w: number; + excavation: Excavation | null; +} { + const {imageSettings, size, includeMargin} = props; + if (imageSettings == null) { + return null; + } + const margin = includeMargin ? MARGIN_SIZE : 0; + 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) { + let floorX = Math.floor(x); + let floorY = Math.floor(y); + let ceilW = Math.ceil(w + x - floorX); + let ceilH = Math.ceil(h + y - floorY); + excavation = {x: floorX, y: floorY, w: ceilW, h: ceilH}; + } + + return {x, y, h, w, excavation}; +} diff --git a/src/index.tsx b/src/index.tsx index bb28962..3c01a19 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,356 +4,11 @@ * SPDX-License-Identifier: ISC */ -import React, {useRef, useEffect} from 'react'; -import type {CSSProperties} from 'react'; -import qrcodegen from './third-party/qrcodegen'; +import React from 'react'; +import { QRProps, DEFAULT_PROPS } from './helpers'; +import QRCodeCanvas from './canvas'; +import QRCodeSVG from './svg'; -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 QRProps = { - value: string; - size: number; - // Should be a real enum, but doesn't seem to be compatible with real code. - level: string; - bgColor: string; - fgColor: string; - style?: CSSProperties; - includeMargin: boolean; - imageSettings?: { - src: string; - height: number; - width: number; - excavate: boolean; - x?: number; - y?: number; - }; -}; - -const DEFAULT_PROPS = { - size: 128, - level: 'L', - bgColor: '#FFFFFF', - fgColor: '#000000', - includeMargin: false, -}; - -const MARGIN_SIZE = 4; - -// 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: number = 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( - props: QRProps, - cells: Modules -): null | { - x: number; - y: number; - h: number; - w: number; - excavation: Excavation | null; -} { - const {imageSettings, size, includeMargin} = props; - if (imageSettings == null) { - return null; - } - const margin = includeMargin ? MARGIN_SIZE : 0; - 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) { - let floorX = Math.floor(x); - let floorY = Math.floor(y); - let ceilW = Math.ceil(w + x - floorX); - let ceilH = Math.ceil(h + y - floorY); - excavation = {x: floorX, y: floorY, w: ceilW, h: ceilH}; - } - - return {x, y, h, w, excavation}; -} - -// 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; -})(); - -function QRCodeCanvas(props: QRProps) { - const _canvas = useRef(null); - const _image = useRef(null); - - function update() { - const {value, size, level, bgColor, fgColor, includeMargin} = props; - - if (_canvas.current != null) { - const canvas = _canvas.current; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - return; - } - - let cells = qrcodegen.QrCode.encodeText( - value, - ERROR_LEVEL_MAP[level] - ).getModules(); - - const margin = includeMargin ? MARGIN_SIZE : 0; - const numCells = cells.length + margin * 2; - const calculatedImageSettings = getImageSettings(props, cells); - - const image = _image.current; - const haveImageToRender = - 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 - ); - } - } - } - - useEffect(() => { - // Always update the canvas. It's cheap enough and we want to be correct - // with the current state. - update(); - }); - - const { - value, - size, - level, - bgColor, - fgColor, - style, - includeMargin, - imageSettings, - ...otherProps - } = props; - const canvasStyle = {height: size, width: size, ...style}; - let img = null; - let imgSrc = imageSettings?.src; - if (imgSrc != null) { - img = ( - { - update(); - }} - ref={_image} - /> - ); - } - return ( - <> - - {img} - - ); -} -QRCodeCanvas.defaultProps = DEFAULT_PROPS; - -function QRCodeSVG(props: QRProps) { - const { - value, - size, - level, - bgColor, - fgColor, - includeMargin, - imageSettings, - ...otherProps - } = props; - - let cells = qrcodegen.QrCode.encodeText( - value, - ERROR_LEVEL_MAP[level] - ).getModules(); - - const margin = includeMargin ? MARGIN_SIZE : 0; - const numCells = cells.length + margin * 2; - const calculatedImageSettings = getImageSettings(props, cells); - - let image = null; - if (imageSettings != null && calculatedImageSettings != null) { - if (calculatedImageSettings.excavation != null) { - cells = excavateModules(cells, calculatedImageSettings.excavation); - } - - image = ( - - ); - } - - // Drawing strategy: instead of a rect per module, we're going to create a - // single path for the dark modules and layer that on top of a light rect, - // for a total of 2 DOM nodes. We pay a bit more in string concat but that's - // way faster than DOM ops. - // For level 1, 441 nodes -> 2 - // For level 40, 31329 -> 2 - const fgPath = generatePath(cells, margin); - - return ( - - - - {image} - - ); -} -QRCodeSVG.defaultProps = DEFAULT_PROPS; type RenderAs = 'svg' | 'canvas'; type RootProps = QRProps & {renderAs: string}; diff --git a/src/svg.js b/src/svg.js new file mode 100644 index 0000000..c80dcea --- /dev/null +++ b/src/svg.js @@ -0,0 +1,66 @@ +import React from 'react'; +import qrcodegen from './third-party/qrcodegen'; +import { QRProps, DEFAULT_PROPS, MARGIN_SIZE, ERROR_LEVEL_MAP, generatePath, excavateModules, getImageSettings } from './helpers'; + +export default function QRCodeSVG(props: QRProps) { + const { + value, + size, + level, + bgColor, + fgColor, + includeMargin, + imageSettings, + ...otherProps + } = props; + + let cells = qrcodegen.QrCode.encodeText( + value, + ERROR_LEVEL_MAP[level] + ).getModules(); + + const margin = includeMargin ? MARGIN_SIZE : 0; + const numCells = cells.length + margin * 2; + const calculatedImageSettings = getImageSettings(props, cells); + + let image = null; + if (imageSettings != null && calculatedImageSettings != null) { + if (calculatedImageSettings.excavation != null) { + cells = excavateModules(cells, calculatedImageSettings.excavation); + } + + image = ( + + ); + } + + // Drawing strategy: instead of a rect per module, we're going to create a + // single path for the dark modules and layer that on top of a light rect, + // for a total of 2 DOM nodes. We pay a bit more in string concat but that's + // way faster than DOM ops. + // For level 1, 441 nodes -> 2 + // For level 40, 31329 -> 2 + const fgPath = generatePath(cells, margin); + + return ( + + + + {image} + + ); +} + +QRCodeSVG.defaultProps = DEFAULT_PROPS;