All files / qr-scanner/src/image source.ts

97.72% Statements 43/44
92.85% Branches 39/42
100% Functions 7/7
97.61% Lines 41/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173                                    5x     24x 22x 22x 22x     2x 1x           1x 1x 1x 1x       1x           1x                             31x 2x           29x 24x 24x 24x     28x 28x 28x 28x         91x       58x                                   24x 14x   10x 2x   8x 8x                   58x   31x 7x     24x 24x                     7x 7x 7x 7x       7x                                                  
import { QRUnsupportedError } from "../errors";
import type { ImageSource } from "../types";
 
/**
 * Normalizing every accepted input into `ImageData`.
 *
 * This is the only file in the decode path that touches the DOM, which is what
 * keeps everything below it usable in a worker, in a test, and on a server.
 * The rest of the pipeline never learns whether its pixels came from a camera
 * frame, a dropped screenshot or a hand-built array.
 */
 
/** A canvas that can be reused across frames rather than reallocated. */
interface ScratchCanvas {
  readonly canvas: HTMLCanvasElement | OffscreenCanvas;
  readonly context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}
 
let scratch: ScratchCanvas | null = null;
 
function createCanvas(width: number, height: number): ScratchCanvas {
  if (typeof OffscreenCanvas !== "undefined") {
    const canvas = new OffscreenCanvas(width, height);
    const context = canvas.getContext("2d", { willReadFrequently: true });
    Eif (context) return { canvas, context };
  }
 
  if (typeof document === "undefined") {
    throw new QRUnsupportedError(
      "Reading pixels needs a canvas: pass an ImageData directly, or run where " +
        "OffscreenCanvas or the DOM is available.",
    );
  }
 
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext("2d", { willReadFrequently: true });
  /* c8 ignore next 6 -- a 2D context is unavailable only when canvas support is
     disabled outright; jsdom without a canvas package hits the OffscreenCanvas
     branch above or throws there first. */
  Iif (!context) {
    throw new QRUnsupportedError(
      "This browser refused a 2D canvas context, so pixels cannot be read from " +
        "the supplied source.",
    );
  }
  return { canvas, context };
}
 
/**
 * Draw a drawable source and read its pixels back.
 *
 * The scratch canvas is module-level and reused: at 12 frames a second, a new
 * canvas per frame is 12 GPU allocations a second, and browsers punish that
 * with visible stutter.
 */
function readPixels(
  source: CanvasImageSource,
  width: number,
  height: number,
): ImageData {
  if (width < 1 || height < 1) {
    throw new QRUnsupportedError(
      "The image source has no pixels yet — a video that has not loaded a frame, " +
        "or an image that has not decoded.",
    );
  }
 
  if (!scratch || scratch.canvas.width !== width || scratch.canvas.height !== height) {
    scratch = createCanvas(width, height);
    scratch.canvas.width = width;
    scratch.canvas.height = height;
  }
 
  const { context } = scratch;
  context.clearRect(0, 0, width, height);
  context.drawImage(source as CanvasImageSource, 0, 0, width, height);
  return context.getImageData(0, 0, width, height);
}
 
/** Release the shared scratch canvas — for tests, and for a scanner shutting down. */
export function releaseScratchCanvas(): void {
  scratch = null;
}
 
function isImageData(source: ImageSource): source is ImageData {
  return (
    typeof source === "object" &&
    source !== null &&
    "data" in source &&
    "width" in source &&
    "height" in source
  );
}
 
/**
 * The pixel dimensions of a source, preferring its *intrinsic* size.
 *
 * A `<video>` element's CSS size has nothing to do with the frame it is
 * showing, and reading pixels at the CSS size would silently downscale (or
 * upscale) every frame — a `videoWidth` of 1280 rendered into a 320-wide
 * canvas throws away exactly the detail a scanner needs.
 */
function dimensionsOf(source: ImageSource): { width: number; height: number } {
  if (typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement) {
    return { width: source.videoWidth, height: source.videoHeight };
  }
  if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) {
    return { width: source.naturalWidth || source.width, height: source.naturalHeight || source.height };
  }
  const sized = source as { width: number; height: number };
  return { width: sized.width, height: sized.height };
}
 
/**
 * Turn any accepted source into `ImageData`.
 *
 * @throws {QRUnsupportedError} when the environment cannot read pixels from the
 *   source, or the source has no pixels yet.
 */
export async function toImageData(source: ImageSource): Promise<ImageData> {
  if (isImageData(source)) return source;
 
  if (typeof Blob !== "undefined" && source instanceof Blob) {
    return fromBlob(source);
  }
 
  const { width, height } = dimensionsOf(source);
  return readPixels(source as CanvasImageSource, width, height);
}
 
/**
 * Decode a blob (a `File` from an `<input>`, a paste, or a drop) into pixels.
 *
 * `createImageBitmap` is preferred — it decodes off the main thread and works
 * in a worker. The `<img>` fallback covers Safari versions that reject blobs
 * there, and is the reason this function is async at all.
 */
async function fromBlob(blob: Blob): Promise<ImageData> {
  Eif (typeof createImageBitmap === "function") {
    const bitmap = await createImageBitmap(blob);
    try {
      return readPixels(bitmap, bitmap.width, bitmap.height);
    } finally {
      // Bitmaps hold GPU memory until closed; a scanner reading a burst of
      // dropped files would otherwise pile up several megabytes each.
      bitmap.close();
    }
  }
 
  /* c8 ignore start -- the `<img>` fallback needs a real image decoder, which
     jsdom does not have; it is exercised in the browser QA pass instead. */
  if (typeof document === "undefined" || typeof URL === "undefined") {
    throw new QRUnsupportedError("This environment cannot decode an image blob.");
  }
 
  const url = URL.createObjectURL(blob);
  try {
    const image = await new Promise<HTMLImageElement>((resolve, reject) => {
      const element = new Image();
      element.onload = () => resolve(element);
      element.onerror = () =>
        reject(new QRUnsupportedError("The supplied file is not an image this browser can decode."));
      element.src = url;
    });
    return readPixels(image, image.naturalWidth, image.naturalHeight);
  } finally {
    URL.revokeObjectURL(url);
  }
  /* c8 ignore stop */
}