/** * @module * HTML5 QR code & barcode scanning library. * - Decode QR Code. * - Decode different kinds of barcodes. * - Decode using web cam, smart phone camera or using images on local file * system. * * @author mebjas * * The word "QR Code" is registered trademark of DENSO WAVE INCORPORATED * http://www.denso-wave.com/qrcode/faqpatent-e.html */ import { QrcodeErrorCallback, QrcodeSuccessCallback, Logger, BaseLoggger, Html5QrcodeResultFactory, Html5QrcodeErrorFactory, Html5QrcodeSupportedFormats, RobustQrcodeDecoderAsync, isValidHtml5QrcodeSupportedFormats, Html5QrcodeConstants, Html5QrcodeResult, isNullOrUndefined, QrDimensions, QrDimensionFunction } from "./core"; import { Html5QrcodeStrings } from "./strings"; import { VideoConstraintsUtil } from "./utils"; import { Html5QrcodeShim } from "./code-decoder"; import { CameraFactory } from "./camera/factories"; import { CameraDevice, CameraCapabilities, CameraRenderingOptions, RenderedCamera, RenderingCallbacks } from "./camera/core"; import { CameraRetriever } from "./camera/retriever"; import { ExperimentalFeaturesConfig } from "./experimental-features"; import { StateManagerProxy, StateManagerFactory, StateManagerTransaction, Html5QrcodeScannerState } from "./state-manager"; class Constants extends Html5QrcodeConstants { //#region static constants static DEFAULT_WIDTH = 300; static DEFAULT_WIDTH_OFFSET = 2; static FILE_SCAN_MIN_HEIGHT = 300; static FILE_SCAN_HIDDEN_CANVAS_PADDING = 100; static MIN_QR_BOX_SIZE = 50; static SHADED_LEFT = 1; static SHADED_RIGHT = 2; static SHADED_TOP = 3; static SHADED_BOTTOM = 4; static SHADED_REGION_ELEMENT_ID = "qr-shaded-region"; static VERBOSE = false; static BORDER_SHADER_DEFAULT_COLOR = "#ffffff"; static BORDER_SHADER_MATCH_COLOR = "rgb(90, 193, 56)"; //#endregion } /** * Interface for configuring {@link Html5Qrcode} class instance. */ export interface Html5QrcodeConfigs { /** * Array of formats to support of type {@link Html5QrcodeSupportedFormats}. * * All invalid values would be ignored. If null or underfined all supported * formats will be used for scanning. Unless you want to limit the scan to * only certain formats or want to improve performance, you should not set * this value. */ formatsToSupport?: Array | undefined; /** * {@link BarcodeDetector} is being implemented by browsers at the moment. * It has very limited browser support but as it gets available it could * enable faster native code scanning experience. * * Set this flag to true, to enable using {@link BarcodeDetector} if * supported. This is true by default. * * Documentations: * - https://developer.mozilla.org/en-US/docs/Web/API/BarcodeDetector * - https://web.dev/shape-detection/#barcodedetector */ useBarCodeDetectorIfSupported?: boolean | undefined; /** * Config for experimental features. * * Everything is false by default. */ experimentalFeatures?: ExperimentalFeaturesConfig | undefined; } /** * Interface for full configuration of {@link Html5Qrcode}. * * Notes: Ideally we don't need to have two interfaces for this purpose, but * since the public APIs before version 2.0.8 allowed passing a boolean verbose * flag to constructor we need to allow users to pass Html5QrcodeFullConfig or * boolean flag to be backward compatible. * In future versions these two interfaces can be merged. */ export interface Html5QrcodeFullConfig extends Html5QrcodeConfigs { /** * If true, all logs would be printed to console. False by default. */ verbose: boolean | undefined; } /** * Configuration type for scanning QR code with camera. */ export interface Html5QrcodeCameraScanConfig { /** * Optional, Expected framerate of qr code scanning. example `{ fps: 2 }` means the * scanning would be done every `500 ms`. */ fps: number | undefined; /** * Optional, edge size, dimension or calculator function for QR scanning * box, the value or computed value should be smaller than the width and * height of the full region. * * This would make the scanner look like this: * ---------------------- * |********************| * |******,,,,,,,,,*****| <--- shaded region * |******| |*****| <--- non shaded region would be * |******| |*****| used for QR code scanning. * |******|_______|*****| * |********************| * |********************| * ---------------------- * * Instance of {@link QrDimensions} can be passed to construct a non * square rendering of scanner box. You can also pass in a function of type * {@link QrDimensionFunction} that takes in the width and height of the * video stream and return QR box size of type {@link QrDimensions}. * * If this value is not set, no shaded QR box will be rendered and the * scanner will scan the entire area of video stream. */ qrbox?: number | QrDimensions | QrDimensionFunction | undefined; /** * Optional, Desired aspect ratio for the video feed. Ideal aspect ratios * are 4:3 or 16:9. Passing very wrong aspect ratio could lead to video feed * not showing up. */ aspectRatio?: number | undefined; /** * Optional, if `true` flipped QR Code won't be scanned. Only use this * if you are sure the camera cannot give mirrored feed if you are facing * performance constraints. */ disableFlip?: boolean | undefined; /** * Optional, @beta(this config is not well supported yet). * * Important: When passed this will override other parameters like * 'cameraIdOrConfig' or configurations like 'aspectRatio'. * 'videoConstraints' should be of type {@link MediaTrackConstraints} as * defined in * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints * and is used to specify a variety of video or camera controls like: * aspectRatio, facingMode, frameRate, etc. */ videoConstraints?: MediaTrackConstraints | undefined; } /** * Internal implementation of {@link Html5QrcodeConfig} with util & factory * methods. * * @hidden */ class InternalHtml5QrcodeConfig implements Html5QrcodeCameraScanConfig { public readonly fps: number; public readonly disableFlip: boolean; public readonly qrbox: number | QrDimensions | QrDimensionFunction | undefined; public readonly aspectRatio: number | undefined; public readonly videoConstraints: MediaTrackConstraints | undefined; private logger: Logger; private constructor( config: Html5QrcodeCameraScanConfig | undefined, logger: Logger) { this.logger = logger; this.fps = Constants.SCAN_DEFAULT_FPS; if (!config) { this.disableFlip = Constants.DEFAULT_DISABLE_FLIP; } else { if (config.fps) { this.fps = config.fps; } this.disableFlip = config.disableFlip === true; this.qrbox = config.qrbox; this.aspectRatio = config.aspectRatio; this.videoConstraints = config.videoConstraints; } } public isMediaStreamConstraintsValid(): boolean { if (!this.videoConstraints) { this.logger.logError( "Empty videoConstraints", /* experimental= */ true); return false; } return VideoConstraintsUtil.isMediaStreamConstraintsValid( this.videoConstraints, this.logger); } public isShadedBoxEnabled(): boolean { return !isNullOrUndefined(this.qrbox); } /** * Create instance of {@link Html5QrcodeCameraScanConfig}. * * Create configuration by merging default and input settings. */ static create(config: Html5QrcodeCameraScanConfig | undefined, logger: Logger) : InternalHtml5QrcodeConfig { return new InternalHtml5QrcodeConfig(config, logger); } } /** @hidden */ interface QrcodeRegionBounds { x: number, y: number, width: number, height: number } /** * Low level APIs for building web based QR and Barcode Scanner. * * Supports APIs for camera as well as file based scanning. * * Depending of the configuration, the class will help render code * scanning UI on the provided parent HTML container. */ export class Html5Qrcode { //#region Private fields. private readonly logger: Logger; private readonly elementId: string; private readonly verbose: boolean; private readonly qrcode: RobustQrcodeDecoderAsync; private shouldScan: boolean; // Nullable elements // TODO(mebjas): Reduce the state-fulness of this mammoth class, by splitting // into independent classes for better separation of concerns and reducing // error prone nature of a large stateful class. private element: HTMLElement | null = null; private canvasElement: HTMLCanvasElement | null = null; private scannerPausedUiElement: HTMLDivElement | null = null; private hasBorderShaders: boolean | null = null; private borderShaders: Array | null = null; private qrMatch: boolean | null = null; private renderedCamera: RenderedCamera | null = null; private foreverScanTimeout: any; private qrRegion: QrcodeRegionBounds | null = null; private context: CanvasRenderingContext2D | null = null; private lastScanImageFile: string | null = null; //#endregion private stateManagerProxy: StateManagerProxy; // TODO(mebjas): deprecate this. /** @hidden */ public isScanning: boolean = false; /** * Initialize the code scanner. * * @param elementId Id of the HTML element. * @param configOrVerbosityFlag optional, config object of type {@link * Html5QrcodeFullConfig} or a boolean verbosity flag (to maintain backward * compatibility). If nothing is passed, default values would be used. * If a boolean value is used, it'll be used to set verbosity. Pass a * config value to configure the Html5Qrcode scanner as per needs. * * Use of `configOrVerbosityFlag` as a boolean value is being * deprecated since version 2.0.7. * * TODO(mebjas): Deprecate the verbosity boolean flag completely. */ public constructor(elementId: string, configOrVerbosityFlag?: boolean | Html5QrcodeFullConfig | undefined) { if (!document.getElementById(elementId)) { throw `HTML Element with id=${elementId} not found`; } this.elementId = elementId; this.verbose = false; let experimentalFeatureConfig : ExperimentalFeaturesConfig | undefined; let configObject: Html5QrcodeFullConfig | undefined; if (typeof configOrVerbosityFlag == "boolean") { this.verbose = configOrVerbosityFlag === true; } else if (configOrVerbosityFlag) { configObject = configOrVerbosityFlag; this.verbose = configObject.verbose === true; experimentalFeatureConfig = configObject.experimentalFeatures; } this.logger = new BaseLoggger(this.verbose); this.qrcode = new Html5QrcodeShim( this.getSupportedFormats(configOrVerbosityFlag), this.getUseBarCodeDetectorIfSupported(configObject), this.verbose, this.logger); this.foreverScanTimeout; this.shouldScan = true; this.stateManagerProxy = StateManagerFactory.create(); } //#region start() /** * Start scanning QR codes or bar codes for a given camera. * * @param cameraIdOrConfig Identifier of the camera, it can either be the * camera id retrieved from {@link Html5Qrcode#getCameras()} method or * object with facing mode constraint. * @param configuration Extra configurations to tune the code scanner. * @param qrCodeSuccessCallback Callback called when an instance of a QR * code or any other supported bar code is found. * @param qrCodeErrorCallback Callback called in cases where no instance of * QR code or any other supported bar code is found. * * @returns Promise for starting the scan. The Promise can fail if the user * doesn't grant permission or some API is not supported by the browser. */ public start( cameraIdOrConfig: string | MediaTrackConstraints, configuration: Html5QrcodeCameraScanConfig | undefined, qrCodeSuccessCallback: QrcodeSuccessCallback | undefined, qrCodeErrorCallback: QrcodeErrorCallback | undefined, ): Promise { // Code will be consumed as javascript. if (!cameraIdOrConfig) { throw "cameraIdOrConfig is required"; } if (!qrCodeSuccessCallback || typeof qrCodeSuccessCallback != "function") { throw "qrCodeSuccessCallback is required and should be a function."; } let qrCodeErrorCallbackInternal: QrcodeErrorCallback; if (qrCodeErrorCallback) { qrCodeErrorCallbackInternal = qrCodeErrorCallback; } else { qrCodeErrorCallbackInternal = this.verbose ? this.logger.log : () => {}; } const internalConfig = InternalHtml5QrcodeConfig.create( configuration, this.logger); this.clearElement(); // Check if videoConstraints is passed and valid let videoConstraintsAvailableAndValid = false; if (internalConfig.videoConstraints) { if (!internalConfig.isMediaStreamConstraintsValid()) { this.logger.logError( "'videoConstraints' is not valid 'MediaStreamConstraints, " + "it will be ignored.'", /* experimental= */ true); } else { videoConstraintsAvailableAndValid = true; } } const areVideoConstraintsEnabled = videoConstraintsAvailableAndValid; // qr shaded box const element = document.getElementById(this.elementId)!; const rootElementWidth = element.clientWidth ? element.clientWidth : Constants.DEFAULT_WIDTH; element.style.position = "relative"; this.shouldScan = true; this.element = element; const $this = this; const toScanningStateChangeTransaction: StateManagerTransaction = this.stateManagerProxy.startTransition( Html5QrcodeScannerState.SCANNING); return new Promise((resolve, reject) => { const videoConstraints = areVideoConstraintsEnabled ? internalConfig.videoConstraints : $this.createVideoConstraints(cameraIdOrConfig); if (!videoConstraints) { toScanningStateChangeTransaction.cancel(); reject("videoConstraints should be defined"); return; } let cameraRenderingOptions: CameraRenderingOptions = {}; if (!areVideoConstraintsEnabled || internalConfig.aspectRatio) { cameraRenderingOptions.aspectRatio = internalConfig.aspectRatio; } let renderingCallbacks: RenderingCallbacks = { onRenderSurfaceReady: (viewfinderWidth, viewfinderHeight) => { $this.setupUi( viewfinderWidth, viewfinderHeight, internalConfig); $this.isScanning = true; $this.foreverScan( internalConfig, qrCodeSuccessCallback, qrCodeErrorCallbackInternal!); } }; // TODO(minhazav): Flatten this flow. CameraFactory.failIfNotSupported().then((factory) => { factory.create(videoConstraints).then((camera) => { return camera.render( this.element!, cameraRenderingOptions, renderingCallbacks) .then((renderedCamera) => { $this.renderedCamera = renderedCamera; toScanningStateChangeTransaction.execute(); resolve(/* Void */ null); }) .catch((error) => { toScanningStateChangeTransaction.cancel(); reject(error); }); }).catch((error) => { toScanningStateChangeTransaction.cancel(); reject(Html5QrcodeStrings.errorGettingUserMedia(error)); }); }).catch((_) => { toScanningStateChangeTransaction.cancel(); reject(Html5QrcodeStrings.cameraStreamingNotSupported()); }); }); } //#endregion //#region Other state related public APIs /** * Pauses the ongoing scan. * * @param shouldPauseVideo (Optional, default = false) If true the * video will be paused. * * @throws error if method is called when scanner is not in scanning state. */ public pause(shouldPauseVideo?: boolean) { if (!this.stateManagerProxy.isStrictlyScanning()) { throw "Cannot pause, scanner is not scanning."; } this.stateManagerProxy.directTransition(Html5QrcodeScannerState.PAUSED); this.showPausedState(); if (isNullOrUndefined(shouldPauseVideo) || shouldPauseVideo !== true) { shouldPauseVideo = false; } if (shouldPauseVideo && this.renderedCamera) { this.renderedCamera.pause(); } } /** * Resumes the paused scan. * * If the video was previously paused by setting `shouldPauseVideo`` * to `true` in {@link Html5Qrcode#pause(shouldPauseVideo)}, calling * this method will resume the video. * * Note: with this caller will start getting results in success and error * callbacks. * * @throws error if method is called when scanner is not in paused state. */ public resume() { if (!this.stateManagerProxy.isPaused()) { throw "Cannot result, scanner is not paused."; } if (!this.renderedCamera) { throw "renderedCamera doesn't exist while trying resume()"; } const $this = this; const transitionToScanning = () => { $this.stateManagerProxy.directTransition( Html5QrcodeScannerState.SCANNING); $this.hidePausedState(); } if (!this.renderedCamera.isPaused()) { transitionToScanning(); return; } this.renderedCamera.resume(() => { // Transition state, when the video playback has resumed. transitionToScanning(); }); } /** * Gets state of the camera scan. * * @returns state of type {@link ScannerState}. */ public getState(): Html5QrcodeScannerState { return this.stateManagerProxy.getState(); } /** * Stops streaming QR Code video and scanning. * * @returns Promise for safely closing the video stream. */ public stop(): Promise { if (!this.stateManagerProxy.isScanning()) { throw "Cannot stop, scanner is not running or paused."; } const toStoppedStateTransaction: StateManagerTransaction = this.stateManagerProxy.startTransition( Html5QrcodeScannerState.NOT_STARTED); this.shouldScan = false; if (this.foreverScanTimeout) { clearTimeout(this.foreverScanTimeout); } // Removes the shaded region if exists. const removeQrRegion = () => { if (!this.element) { return; } let childElement = document.getElementById(Constants.SHADED_REGION_ELEMENT_ID); if (childElement) { this.element.removeChild(childElement); } }; let $this = this; return this.renderedCamera!.close().then(() => { $this.renderedCamera = null; if ($this.element) { $this.element.removeChild($this.canvasElement!); $this.canvasElement = null; } removeQrRegion(); if ($this.qrRegion) { $this.qrRegion = null; } if ($this.context) { $this.context = null; } toStoppedStateTransaction.execute(); $this.hidePausedState(); $this.isScanning = false; return Promise.resolve(); }); } //#endregion //#region File scan related public APIs /** * Scans an Image File for QR Code. * * This feature is mutually exclusive to camera-based scanning, you should * call stop() if the camera-based scanning was ongoing. * * @param imageFile a local file with Image content. * @param showImage if true the Image will be rendered on given * element. * * @returns Promise with decoded QR code string on success and error message * on failure. Failure could happen due to different reasons: * 1. QR Code decode failed because enough patterns not found in image. * 2. Input file was not image or unable to load the image or other image * load errors. */ public scanFile( imageFile: File, /* default=true */ showImage?: boolean): Promise { return this.scanFileV2(imageFile, showImage) .then((html5qrcodeResult) => html5qrcodeResult.decodedText); } /** * Scans an Image File for QR Code & returns {@link Html5QrcodeResult}. * * This feature is mutually exclusive to camera-based scanning, you should * call stop() if the camera-based scanning was ongoing. * * @param imageFile a local file with Image content. * @param showImage if true the Image will be rendered on given * element. * * @returns Promise which resolves with result of type * {@link Html5QrcodeResult}. * * @beta This is a WIP method, it's available as a public method but not * documented. * TODO(mebjas): Replace scanFile with ScanFileV2 */ public scanFileV2(imageFile: File, /* default=true */ showImage?: boolean) : Promise { if (!imageFile || !(imageFile instanceof File)) { throw "imageFile argument is mandatory and should be instance " + "of File. Use 'event.target.files[0]'."; } if (isNullOrUndefined(showImage)) { showImage = true; } if (!this.stateManagerProxy.canScanFile()) { throw "Cannot start file scan - ongoing camera scan"; } return new Promise((resolve, reject) => { this.possiblyCloseLastScanImageFile(); this.clearElement(); this.lastScanImageFile = URL.createObjectURL(imageFile); const inputImage = new Image; inputImage.onload = () => { const imageWidth = inputImage.width; const imageHeight = inputImage.height; const element = document.getElementById(this.elementId)!; const containerWidth = element.clientWidth ? element.clientWidth : Constants.DEFAULT_WIDTH; // No default height anymore. const containerHeight = Math.max( element.clientHeight ? element.clientHeight : imageHeight, Constants.FILE_SCAN_MIN_HEIGHT); const config = this.computeCanvasDrawConfig( imageWidth, imageHeight, containerWidth, containerHeight); if (showImage) { const visibleCanvas = this.createCanvasElement( containerWidth, containerHeight, "qr-canvas-visible"); visibleCanvas.style.display = "inline-block"; element.appendChild(visibleCanvas); const context = visibleCanvas.getContext("2d"); if (!context) { throw "Unable to get 2d context from canvas"; } context.canvas.width = containerWidth; context.canvas.height = containerHeight; // More reference // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage context.drawImage( inputImage, /* sx= */ 0, /* sy= */ 0, /* sWidth= */ imageWidth, /* sHeight= */ imageHeight, /* dx= */ config.x, /* dy= */ config.y, /* dWidth= */ config.width, /* dHeight= */ config.height); } // Hidden canvas should be at-least as big as the image. // This could get really troublesome for large images like 12MP // images or 48MP images captured on phone. let padding = Constants.FILE_SCAN_HIDDEN_CANVAS_PADDING; let hiddenImageWidth = Math.max(inputImage.width, config.width); let hiddenImageHeight = Math.max(inputImage.height, config.height); let hiddenCanvasWidth = hiddenImageWidth + 2 * padding; let hiddenCanvasHeight = hiddenImageHeight + 2 * padding; // Try harder for file scan. // TODO(minhazav): Fallback to mirroring, 90 degree rotation and // color inversion. const hiddenCanvas = this.createCanvasElement( hiddenCanvasWidth, hiddenCanvasHeight); element.appendChild(hiddenCanvas); const context = hiddenCanvas.getContext("2d"); if (!context) { throw "Unable to get 2d context from canvas"; } context.canvas.width = hiddenCanvasWidth; context.canvas.height = hiddenCanvasHeight; context.drawImage( inputImage, /* sx= */ 0, /* sy= */ 0, /* sWidth= */ imageWidth, /* sHeight= */ imageHeight, /* dx= */ padding, /* dy= */ padding, /* dWidth= */ hiddenImageWidth, /* dHeight= */ hiddenImageHeight); try { this.qrcode.decodeRobustlyAsync(hiddenCanvas) .then((result) => { resolve( Html5QrcodeResultFactory.createFromQrcodeResult( result)); }) .catch(reject); } catch (exception) { reject(`QR code parse error, error = ${exception}`); } }; inputImage.onerror = reject; inputImage.onabort = reject; inputImage.onstalled = reject; inputImage.onsuspend = reject; inputImage.src = URL.createObjectURL(imageFile); }); } //#endregion /** * Clears the existing canvas. * * Note: in case of ongoing web cam based scan, it needs to be explicitly * closed before calling this method, else it will throw exception. */ public clear(): void { this.clearElement(); } /** * Returns list of {@link CameraDevice} supported by the device. * * @returns array of camera devices on success. */ public static getCameras(): Promise> { return CameraRetriever.retrieve(); } /** * Returns the capabilities of the running video track. * * Read more: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getConstraints * * Important: * 1. Must be called only if the camera based scanning is in progress. * * @returns capabilities of the running camera. * @throws error if the scanning is not in running state. */ public getRunningTrackCapabilities(): MediaTrackCapabilities { return this.getRenderedCameraOrFail().getRunningTrackCapabilities(); } /** * Returns the object containing the current values of each constrainable * property of the running video track. * * Read more: https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack/getSettings * * Important: * 1. Must be called only if the camera based scanning is in progress. * * @returns settings of the running media track. * * @throws error if the scanning is not in running state. */ public getRunningTrackSettings(): MediaTrackSettings { return this.getRenderedCameraOrFail().getRunningTrackSettings(); } /** * Returns {@link CameraCapabilities} of the running video track. * * TODO(minhazav): Document this API, currently hidden. * * @returns capabilities of the running camera. * @throws error if the scanning is not in running state. */ public getRunningTrackCameraCapabilities(): CameraCapabilities { return this.getRenderedCameraOrFail().getCapabilities(); } /** * Apply a video constraints on running video track from camera. * * Important: * 1. Must be called only if the camera based scanning is in progress. * 2. Changing aspectRatio while scanner is running is not yet supported. * * @param {MediaTrackConstraints} specifies a variety of video or camera * controls as defined in * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints * @returns a Promise which succeeds if the passed constraints are applied, * fails otherwise. * @throws error if the scanning is not in running state. */ public applyVideoConstraints(videoConstaints: MediaTrackConstraints) : Promise { if (!videoConstaints) { throw "videoConstaints is required argument."; } else if (!VideoConstraintsUtil.isMediaStreamConstraintsValid( videoConstaints, this.logger)) { throw "invalid videoConstaints passed, check logs for more details"; } return this.getRenderedCameraOrFail().applyVideoConstraints( videoConstaints); } //#region Private methods. private getRenderedCameraOrFail() { if (this.renderedCamera == null) { throw "Scanning is not in running state, call this API only when" + " QR code scanning using camera is in running state."; } return this.renderedCamera!; } /** * Construct list of supported formats and returns based on input args. * `configOrVerbosityFlag` optional, config object of type {@link * Html5QrcodeFullConfig} or a boolean verbosity flag (to maintain backward * compatibility). If nothing is passed, default values would be used. * If a boolean value is used, it'll be used to set verbosity. Pass a * config value to configure the Html5Qrcode scanner as per needs. * * Use of `configOrVerbosityFlag` as a boolean value is being * deprecated since version 2.0.7. * * TODO(mebjas): Deprecate the verbosity boolean flag completely. */ private getSupportedFormats( configOrVerbosityFlag: boolean | Html5QrcodeFullConfig | undefined) : Array { const allFormats: Array = [ Html5QrcodeSupportedFormats.QR_CODE, Html5QrcodeSupportedFormats.AZTEC, Html5QrcodeSupportedFormats.CODABAR, Html5QrcodeSupportedFormats.CODE_39, Html5QrcodeSupportedFormats.CODE_93, Html5QrcodeSupportedFormats.CODE_128, Html5QrcodeSupportedFormats.DATA_MATRIX, Html5QrcodeSupportedFormats.MAXICODE, Html5QrcodeSupportedFormats.ITF, Html5QrcodeSupportedFormats.EAN_13, Html5QrcodeSupportedFormats.EAN_8, Html5QrcodeSupportedFormats.PDF_417, Html5QrcodeSupportedFormats.RSS_14, Html5QrcodeSupportedFormats.RSS_EXPANDED, Html5QrcodeSupportedFormats.UPC_A, Html5QrcodeSupportedFormats.UPC_E, Html5QrcodeSupportedFormats.UPC_EAN_EXTENSION, ]; if (!configOrVerbosityFlag || typeof configOrVerbosityFlag == "boolean") { return allFormats; } if (!configOrVerbosityFlag.formatsToSupport) { return allFormats; } if (!Array.isArray(configOrVerbosityFlag.formatsToSupport)) { throw "configOrVerbosityFlag.formatsToSupport should be undefined " + "or an array."; } if (configOrVerbosityFlag.formatsToSupport.length === 0) { throw "Atleast 1 formatsToSupport is needed."; } const supportedFormats: Array = []; for (const format of configOrVerbosityFlag.formatsToSupport) { if (isValidHtml5QrcodeSupportedFormats(format)) { supportedFormats.push(format); } else { this.logger.warn( `Invalid format: ${format} passed in config, ignoring.`); } } if (supportedFormats.length === 0) { throw "None of formatsToSupport match supported values."; } return supportedFormats; } /** * Returns `true` if `useBarCodeDetectorIfSupported` is * enabled in the config. */ /*eslint complexity: ["error", 10]*/ private getUseBarCodeDetectorIfSupported( config: Html5QrcodeConfigs | undefined) : boolean { // Default value is true. if (isNullOrUndefined(config)) { return true; } if (!isNullOrUndefined(config!.useBarCodeDetectorIfSupported)) { // Default value is false. return config!.useBarCodeDetectorIfSupported !== false; } if (isNullOrUndefined(config!.experimentalFeatures)) { return true; } let experimentalFeatures = config!.experimentalFeatures!; if (isNullOrUndefined( experimentalFeatures.useBarCodeDetectorIfSupported)) { return true; } return experimentalFeatures.useBarCodeDetectorIfSupported !== false; } /** * Validates if the passed config for qrbox is correct. */ private validateQrboxSize( viewfinderWidth: number, viewfinderHeight: number, internalConfig: InternalHtml5QrcodeConfig) { const qrboxSize = internalConfig.qrbox!; this.validateQrboxConfig(qrboxSize); let qrDimensions = this.toQrdimensions( viewfinderWidth, viewfinderHeight, qrboxSize); const validateMinSize = (size: number) => { if (size < Constants.MIN_QR_BOX_SIZE) { throw "minimum size of 'config.qrbox' dimension value is" + ` ${Constants.MIN_QR_BOX_SIZE}px.`; } }; /** * The 'config.qrbox.width' shall be overriden if it's larger than the * width of the root element. * * Based on the verbosity settings, this will be logged to the logger. * * @param configWidth the width of qrbox set by users in the config. */ const correctWidthBasedOnRootElementSize = (configWidth: number) => { if (configWidth > viewfinderWidth) { this.logger.warn("`qrbox.width` or `qrbox` is larger than the" + " width of the root element. The width will be truncated" + " to the width of root element."); configWidth = viewfinderWidth; } return configWidth; }; validateMinSize(qrDimensions.width); validateMinSize(qrDimensions.height); qrDimensions.width = correctWidthBasedOnRootElementSize( qrDimensions.width); // Note: In this case if the height of the qrboxSize turns out to be // greater than the height of the root element (which should later be // based on the aspect ratio of the camera stream), it would be silently // ignored with a warning. } /** * Validates if the `qrboxSize` is a valid value. * * It's expected to be either a number or of type {@link QrDimensions}. */ private validateQrboxConfig( qrboxSize: number | QrDimensions | QrDimensionFunction) { if (typeof qrboxSize === "number") { return; } if (typeof qrboxSize === "function") { // This is a valid format. return; } // Alternatively, the config is expected to be of type QrDimensions. if (qrboxSize.width === undefined || qrboxSize.height === undefined) { throw "Invalid instance of QrDimensions passed for " + "'config.qrbox'. Both 'width' and 'height' should be set."; } } /** * Possibly converts `qrboxSize` to an object of type * {@link QrDimensions}. */ private toQrdimensions( viewfinderWidth: number, viewfinderHeight: number, qrboxSize: number | QrDimensions | QrDimensionFunction): QrDimensions { if (typeof qrboxSize === "number") { return { width: qrboxSize, height: qrboxSize}; } else if (typeof qrboxSize === "function") { try { return qrboxSize(viewfinderWidth, viewfinderHeight); } catch (error) { throw new Error( "qrbox config was passed as a function but it failed with " + "unknown error" + error); } } return qrboxSize; } //#region Documented private methods for camera based scanner. /** * Setups the UI elements, changes the state of this class. * * @param viewfinderWidth derived width of viewfinder. * @param viewfinderHeight derived height of viewfinder. */ private setupUi( viewfinderWidth: number, viewfinderHeight: number, internalConfig: InternalHtml5QrcodeConfig): void { // Validate before insertion if (internalConfig.isShadedBoxEnabled()) { this.validateQrboxSize( viewfinderWidth, viewfinderHeight, internalConfig); } // If `qrbox` size is not set, it will default to the dimensions of the // viewfinder. const qrboxSize = isNullOrUndefined(internalConfig.qrbox) ? {width: viewfinderWidth, height: viewfinderHeight}: internalConfig.qrbox!; this.validateQrboxConfig(qrboxSize); let qrDimensions = this.toQrdimensions(viewfinderWidth, viewfinderHeight, qrboxSize); if (qrDimensions.height > viewfinderHeight) { this.logger.warn("[Html5Qrcode] config.qrbox has height that is" + "greater than the height of the video stream. Shading will be" + " ignored"); } const shouldShadingBeApplied = internalConfig.isShadedBoxEnabled() && qrDimensions.height <= viewfinderHeight; const defaultQrRegion: QrcodeRegionBounds = { x: 0, y: 0, width: viewfinderWidth, height: viewfinderHeight }; const qrRegion = shouldShadingBeApplied ? this.getShadedRegionBounds(viewfinderWidth, viewfinderHeight, qrDimensions) : defaultQrRegion; const canvasElement = this.createCanvasElement( qrRegion.width, qrRegion.height); // Tell user agent that this canvas will be read frequently. // More info: // https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently const contextAttributes: any = { willReadFrequently: true }; // Casting canvas to any, as Microsoft's interface definition hasn't // caught up with latest definition for 'CanvasRenderingContext2DSettings'. const context: CanvasRenderingContext2D = (canvasElement).getContext("2d", contextAttributes)!; context.canvas.width = qrRegion.width; context.canvas.height = qrRegion.height; // Insert the canvas this.element!.append(canvasElement); if (shouldShadingBeApplied) { this.possiblyInsertShadingElement( this.element!, viewfinderWidth, viewfinderHeight, qrDimensions); } this.createScannerPausedUiElement(this.element!); // Update local states this.qrRegion = qrRegion; this.context = context; this.canvasElement = canvasElement; } // TODO(mebjas): Convert this to a standard message viewer. private createScannerPausedUiElement(rootElement: HTMLElement) { const scannerPausedUiElement = document.createElement("div"); scannerPausedUiElement.innerText = Html5QrcodeStrings.scannerPaused(); scannerPausedUiElement.style.display = "none"; scannerPausedUiElement.style.position = "absolute"; scannerPausedUiElement.style.top = "0px"; scannerPausedUiElement.style.zIndex = "1"; scannerPausedUiElement.style.background = "rgba(9, 9, 9, 0.46)"; scannerPausedUiElement.style.color = "#FFECEC"; scannerPausedUiElement.style.textAlign = "center"; scannerPausedUiElement.style.width = "100%"; rootElement.appendChild(scannerPausedUiElement); this.scannerPausedUiElement = scannerPausedUiElement; } /** * Scans current context using the qrcode library. * *

This method call would result in callback being triggered by the * qrcode library. This method also handles the border coloring. * * @returns true if scan match is found, false otherwise. */ private scanContext( qrCodeSuccessCallback: QrcodeSuccessCallback, qrCodeErrorCallback: QrcodeErrorCallback ): Promise { if (this.stateManagerProxy.isPaused()) { return Promise.resolve(false); } return this.qrcode.decodeAsync(this.canvasElement!) .then((result) => { qrCodeSuccessCallback( result.text, Html5QrcodeResultFactory.createFromQrcodeResult( result)); this.possiblyUpdateShaders(/* qrMatch= */ true); return true; }).catch((error) => { this.possiblyUpdateShaders(/* qrMatch= */ false); let errorMessage = Html5QrcodeStrings.codeParseError(error); qrCodeErrorCallback( errorMessage, Html5QrcodeErrorFactory.createFrom(errorMessage)); return false; }); } /** * Forever scanning method. */ private foreverScan( internalConfig: InternalHtml5QrcodeConfig, qrCodeSuccessCallback: QrcodeSuccessCallback, qrCodeErrorCallback: QrcodeErrorCallback) { if (!this.shouldScan) { // Stop scanning. return; } if (!this.renderedCamera) { return; } // There is difference in size of rendered video and one that is // considered by the canvas. Need to account for scaling factor. const videoElement = this.renderedCamera!.getSurface(); const widthRatio = videoElement.videoWidth / videoElement.clientWidth; const heightRatio = videoElement.videoHeight / videoElement.clientHeight; if (!this.qrRegion) { throw "qrRegion undefined when localMediaStream is ready."; } const sWidthOffset = this.qrRegion.width * widthRatio; const sHeightOffset = this.qrRegion.height * heightRatio; const sxOffset = this.qrRegion.x * widthRatio; const syOffset = this.qrRegion.y * heightRatio; // Only decode the relevant area, ignore the shaded area, // More reference: // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage this.context!.drawImage( videoElement, /* sx= */ sxOffset, /* sy= */ syOffset, /* sWidth= */ sWidthOffset, /* sHeight= */ sHeightOffset, /* dx= */ 0, /* dy= */ 0, /* dWidth= */ this.qrRegion.width, /* dHeight= */ this.qrRegion.height); const triggerNextScan = () => { this.foreverScanTimeout = setTimeout(() => { this.foreverScan( internalConfig, qrCodeSuccessCallback, qrCodeErrorCallback); }, this.getTimeoutFps(internalConfig.fps)); }; // Try scanning normal frame and in case of failure, scan // the inverted context if not explictly disabled. // TODO(mebjas): Move this logic to decoding library. this.scanContext(qrCodeSuccessCallback, qrCodeErrorCallback) .then((isSuccessfull) => { // Previous scan failed and disableFlip is off. if (!isSuccessfull && internalConfig.disableFlip !== true) { this.context!.translate(this.context!.canvas.width, 0); this.context!.scale(-1, 1); this.scanContext(qrCodeSuccessCallback, qrCodeErrorCallback) .finally(() => { triggerNextScan(); }); } else { triggerNextScan(); } }).catch((error) => { this.logger.logError( "Error happend while scanning context", error); triggerNextScan(); }); } private createVideoConstraints( cameraIdOrConfig: string | MediaTrackConstraints) : MediaTrackConstraints | undefined { if (typeof cameraIdOrConfig == "string") { // If it's a string it should be camera device Id. return { deviceId: { exact: cameraIdOrConfig } }; } else if (typeof cameraIdOrConfig == "object") { const facingModeKey = "facingMode"; const deviceIdKey = "deviceId"; const allowedFacingModeValues = { "user" : true, "environment" : true}; const exactKey = "exact"; const isValidFacingModeValue = (value: string) => { if (value in allowedFacingModeValues) { // Valid config return true; } else { // Invalid config throw "config has invalid 'facingMode' value = " + `'${value}'`; } }; const keys = Object.keys(cameraIdOrConfig); if (keys.length !== 1) { throw "'cameraIdOrConfig' object should have exactly 1 key," + ` if passed as an object, found ${keys.length} keys`; } const key:string = Object.keys(cameraIdOrConfig)[0]; if (key !== facingModeKey && key !== deviceIdKey) { throw `Only '${facingModeKey}' and '${deviceIdKey}' ` + " are supported for 'cameraIdOrConfig'"; } if (key === facingModeKey) { /** * Supported scenarios: * - { facingMode: "user" } * - { facingMode: "environment" } * - { facingMode: { exact: "environment" } } * - { facingMode: { exact: "user" } } */ const facingMode: any = cameraIdOrConfig.facingMode; if (typeof facingMode == "string") { if (isValidFacingModeValue(facingMode)) { return { facingMode: facingMode }; } } else if (typeof facingMode == "object") { if (exactKey in facingMode) { if (isValidFacingModeValue(facingMode[`${exactKey}`])) { return { facingMode: { exact: facingMode[`${exactKey}`] } }; } } else { throw "'facingMode' should be string or object with" + ` ${exactKey} as key.`; } } else { const type = (typeof facingMode); throw `Invalid type of 'facingMode' = ${type}`; } } else { /** * key == deviceIdKey; Supported scenarios: * - { deviceId: { exact: "a76afe74e95e3.....38627b3bde" } * - { deviceId: "a76afe74e95e3....065c9cd89438627b3bde" } */ const deviceId: any = cameraIdOrConfig.deviceId; if (typeof deviceId == "string") { return { deviceId: deviceId }; } else if (typeof deviceId == "object") { if (exactKey in deviceId) { return { deviceId : { exact: deviceId[`${exactKey}`] } }; } else { throw "'deviceId' should be string or object with" + ` ${exactKey} as key.`; } } else { const type = (typeof deviceId); throw `Invalid type of 'deviceId' = ${type}`; } } } // invalid type const type = (typeof cameraIdOrConfig); throw `Invalid type of 'cameraIdOrConfig' = ${type}`; } //#endregion //#region Documented private methods for file based scanner. private computeCanvasDrawConfig( imageWidth: number, imageHeight: number, containerWidth: number, containerHeight: number): QrcodeRegionBounds { if (imageWidth <= containerWidth && imageHeight <= containerHeight) { // no downsampling needed. const xoffset = (containerWidth - imageWidth) / 2; const yoffset = (containerHeight - imageHeight) / 2; return { x: xoffset, y: yoffset, width: imageWidth, height: imageHeight }; } else { const formerImageWidth = imageWidth; const formerImageHeight = imageHeight; if (imageWidth > containerWidth) { imageHeight = (containerWidth / imageWidth) * imageHeight; imageWidth = containerWidth; } if (imageHeight > containerHeight) { imageWidth = (containerHeight / imageHeight) * imageWidth; imageHeight = containerHeight; } this.logger.log( "Image downsampled from " + `${formerImageWidth}X${formerImageHeight}` + ` to ${imageWidth}X${imageHeight}.`); return this.computeCanvasDrawConfig( imageWidth, imageHeight, containerWidth, containerHeight); } } //#endregion private clearElement(): void { if (this.stateManagerProxy.isScanning()) { throw "Cannot clear while scan is ongoing, close it first."; } const element = document.getElementById(this.elementId); if (element) { element.innerHTML = ""; } } private possiblyUpdateShaders(qrMatch: boolean) { if (this.qrMatch === qrMatch) { return; } if (this.hasBorderShaders && this.borderShaders && this.borderShaders.length) { this.borderShaders.forEach((shader) => { shader.style.backgroundColor = qrMatch ? Constants.BORDER_SHADER_MATCH_COLOR : Constants.BORDER_SHADER_DEFAULT_COLOR; }); } this.qrMatch = qrMatch; } private possiblyCloseLastScanImageFile() { if (this.lastScanImageFile) { URL.revokeObjectURL(this.lastScanImageFile); this.lastScanImageFile = null; } } private createCanvasElement( width: number, height: number, customId?: string): HTMLCanvasElement { const canvasWidth = width; const canvasHeight = height; const canvasElement = document.createElement("canvas"); canvasElement.style.width = `${canvasWidth}px`; canvasElement.style.height = `${canvasHeight}px`; canvasElement.style.display = "none"; canvasElement.id = isNullOrUndefined(customId) ? "qr-canvas" : customId!; return canvasElement; } private getShadedRegionBounds( width: number, height: number, qrboxSize: QrDimensions) : QrcodeRegionBounds { if (qrboxSize.width > width || qrboxSize.height > height) { throw "'config.qrbox' dimensions should not be greater than the " + "dimensions of the root HTML element."; } return { x: (width - qrboxSize.width) / 2, y: (height - qrboxSize.height) / 2, width: qrboxSize.width, height: qrboxSize.height }; } private possiblyInsertShadingElement( element: HTMLElement, width: number, height: number, qrboxSize: QrDimensions) { if ((width - qrboxSize.width) < 1 || (height - qrboxSize.height) < 1) { return; } const shadingElement = document.createElement("div"); shadingElement.style.position = "absolute"; const rightLeftBorderSize = (width - qrboxSize.width) / 2; const topBottomBorderSize = (height - qrboxSize.height) / 2; shadingElement.style.borderLeft = `${rightLeftBorderSize}px solid rgba(0, 0, 0, 0.48)`; shadingElement.style.borderRight = `${rightLeftBorderSize}px solid rgba(0, 0, 0, 0.48)`; shadingElement.style.borderTop = `${topBottomBorderSize}px solid rgba(0, 0, 0, 0.48)`; shadingElement.style.borderBottom = `${topBottomBorderSize}px solid rgba(0, 0, 0, 0.48)`; shadingElement.style.boxSizing = "border-box"; shadingElement.style.top = "0px"; shadingElement.style.bottom = "0px"; shadingElement.style.left = "0px"; shadingElement.style.right = "0px"; shadingElement.id = `${Constants.SHADED_REGION_ELEMENT_ID}`; // Check if div is too small for shadows. As there are two 5px width // borders the needs to have a size above 10px. if ((width - qrboxSize.width) < 11 || (height - qrboxSize.height) < 11) { this.hasBorderShaders = false; } else { const smallSize = 5; const largeSize = 40; this.insertShaderBorders( shadingElement, /* width= */ largeSize, /* height= */ smallSize, /* top= */ -smallSize, /* bottom= */ null, /* side= */ 0, /* isLeft= */ true); this.insertShaderBorders( shadingElement, /* width= */ largeSize, /* height= */ smallSize, /* top= */ -smallSize, /* bottom= */ null, /* side= */ 0, /* isLeft= */ false); this.insertShaderBorders( shadingElement, /* width= */ largeSize, /* height= */ smallSize, /* top= */ null, /* bottom= */ -smallSize, /* side= */ 0, /* isLeft= */ true); this.insertShaderBorders( shadingElement, /* width= */ largeSize, /* height= */ smallSize, /* top= */ null, /* bottom= */ -smallSize, /* side= */ 0, /* isLeft= */ false); this.insertShaderBorders( shadingElement, /* width= */ smallSize, /* height= */ largeSize + smallSize, /* top= */ -smallSize, /* bottom= */ null, /* side= */ -smallSize, /* isLeft= */ true); this.insertShaderBorders( shadingElement, /* width= */ smallSize, /* height= */ largeSize + smallSize, /* top= */ null, /* bottom= */ -smallSize, /* side= */ -smallSize, /* isLeft= */ true); this.insertShaderBorders( shadingElement, /* width= */ smallSize, /* height= */ largeSize + smallSize, /* top= */ -smallSize, /* bottom= */ null, /* side= */ -smallSize, /* isLeft= */ false); this.insertShaderBorders( shadingElement, /* width= */ smallSize, /* height= */ largeSize + smallSize, /* top= */ null, /* bottom= */ -smallSize, /* side= */ -smallSize, /* isLeft= */ false); this.hasBorderShaders = true; } element.append(shadingElement); } private insertShaderBorders( shaderElem: HTMLDivElement, width: number, height: number, top: number | null, bottom: number | null, side: number, isLeft: boolean) { const elem = document.createElement("div"); elem.style.position = "absolute"; elem.style.backgroundColor = Constants.BORDER_SHADER_DEFAULT_COLOR; elem.style.width = `${width}px`; elem.style.height = `${height}px`; if (top !== null) { elem.style.top = `${top}px`; } if (bottom !== null) { elem.style.bottom = `${bottom}px`; } if (isLeft) { elem.style.left = `${side}px`; } else { elem.style.right = `${side}px`; } if (!this.borderShaders) { this.borderShaders = []; } this.borderShaders.push(elem); shaderElem.appendChild(elem); } private showPausedState() { if (!this.scannerPausedUiElement) { throw "[internal error] scanner paused UI element not found"; } this.scannerPausedUiElement.style.display = "block"; } private hidePausedState() { if (!this.scannerPausedUiElement) { throw "[internal error] scanner paused UI element not found"; } this.scannerPausedUiElement.style.display = "none"; } private getTimeoutFps(fps: number) { return 1000 / fps; } //#endregion }