Redactor: the code

These are the files that do most of the work. The comments explain why each part works the way it does. If anything is unclear, or you would do it differently, tell me. I will also send you the full code for free, with nothing to sign up for. Ask for the full code

src/detect/validators.ts

The checks behind the rules. A MyKad number needs a real date of birth, and an NRIC needs the right check letter. 45 lines

import { MYKAD_PLACE_CODES } from "./mykad-places.ts";

function realDate(year: number, month: number, day: number): boolean {
  const d = new Date(Date.UTC(year, month - 1, day));
  return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
}

// The card carries two digits of year, so the date must be real in either century.
export function isValidMyKad(digits12: string): boolean {
  if (!/^\d{12}$/.test(digits12)) return false;
  const yy = Number(digits12.slice(0, 2));
  const mm = Number(digits12.slice(2, 4));
  const dd = Number(digits12.slice(4, 6));
  if (!realDate(1900 + yy, mm, dd) && !realDate(2000 + yy, mm, dd)) return false;
  return MYKAD_PLACE_CODES.has(digits12.slice(6, 8));
}

const WEIGHTS = [2, 7, 6, 5, 4, 3, 2];
const CHECK_ST = "JZIHGFEDCBA";
const CHECK_FG = "XWUTRQPNMLK";

export function nricValid(s: string): boolean {
  const v = s.toUpperCase();
  if (!/^[STFG]\d{7}[A-Z]$/.test(v)) return false;
  let sum = WEIGHTS.reduce((acc, w, i) => acc + w * Number(v[i + 1]), 0);
  if (v[0] === "T" || v[0] === "G") sum += 4;
  const table = v[0] === "S" || v[0] === "T" ? CHECK_ST : CHECK_FG;
  return table[sum % 11] === v[8];
}

export function luhn(digits: string): boolean {
  if (!/^\d+$/.test(digits)) return false;
  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let n = Number(digits[i]);
    if (double) {
      n *= 2;
      if (n > 9) n -= 9;
    }
    sum += n;
    double = !double;
  }
  return sum % 10 === 0;
}

src/redact/image.ts

The picture export. Boxes are painted into the pixels and the file is built again from them. 59 lines

import type { Box } from "../eval/score.ts";
import type { Span } from "../detect/types.ts";

export const MAX_FILE_BYTES = 20 * 1024 * 1024;
export const MAX_PIXELS = 40_000_000;

// The padding drawn around every found box, scaled with the picture so large scans still cover anti-aliased edges.
export const padFor = (width: number, height: number): number => Math.max(3, Math.round(Math.min(width, height) * 0.003));

// Orientation from EXIF is applied here, so the exported picture is upright without carrying the tag.
export function decodeImage(file: Blob): Promise<ImageBitmap> {
  return createImageBitmap(file, { imageOrientation: "from-image" });
}

// One box per line the span touches, padded on every side. Zero-size boxes are separators and are skipped.
export function boxesForSpans(spans: Span[], charBoxes: Box[], pad: number): Box[] {
  const out: Box[] = [];
  for (const span of spans) {
    let run: { x0: number; y0: number; x1: number; y1: number } | null = null;
    const flush = () => {
      if (run) out.push({ x: run.x0 - pad, y: run.y0 - pad, w: run.x1 - run.x0 + 2 * pad, h: run.y1 - run.y0 + 2 * pad });
      run = null;
    };
    for (let i = span.start; i < span.end && i < charBoxes.length; i++) {
      const b = charBoxes[i];
      if (b.w === 0 || b.h === 0) continue;
      const mid = b.y + b.h / 2;
      if (run && (mid < run.y0 || mid > run.y1)) flush();
      if (!run) run = { x0: b.x, y0: b.y, x1: b.x + b.w, y1: b.y + b.h };
      else {
        run.x0 = Math.min(run.x0, b.x);
        run.y0 = Math.min(run.y0, b.y);
        run.x1 = Math.max(run.x1, b.x + b.w);
        run.y1 = Math.max(run.y1, b.y + b.h);
      }
    }
    flush();
  }
  return out;
}

// Always a fresh canvas and a freshly encoded PNG: no metadata chunk of the original can come along.
export async function flattenImage(source: ImageBitmap | HTMLCanvasElement, boxes: Box[]): Promise<Blob> {
  const canvas = document.createElement("canvas");
  canvas.width = source.width;
  canvas.height = source.height;
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("no canvas");
  ctx.drawImage(source, 0, 0);
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = "#000";
  for (const b of boxes) {
    const x0 = Math.floor(b.x);
    const y0 = Math.floor(b.y);
    ctx.fillRect(x0, y0, Math.ceil(b.x + b.w) - x0, Math.ceil(b.y + b.h) - y0);
  }
  return new Promise((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("encode failed"))), "image/png"));
}