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

100% Statements 42/42
100% Branches 55/55
100% Functions 10/10
100% Lines 34/34

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 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216                                                  12x 12x     12x   12x   12x   12x                                                         12x                                                                     35x 70x 69x     35x 34x               104x 102x                                         499x     499x         499x   499x                                                         77x 77x   77x 77x 86x 86x 79x   77x               5x                           68x   68x 68x 75x 75x 72x   68x    
import type {
  QREyeShape,
  QRGradient,
  QRLogoOptions,
  QRModuleShape,
  QRStyleOptions,
} from "../types";
import { clampFinite, clampInt } from "../types";
import {
  DEFAULT_BG,
  DEFAULT_FG,
  contrastRatio,
  isSafeCssColor,
  relativeLuminance,
  sanitizeBackground,
  sanitizeColor,
} from "./color";
 
/**
 * Option resolution: one pass that clamps every number, sanitizes every
 * colour, and hands the renderers a struct with no optional fields left to
 * re-interpret. Every surface (SVG, canvas, PNG) consumes the *same* resolved
 * object, which is what makes them agree by construction.
 */
 
const MODULE_SHAPES: readonly QRModuleShape[] = ["square", "rounded", "dot", "classy"];
const EYE_SHAPES: readonly QREyeShape[] = ["square", "rounded", "circle", "leaf"];
 
/** Below this WCAG ratio a code is at real risk of not scanning. */
export const MIN_SCANNABLE_CONTRAST = 3;
/** A logo may never cover more than this fraction of the code's width. */
export const MAX_LOGO_SIZE = 0.35;
/** The hard ceiling on `moduleGap`; see {@link MAX_SAFE_MODULE_GAP}. */
export const MAX_MODULE_GAP = 0.3;
/** The spec's minimum quiet zone, in modules. */
export const MIN_QUIET_ZONE = 4;
 
/**
 * The largest `moduleGap` each shape still decodes at reliably.
 *
 * **Measured, not assumed.** Each combination was rendered by this package in
 * a real browser, rasterized at 180/240/360/480/640 px, and decoded with a real
 * QR decoder. The table is the number of those five rasterizations that read
 * back correctly:
 *
 * | shape | 0 | 0.05 | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 |
 * |---|---|---|---|---|---|---|---|
 * | square  | 5/5 | 5/5 | 5/5 | 5/5 | 5/5 | 4/5 | 2/5 |
 * | rounded | 5/5 | 5/5 | 4/5 | 5/5 | 4/5 | 1/5 | 0/5 |
 * | classy  | 5/5 | 5/5 | 4/5 | 5/5 | 1/5 | 0/5 | 0/5 |
 * | dot     | 5/5 | 5/5 | 4/5 | 2/5 | 0/5 | 0/5 | 0/5 |
 *
 * The budgets below are the last gap that read back at *every* resolution.
 * Partial scores are the decoder's binarizer landing differently as the
 * module-to-pixel ratio shifts — which is exactly why a threshold has to be
 * checked at several sizes rather than one.
 *
 * Separated circles disturb timing-pattern detection far sooner than inset
 * squares do, which is why `dot` gets the tightest budget by a wide margin.
 *
 * Exceeding these warns in development rather than clamping: a code rendered
 * large or for print tolerates more than one on a phone screen, and silently
 * overriding an explicit value would be the more surprising behaviour.
 */
export const MAX_SAFE_MODULE_GAP: Record<QRModuleShape, number> = {
  square: 0.2,
  rounded: 0.15,
  classy: 0.15,
  dot: 0.05,
};
 
export interface ResolvedLogo {
  src: string;
  /** Width as a fraction of the rendered side, including the quiet zone. */
  size: number;
  excavate: boolean;
  padding: number;
  shape: "square" | "circle";
  crossOrigin?: "anonymous" | "use-credentials";
}
 
export interface ResolvedStyle {
  margin: number;
  /** Solid foreground; also the fallback paint when a gradient can't be used. */
  fg: string;
  /** Sanitized gradient, or `null` for a solid foreground. */
  fgGradient: QRGradient | null;
  bg: string | null;
  moduleShape: QRModuleShape;
  eyeShape: QREyeShape;
  /** Finder-ring paint; `null` means "same as the foreground". */
  eyeOuter: string | null;
  /** Finder-centre paint; `null` means "same as the foreground". */
  eyeInner: string | null;
  moduleGap: number;
  logo: ResolvedLogo | null;
}
 
function resolveGradient(gradient: QRGradient): QRGradient | null {
  const stops = gradient.stops
    .filter((stop) => isSafeCssColor(stop.color))
    .map((stop) => ({ offset: clampFinite(stop.offset, 0, 0, 1), color: stop.color }));
  // A gradient needs two stops to be a gradient; anything less falls back to a
  // solid colour rather than rendering an invisible code.
  if (stops.length < 2) return null;
  return {
    type: gradient.type === "radial" ? "radial" : "linear",
    rotation: clampFinite(gradient.rotation, 0, -360, 360),
    stops,
  };
}
 
function resolveLogo(logo: QRLogoOptions): ResolvedLogo | null {
  if (typeof logo.src !== "string" || logo.src.length === 0) return null;
  return {
    src: logo.src,
    size: clampFinite(logo.size, 0.2, 0.05, MAX_LOGO_SIZE),
    excavate: logo.excavate !== false,
    padding: clampFinite(logo.padding, 0.02, 0, 0.1),
    shape: logo.shape === "circle" ? "circle" : "square",
    ...(logo.crossOrigin ? { crossOrigin: logo.crossOrigin } : {}),
  };
}
 
/**
 * Normalize style options into the struct every renderer consumes.
 *
 * @example
 * ```ts
 * resolveStyle({ margin: NaN, fg: "url(#x)", moduleGap: 5 });
 * // → { margin: 4, fg: "#000000", moduleGap: 0.3, … }
 * ```
 */
export function resolveStyle(options: QRStyleOptions = {}): ResolvedStyle {
  const gradient =
    options.fg && typeof options.fg === "object" ? resolveGradient(options.fg) : null;
  // With a gradient, the solid `fg` becomes the canvas/PNG fallback and the
  // colour a contrast check can reason about: its first stop.
  const fg = gradient
    ? sanitizeColor(gradient.stops[0]!.color)
    : sanitizeColor(typeof options.fg === "string" ? options.fg : DEFAULT_FG);
 
  const bg =
    options.bg === undefined ? DEFAULT_BG : options.bg === null ? null : sanitizeBackground(options.bg);
 
  return {
    margin: clampInt(options.margin, 4, 0, 64),
    fg,
    fgGradient: gradient,
    bg,
    moduleShape: MODULE_SHAPES.includes(options.moduleShape!) ? options.moduleShape! : "square",
    eyeShape: EYE_SHAPES.includes(options.eyeShape!) ? options.eyeShape! : "square",
    eyeOuter: isSafeCssColor(options.eyeColor?.outer) ? options.eyeColor!.outer! : null,
    eyeInner: isSafeCssColor(options.eyeColor?.inner) ? options.eyeColor!.inner! : null,
    moduleGap: clampFinite(options.moduleGap, 0, 0, MAX_MODULE_GAP),
    logo: options.logo ? resolveLogo(options.logo) : null,
  };
}
 
/**
 * The *worst* foreground/background contrast in a style — and which colour
 * produces it.
 *
 * For a gradient this examines every stop, not just the first. A gradient that
 * starts at a strong navy and fades to pale pink is perfectly legible where it
 * starts and unreadable where it ends; judging it by its first stop alone
 * would wave through exactly the codes most likely to fail a scanner.
 *
 * Stops in a colour format this package cannot parse are skipped rather than
 * poisoning the result — see `relativeLuminance`.
 */
export function worstContrast(
  style: ResolvedStyle,
): { ratio: number; color: string } | null {
  const background = style.bg ?? DEFAULT_BG;
  const colors = style.fgGradient ? style.fgGradient.stops.map((stop) => stop.color) : [style.fg];
 
  let worst: { ratio: number; color: string } | null = null;
  for (const color of colors) {
    const ratio = contrastRatio(color, background);
    if (ratio === null) continue;
    if (worst === null || ratio < worst.ratio) worst = { ratio, color };
  }
  return worst;
}
 
/**
 * Contrast between the resolved foreground and background, or `null` when no
 * colour involved could be parsed. For a gradient this is its worst stop.
 */
export function styleContrast(style: ResolvedStyle): number | null {
  return worstContrast(style)?.ratio ?? null;
}
 
/**
 * The lightest foreground colour in a style — for a gradient, its lightest
 * stop rather than its first.
 *
 * Polarity has the same trap as contrast: a gradient may start at a strong
 * navy and end at near-white, and judging it by `fg` alone would miss that
 * most of the code is painted lighter than its background.
 */
export function lightestForeground(
  style: ResolvedStyle,
): { luminance: number; color: string } | null {
  const colors = style.fgGradient ? style.fgGradient.stops.map((stop) => stop.color) : [style.fg];
 
  let lightest: { luminance: number; color: string } | null = null;
  for (const color of colors) {
    const luminance = relativeLuminance(color);
    if (luminance === null) continue;
    if (lightest === null || luminance > lightest.luminance) lightest = { luminance, color };
  }
  return lightest;
}