12345678910111213141516171819import { reedSolomonEncode, reedSolomonDecode } from "./rs";2021export const MAYAN_MAGIC = 0x4d;22export const MAYAN_VERSION = 0x01;23export const RUNE_ZONE = 27; 24export const QUIET_ZONE = 2;25export const ECC_RATE = 0.30;26export const MAX_PAYLOAD_BYTES = 65535;2728export interface MayanResult {29 canvas: HTMLCanvasElement;30 symbolSize: number;31 payloadBytes: number;32 printable: boolean;33}34353637export function calculateSymbolSize(payloadBytes: number): number {38 const headerBytes = 4;39 const totalData = payloadBytes + headerBytes;40 const withECC = totalData / (1 - ECC_RATE);41 const dataBits = Math.ceil(withECC * 8);4243 for (let size = 64; size <= 600; size++) {44 const totalModules = size * size;45 const centerModules = RUNE_ZONE * RUNE_ZONE;46 const quietModules = 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;47 const refGridModules = size > 12848 ? approxReferenceGridCount(size)49 : 0;50 const usable = totalModules - centerModules - quietModules - refGridModules;51 if (usable >= dataBits) return size;52 }53 return 600;54}5556function approxReferenceGridCount(size: number): number {57 58 59 const lines = Math.floor((size - 2 * QUIET_ZONE) / 32);60 const linePos = lines * (size - 2 * QUIET_ZONE);61 return linePos * 2;62}63646566function fillRect(67 matrix: boolean[][],68 r: number,69 c: number,70 h: number,71 w: number,72): void {73 for (let dr = 0; dr < h; dr++) {74 for (let dc = 0; dc < w; dc++) {75 const mr = r + dr;76 const mc = c + dc;77 if (mr >= 0 && mr < matrix.length && mc >= 0 && mc < matrix[0].length) {78 matrix[mr][mc] = true;79 }80 }81 }82}8384function drawDiag(85 matrix: boolean[][],86 r0: number,87 c0: number,88 r1: number,89 c1: number,90 r2: number,91 c2: number,92 stroke: number,93): void {94 95 96 const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1));97 const half = Math.floor(stroke / 2);98 for (let i = 0; i <= steps; i++) {99 const r = Math.round(r1 + ((r2 - r1) * i) / steps);100 const c = Math.round(c1 + ((c2 - c1) * i) / steps);101 fillRect(matrix, r0 + r - half, c0 + c - half, stroke, stroke);102 }103}104105function drawOthalan(106 matrix: boolean[][],107 centerRow: number,108 centerCol: number,109): void {110 111 const r0 = centerRow - 11;112 const c0 = centerCol - 11;113 const STROKE = 3;114115 116 const TOP = [1, 11];117 const RIGHT = [10, 21];118 const BOT = [19, 11];119 const LEFT = [10, 1];120121 drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE);122 drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE);123 drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE);124 drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE);125126 127 drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE);128 drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE);129130 131 fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE);132}133134135136export function isReserved(137 r: number,138 c: number,139 center: number,140 size: number,141): boolean {142 if (r < QUIET_ZONE || r >= size - QUIET_ZONE) return true;143 if (c < QUIET_ZONE || c >= size - QUIET_ZONE) return true;144 145 const half = Math.floor(RUNE_ZONE / 2);146 if (147 r >= center - half &&148 r <= center + half &&149 c >= center - half &&150 c <= center + half151 ) return true;152 153 if (size > 128) {154 if (155 r >= QUIET_ZONE && r < size - QUIET_ZONE && (r - QUIET_ZONE) % 32 === 0 &&156 r !== QUIET_ZONE157 ) return true;158 if (159 c >= QUIET_ZONE && c < size - QUIET_ZONE && (c - QUIET_ZONE) % 32 === 0 &&160 c !== QUIET_ZONE161 ) return true;162 }163 return false;164}165166167168export function encodeMayan(169 encryptedPayload: Uint8Array,170 modulePx = 3,171): MayanResult {172 if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {173 throw new Error(174 `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,175 );176 }177 const size = calculateSymbolSize(encryptedPayload.length);178 const center = Math.floor(size / 2);179180 181 const header = new Uint8Array(4);182 header[0] = MAYAN_MAGIC;183 header[1] = MAYAN_VERSION;184 header[2] = encryptedPayload.length & 0xff;185 header[3] = (encryptedPayload.length >> 8) & 0xff;186187 const fullData = new Uint8Array(header.length + encryptedPayload.length);188 fullData.set(header);189 fullData.set(encryptedPayload, header.length);190191 const withECC = reedSolomonEncode(fullData);192193 194 const bits: boolean[] = [];195 for (const byte of withECC) {196 for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);197 }198199 200 const matrix: boolean[][] = Array.from(201 { length: size },202 () => new Array<boolean>(size).fill(false),203 );204205 206 if (size > 128) {207 for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {208 for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {209 const dark = (p % 2) === 0;210 matrix[line][p] = dark;211 matrix[p][line] = dark;212 }213 }214 }215216 drawOthalan(matrix, center, center);217218 219 let bitIdx = 0;220 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {221 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {222 if (isReserved(r, c, center, size)) continue;223 matrix[r][c] = bits[bitIdx++];224 }225 }226227 const canvas = renderMatrix(matrix, size, modulePx);228 const printable = size * 0.6 <= 210; 229 return {230 canvas,231 symbolSize: size,232 payloadBytes: encryptedPayload.length,233 printable,234 };235}236237function renderMatrix(238 matrix: boolean[][],239 size: number,240 modulePx: number,241): HTMLCanvasElement {242 const canvas = document.createElement("canvas");243 canvas.width = size * modulePx;244 canvas.height = size * modulePx;245 const ctx = canvas.getContext("2d");246 if (!ctx) throw new Error("Mayan: 2D canvas unavailable");247 ctx.fillStyle = "#ffffff";248 ctx.fillRect(0, 0, canvas.width, canvas.height);249 ctx.fillStyle = "#000000";250 for (let r = 0; r < size; r++) {251 for (let c = 0; c < size; c++) {252 if (matrix[r][c]) {253 ctx.fillRect(c * modulePx, r * modulePx, modulePx, modulePx);254 }255 }256 }257 return canvas;258}259260261262export function encodeMayanMatrix(263 encryptedPayload: Uint8Array,264): { matrix: boolean[][]; size: number } {265 if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {266 throw new Error(267 `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,268 );269 }270 const size = calculateSymbolSize(encryptedPayload.length);271 const center = Math.floor(size / 2);272 const header = new Uint8Array(4);273 header[0] = MAYAN_MAGIC;274 header[1] = MAYAN_VERSION;275 header[2] = encryptedPayload.length & 0xff;276 header[3] = (encryptedPayload.length >> 8) & 0xff;277 const fullData = new Uint8Array(header.length + encryptedPayload.length);278 fullData.set(header);279 fullData.set(encryptedPayload, header.length);280 const withECC = reedSolomonEncode(fullData);281 const bits: boolean[] = [];282 for (const byte of withECC) {283 for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);284 }285 const matrix: boolean[][] = Array.from(286 { length: size },287 () => new Array<boolean>(size).fill(false),288 );289 if (size > 128) {290 for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {291 for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {292 const dark = (p % 2) === 0;293 matrix[line][p] = dark;294 matrix[p][line] = dark;295 }296 }297 }298 drawOthalan(matrix, center, center);299 let bitIdx = 0;300 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {301 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {302 if (isReserved(r, c, center, size)) continue;303 matrix[r][c] = bits[bitIdx++];304 }305 }306 return { matrix, size };307}308309310311312313314export function decodeMayan(imageData: ImageData): Uint8Array | null {315 const binary = adaptiveBinarize(imageData);316 const W = imageData.width;317 const H = imageData.height;318319 const rune = findOthalan(binary, W, H);320 if (!rune) return null;321322 const modulePx = estimateModuleSize(binary, rune, W);323 if (!modulePx || modulePx < 1) return null;324325 const symbolModules = Math.round(Math.min(W, H) / modulePx);326 if (symbolModules < 64) return null;327328 const rotation = detectRotation(binary, rune, modulePx);329 const matrix = sampleModuleGrid(330 binary,331 { x: rune.cx, y: rune.cy },332 modulePx,333 symbolModules,334 rotation,335 );336337 return decodeMatrix(matrix, symbolModules);338}339340341export function decodeMatrix(342 matrix: boolean[][],343 symbolModules: number,344): Uint8Array | null {345 const center = Math.floor(symbolModules / 2);346347 348 const headerBits = readDataBits(matrix, center, symbolModules, 32);349 if (headerBits.length < 32) return null;350 const headerBytes = bitsToBytes(headerBits);351 if (headerBytes[0] !== MAYAN_MAGIC) return null;352 if (headerBytes[1] !== MAYAN_VERSION) return null;353 const payloadLength = headerBytes[2] | (headerBytes[3] << 8);354 if (payloadLength > MAX_PAYLOAD_BYTES) return null;355356 357 const totalDataBytes = 4 + payloadLength;358 const blocks = Math.ceil(totalDataBytes / 178);359 const totalBits = blocks * 255 * 8;360 const allBits = readDataBits(matrix, center, symbolModules, totalBits);361 if (allBits.length < totalBits) return null;362 const rawBytes = new Uint8Array(bitsToBytes(allBits));363364 let decoded: Uint8Array;365 try {366 decoded = reedSolomonDecode(rawBytes, totalDataBytes);367 } catch {368 return null;369 }370 return decoded.slice(4, 4 + payloadLength);371}372373374375function adaptiveBinarize(imageData: ImageData): boolean[][] {376 const { data, width, height } = imageData;377 const out: boolean[][] = Array.from(378 { length: height },379 () => new Array<boolean>(width).fill(false),380 );381 const R = 9;382 383 const integral = new Float64Array((width + 1) * (height + 1));384 for (let y = 0; y < height; y++) {385 let rowSum = 0;386 for (let x = 0; x < width; x++) {387 const i = (y * width + x) * 4;388 const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];389 rowSum += gray;390 integral[(y + 1) * (width + 1) + (x + 1)] =391 integral[y * (width + 1) + (x + 1)] + rowSum;392 }393 }394 for (let y = 0; y < height; y++) {395 for (let x = 0; x < width; x++) {396 const i = (y * width + x) * 4;397 const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];398 const x0 = Math.max(0, x - R);399 const y0 = Math.max(0, y - R);400 const x1 = Math.min(width - 1, x + R);401 const y1 = Math.min(height - 1, y + R);402 const sum =403 integral[(y1 + 1) * (width + 1) + (x1 + 1)] -404 integral[(y0) * (width + 1) + (x1 + 1)] -405 integral[(y1 + 1) * (width + 1) + (x0)] +406 integral[(y0) * (width + 1) + (x0)];407 const count = (x1 - x0 + 1) * (y1 - y0 + 1);408 out[y][x] = gray < sum / count - 8;409 }410 }411 return out;412}413414415416function findOthalan(417 binary: boolean[][],418 W: number,419 H: number,420): { cx: number; cy: number; size: number } | null {421 422 423 const candidates: { cx: number; cy: number; size: number }[] = [];424425 const yStart = Math.floor(H * 0.2);426 const yEnd = Math.floor(H * 0.8);427 for (let y = yStart; y < yEnd; y += 2) {428 let x = 0;429 while (x < W) {430 while (x < W && !binary[y][x]) x++;431 if (x >= W) break;432 const darkStart = x;433 while (x < W && binary[y][x]) x++;434 const darkLen = x - darkStart;435436 const lightStart = x;437 while (x < W && !binary[y][x]) x++;438 const lightLen = x - lightStart;439440 const dark2Start = x;441 while (x < W && binary[y][x]) x++;442 const dark2Len = x - dark2Start;443444 if (445 darkLen >= 2 &&446 dark2Len >= 2 &&447 Math.abs(darkLen - dark2Len) <= darkLen * 0.5 &&448 lightLen > darkLen * 1.5449 ) {450 const cx = Math.floor((darkStart + x) / 2);451 const size = x - darkStart;452 candidates.push({ cx, cy: y, size });453 }454 }455 }456457 if (candidates.length < 3) return null;458459 460 const best = candidates.reduce((a, b) => (a.size > b.size ? a : b));461462 463 let cyTop = best.cy;464 while (cyTop > 0 && binary[cyTop][best.cx]) cyTop--;465 let cyBot = best.cy;466 while (467 cyBot < H - 1 &&468 (binary[cyBot][best.cx] ||469 (best.cx > 0 && binary[cyBot][best.cx - 1]) ||470 (best.cx < W - 1 && binary[cyBot][best.cx + 1]))471 ) cyBot++;472 const cy = Math.floor((cyTop + cyBot) / 2);473 return { cx: best.cx, cy, size: best.size };474}475476function estimateModuleSize(477 binary: boolean[][],478 rune: { cx: number; cy: number; size: number },479 W: number,480): number {481 const y = rune.cy;482 const strokeWidths: number[] = [];483 for (let x = Math.max(0, rune.cx - rune.size); x < rune.cx + rune.size; x++) {484 if (x >= W) break;485 if (binary[y][x] && (x === 0 || !binary[y][x - 1])) {486 let runLen = 0;487 let xx = x;488 while (xx < W && binary[y][xx]) { runLen++; xx++; }489 if (runLen > 1 && runLen < rune.size / 2) strokeWidths.push(runLen);490 }491 }492 if (strokeWidths.length === 0) return rune.size / 23;493 const avg = strokeWidths.reduce((a, b) => a + b, 0) / strokeWidths.length;494 return avg / 3;495}496497function detectRotation(498 binary: boolean[][],499 rune: { cx: number; cy: number; size: number },500 modulePx: number,501): number {502 503 504 const offsets: { angle: number; dr: number; dc: number }[] = [505 { angle: 0, dr: -10, dc: +10 },506 { angle: 90, dr: +10, dc: +10 },507 { angle: 180, dr: +10, dc: -10 },508 { angle: 270, dr: -10, dc: -10 },509 ];510 let bestAngle = 0;511 let bestScore = -1;512 for (const { angle, dr, dc } of offsets) {513 const pr = Math.round(rune.cy + dr * modulePx);514 const pc = Math.round(rune.cx + dc * modulePx);515 let darkCount = 0;516 for (let dr2 = -1; dr2 <= 1; dr2++) {517 for (let dc2 = -1; dc2 <= 1; dc2++) {518 const r = pr + Math.round(dr2 * modulePx);519 const c = pc + Math.round(dc2 * modulePx);520 if (r >= 0 && r < binary.length && c >= 0 && c < binary[0].length) {521 if (binary[r][c]) darkCount++;522 }523 }524 }525 if (darkCount > bestScore) {526 bestScore = darkCount;527 bestAngle = angle;528 }529 }530 return bestAngle;531}532533function sampleModuleGrid(534 binary: boolean[][],535 center: { x: number; y: number },536 modulePx: number,537 symbolModules: number,538 rotation: number,539): boolean[][] {540 const half = Math.floor(symbolModules / 2);541 const matrix: boolean[][] = Array.from(542 { length: symbolModules },543 () => new Array<boolean>(symbolModules).fill(false),544 );545 for (let r = 0; r < symbolModules; r++) {546 for (let c = 0; c < symbolModules; c++) {547 let dr = r - half;548 let dc = c - half;549 if (rotation === 90) { [dr, dc] = [dc, -dr]; }550 if (rotation === 180) { [dr, dc] = [-dr, -dc]; }551 if (rotation === 270) { [dr, dc] = [-dc, dr]; }552 const px = Math.round(center.x + dc * modulePx);553 const py = Math.round(center.y + dr * modulePx);554 if (555 py >= 0 &&556 py < binary.length &&557 px >= 0 &&558 px < binary[0].length559 ) matrix[r][c] = binary[py][px];560 }561 }562 return matrix;563}564565function readDataBits(566 matrix: boolean[][],567 center: number,568 size: number,569 count: number,570): boolean[] {571 const bits: boolean[] = [];572 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {573 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {574 if (isReserved(r, c, center, size)) continue;575 bits.push(matrix[r][c]);576 }577 }578 return bits;579}580581function bitsToBytes(bits: boolean[]): number[] {582 const bytes: number[] = [];583 for (let i = 0; i < bits.length; i += 8) {584 let byte = 0;585 for (let j = 0; j < 8 && i + j < bits.length; j++) {586 if (bits[i + j]) byte |= 1 << (7 - j);587 }588 bytes.push(byte);589 }590 return bytes;591}