All files / qr-code/src/render shapes.ts

100% Statements 38/38
100% Branches 69/69
100% Functions 8/8
100% Lines 36/36

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 174                                          16034x 16034x                           1446x 5784x 1446x   734x 734x 734x 1446x 1258x   1446x 722x                           12x                             1981x 1981x                                                           4089x 4089x 4089x 4089x   4089x   723x   1958x   1072x 1072x                   336x 336x                       462x   450x   3x         6x   3x                 466x   451x   4x   7x   4x      
import type { QREyeShape, QRModuleShape } from "../types";
import { fmt } from "./color";
 
/**
 * SVG path construction for modules and finder "eyes".
 *
 * Everything here is a pure string builder over user units where one unit is
 * one module. The canvas renderer replays the *same* functions through
 * `Path2D`, so a shape can never look different between SVG and canvas —
 * there is only one definition of what "rounded" means.
 *
 * Winding matters: a finder ring is an outer shape with a hole, and under the
 * default `nonzero` fill rule the hole must run in the opposite direction from
 * the outline. Every builder therefore takes a `clockwise` flag.
 */
 
/** Corner radii, clockwise from the top-left. */
export type Corners = readonly [number, number, number, number];
 
/** An axis-aligned rectangle. */
export function rectPath(x: number, y: number, w: number, h: number, clockwise = true): string {
  const [x0, y0, x1, y1] = [fmt(x), fmt(y), fmt(x + w), fmt(y + h)];
  return clockwise
    ? `M${x0} ${y0}H${x1}V${y1}H${x0}Z`
    : `M${x0} ${y0}V${y1}H${x1}V${y0}Z`;
}
 
/** A rectangle with independent corner radii. */
export function roundedRectPath(
  x: number,
  y: number,
  w: number,
  h: number,
  corners: Corners,
  clockwise = true,
): string {
  const limit = Math.min(w, h) / 2;
  const [tl, tr, br, bl] = corners.map((r) => Math.max(0, Math.min(r, limit))) as unknown as Corners;
  if (tl === 0 && tr === 0 && br === 0 && bl === 0) return rectPath(x, y, w, h, clockwise);
 
  const right = x + w;
  const bottom = y + h;
  const sweep = clockwise ? 1 : 0;
  const arc = (r: number, ex: number, ey: number): string =>
    `A${fmt(r)} ${fmt(r)} 0 0 ${sweep} ${fmt(ex)} ${fmt(ey)}`;
 
  if (clockwise) {
    return (
      `M${fmt(x + tl)} ${fmt(y)}` +
      `H${fmt(right - tr)}` +
      (tr ? arc(tr, right, y + tr) : "") +
      `V${fmt(bottom - br)}` +
      (br ? arc(br, right - br, bottom) : "") +
      `H${fmt(x + bl)}` +
      (bl ? arc(bl, x, bottom - bl) : "") +
      `V${fmt(y + tl)}` +
      (tl ? arc(tl, x + tl, y) : "") +
      "Z"
    );
  }
 
  return (
    `M${fmt(x + tl)} ${fmt(y)}` +
    (tl ? arc(tl, x, y + tl) : `H${fmt(x)}`) +
    `V${fmt(bottom - bl)}` +
    (bl ? arc(bl, x + bl, bottom) : "") +
    `H${fmt(right - br)}` +
    (br ? arc(br, right, bottom - br) : "") +
    `V${fmt(y + tr)}` +
    (tr ? arc(tr, right - tr, y) : "") +
    "Z"
  );
}
 
/** A full circle, drawn as two half-arcs. */
export function circlePath(cx: number, cy: number, r: number, clockwise = true): string {
  const sweep = clockwise ? 1 : 0;
  return (
    `M${fmt(cx - r)} ${fmt(cy)}` +
    `A${fmt(r)} ${fmt(r)} 0 1 ${sweep} ${fmt(cx + r)} ${fmt(cy)}` +
    `A${fmt(r)} ${fmt(r)} 0 1 ${sweep} ${fmt(cx - r)} ${fmt(cy)}Z`
  );
}
 
/** Which of a module's four orthogonal neighbours are painted. */
export interface Neighbours {
  up: boolean;
  down: boolean;
  left: boolean;
  right: boolean;
}
 
/**
 * One data module.
 *
 * The shaped variants are **neighbour-aware**: a corner is only rounded when
 * both edges meeting there are exposed. Without that, a run of rounded modules
 * would pinch at every join and visually break the code into disconnected
 * blobs — which is a scanning problem, not a taste problem.
 */
export function modulePath(
  shape: QRModuleShape,
  x: number,
  y: number,
  gap: number,
  neighbours: Neighbours,
): string {
  const inset = gap / 2;
  const px = x + inset;
  const py = y + inset;
  const span = 1 - gap;
 
  switch (shape) {
    case "square":
      return rectPath(px, py, span, span);
    case "dot":
      return circlePath(x + 0.5, y + 0.5, span / 2);
    case "rounded": {
      const r = span * 0.45;
      return roundedRectPath(px, py, span, span, [
        !neighbours.up && !neighbours.left ? r : 0,
        !neighbours.up && !neighbours.right ? r : 0,
        !neighbours.down && !neighbours.right ? r : 0,
        !neighbours.down && !neighbours.left ? r : 0,
      ]);
    }
    case "classy": {
      // Only the top-left and bottom-right corners round, and fully — the
      // asymmetry is what gives the style its leaf-like diagonal rhythm.
      const r = span / 2;
      return roundedRectPath(px, py, span, span, [
        !neighbours.up && !neighbours.left ? r : 0,
        0,
        !neighbours.down && !neighbours.right ? r : 0,
        0,
      ]);
    }
  }
}
 
/** The 7×7 finder ring (outline with a 5×5 hole), positioned in padded units. */
export function eyeOuterPath(shape: QREyeShape, x: number, y: number): string {
  switch (shape) {
    case "square":
      return rectPath(x, y, 7, 7) + rectPath(x + 1, y + 1, 5, 5, false);
    case "rounded":
      return (
        roundedRectPath(x, y, 7, 7, [2, 2, 2, 2]) +
        roundedRectPath(x + 1, y + 1, 5, 5, [1.4, 1.4, 1.4, 1.4], false)
      );
    case "circle":
      return circlePath(x + 3.5, y + 3.5, 3.5) + circlePath(x + 3.5, y + 3.5, 2.5, false);
    case "leaf":
      return (
        roundedRectPath(x, y, 7, 7, [3.5, 0, 3.5, 0]) +
        roundedRectPath(x + 1, y + 1, 5, 5, [2.5, 0, 2.5, 0], false)
      );
  }
}
 
/** The 3×3 finder centre, positioned in padded units. */
export function eyeInnerPath(shape: QREyeShape, x: number, y: number): string {
  switch (shape) {
    case "square":
      return rectPath(x + 2, y + 2, 3, 3);
    case "rounded":
      return roundedRectPath(x + 2, y + 2, 3, 3, [0.9, 0.9, 0.9, 0.9]);
    case "circle":
      return circlePath(x + 3.5, y + 3.5, 1.5);
    case "leaf":
      return roundedRectPath(x + 2, y + 2, 3, 3, [1.5, 0, 1.5, 0]);
  }
}