All files / qr-scanner/src utils.ts

100% Statements 46/46
100% Branches 20/20
100% Functions 9/9
100% Lines 40/40

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                                                                                16x 8x 2x     6x 6x   6x 14x 14x 2x           12x     4x 6x 9x   4x 4x 11x     4x 9x 4x 4x 4x 9x 9x     4x 9x                                                                                   18x   18x         17x 7x 7x     10x     10x 10x         17x 17x   17x 17x   17x         2x                   8x    
import type { Point, QRScanResult, Quad } from "./types";
 
/**
 * Small helpers that belong to a consumer's code more than to the decoder, but
 * are fiddly enough that everyone would write them slightly wrong.
 */
 
/* ─────────────────────── Structured Append ─────────────────────── */
 
export interface JoinedSequence {
  /** The concatenated text, in symbol order. */
  readonly text: string;
  readonly bytes: Uint8Array;
  /** How many symbols the sequence expects. */
  readonly total: number;
  /** Sequence positions still missing, in order. */
  readonly missing: number[];
  /** Every symbol present, ordered by position. */
  readonly parts: readonly QRScanResult[];
}
 
/**
 * Assemble a Structured Append sequence from the symbols scanned so far.
 *
 * The QR standard lets one payload span up to 16 symbols; a scanner meets them
 * one at a time, in whatever order the user happens to point the camera. This
 * sorts, de-duplicates and reports what is still missing, so a UI can say
 * "3 of 5 — keep scanning" instead of silently concatenating a partial result.
 *
 * @throws {RangeError} when the results do not belong to one sequence — a
 *   mismatched parity byte or total means two different multi-part payloads
 *   were mixed, and joining them would produce convincing nonsense.
 *
 * @example
 * ```ts
 * const sequence = joinStructuredAppend(scanned);
 * if (sequence.missing.length === 0) save(sequence.text);
 * ```
 */
export function joinStructuredAppend(results: readonly QRScanResult[]): JoinedSequence {
  const parts = results.filter((result) => result.symbol?.structuredAppend);
  if (parts.length === 0) {
    throw new RangeError("None of these results is part of a Structured Append sequence.");
  }
 
  const first = parts[0]!.symbol!.structuredAppend!;
  const byIndex = new Map<number, QRScanResult>();
 
  for (const part of parts) {
    const info = part.symbol!.structuredAppend!;
    if (info.parity !== first.parity || info.total !== first.total) {
      throw new RangeError(
        "These symbols belong to different Structured Append sequences " +
          `(parity ${info.parity} vs ${first.parity}, total ${info.total} vs ${first.total}).`,
      );
    }
    // A symbol scanned twice is normal — the camera sees it in many frames.
    byIndex.set(info.index, part);
  }
 
  const ordered = Array.from(byIndex.keys())
    .sort((a, b) => a - b)
    .map((index) => byIndex.get(index)!);
 
  const missing: number[] = [];
  for (let index = 0; index < first.total; index++) {
    if (!byIndex.has(index)) missing.push(index);
  }
 
  let length = 0;
  for (const part of ordered) length += part.bytes.length;
  const bytes = new Uint8Array(length);
  let offset = 0;
  for (const part of ordered) {
    bytes.set(part.bytes, offset);
    offset += part.bytes.length;
  }
 
  return {
    text: ordered.map((part) => part.text).join(""),
    bytes,
    total: first.total,
    missing,
    parts: ordered,
  };
}
 
/* ─────────────────────────── Geometry ─────────────────────────── */
 
/** The rendered box of a media element, and how its pixels map into it. */
export interface ElementGeometry {
  /** Intrinsic pixel width of the source (`videoWidth`, `naturalWidth`). */
  readonly sourceWidth: number;
  readonly sourceHeight: number;
  /** Size the element occupies on the page, in CSS pixels. */
  readonly elementWidth: number;
  readonly elementHeight: number;
  /** The element's `object-fit`. @default "cover" */
  readonly objectFit?: "cover" | "contain" | "fill";
  /** The element is displayed mirrored — the usual treatment of a front camera. */
  readonly mirrored?: boolean;
}
 
/**
 * Map a point from source-image pixels to the element's own CSS pixels.
 *
 * This is the step that decides whether an overlay box sits *on* the code or
 * merely near it. A `<video>` showing a 1280 × 720 stream inside a 360 × 640
 * portrait viewport under `object-fit: cover` is scaled **and** cropped on one
 * axis, and a front camera is usually mirrored on top of that. Ignoring any of
 * those puts the highlight in the wrong place, which reads as a broken scanner
 * even though the decode was perfect.
 */
export function mapPointToElement(point: Point, geometry: ElementGeometry): Point {
  const {
    sourceWidth,
    sourceHeight,
    elementWidth,
    elementHeight,
    objectFit = "cover",
    mirrored = false,
  } = geometry;
 
  if (sourceWidth <= 0 || sourceHeight <= 0) return { x: 0, y: 0 };
 
  let scaleX: number;
  let scaleY: number;
 
  if (objectFit === "fill") {
    scaleX = elementWidth / sourceWidth;
    scaleY = elementHeight / sourceHeight;
  } else {
    const scale =
      objectFit === "contain"
        ? Math.min(elementWidth / sourceWidth, elementHeight / sourceHeight)
        : Math.max(elementWidth / sourceWidth, elementHeight / sourceHeight);
    scaleX = scale;
    scaleY = scale;
  }
 
  // Whatever the fit leaves over is split evenly on both sides — cropped for
  // `cover` (negative offset), letterboxed for `contain` (positive).
  const offsetX = (elementWidth - sourceWidth * scaleX) / 2;
  const offsetY = (elementHeight - sourceHeight * scaleY) / 2;
 
  const x = point.x * scaleX + offsetX;
  const y = point.y * scaleY + offsetY;
 
  return { x: mirrored ? elementWidth - x : x, y };
}
 
/** Map all four corners of a result into element space (see {@link mapPointToElement}). */
export function mapCorners(result: QRScanResult, geometry: ElementGeometry): Quad {
  return [
    mapPointToElement(result.corners[0], geometry),
    mapPointToElement(result.corners[1], geometry),
    mapPointToElement(result.corners[2], geometry),
    mapPointToElement(result.corners[3], geometry),
  ];
}
 
/** An SVG `points` attribute for a quad — the shortest path to an overlay. */
export function cornersToPolygon(corners: Quad): string {
  return corners.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`).join(" ");
}