12345678910111213141516171819import {20 reedSolomonEncode,21 reedSolomonDecode,22 RS_BLOCK_DATA,23 RS_BLOCK_TOTAL,24} from "./rs";2526export const MAYAN_MAGIC = 0x4d;27export const MAYAN_VERSION = 0x01;28export const RUNE_ZONE = 27; 29export const QUIET_ZONE = 2;30export const ECC_RATE = 0.30;31export const MAX_PAYLOAD_BYTES = 65535;3233export interface MayanResult {34 canvas: HTMLCanvasElement;35 symbolSize: number;36 payloadBytes: number;37 printable: boolean;38}39404142export function calculateSymbolSize(payloadBytes: number): number {43 const headerBytes = 4;44 const totalData = payloadBytes + headerBytes;45 46 47 48 49 const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA));50 const dataBits = blocks * RS_BLOCK_TOTAL * 8;5152 for (let size = 64; size <= 600; size++) {53 const totalModules = size * size;54 const centerModules = RUNE_ZONE * RUNE_ZONE;55 const quietModules = 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;56 const refGridModules = size > 12857 ? approxReferenceGridCount(size)58 : 0;59 const usable = totalModules - centerModules - quietModules - refGridModules;60 if (usable >= dataBits) return size;61 }62 return 600;63}6465function approxReferenceGridCount(size: number): number {66 67 68 const lines = Math.floor((size - 2 * QUIET_ZONE) / 32);69 const linePos = lines * (size - 2 * QUIET_ZONE);70 return linePos * 2;71}72737475function fillRect(76 matrix: boolean[][],77 r: number,78 c: number,79 h: number,80 w: number,81): void {82 for (let dr = 0; dr < h; dr++) {83 for (let dc = 0; dc < w; dc++) {84 const mr = r + dr;85 const mc = c + dc;86 if (mr >= 0 && mr < matrix.length && mc >= 0 && mc < matrix[0].length) {87 matrix[mr][mc] = true;88 }89 }90 }91}9293function drawDiag(94 matrix: boolean[][],95 r0: number,96 c0: number,97 r1: number,98 c1: number,99 r2: number,100 c2: number,101 stroke: number,102): void {103 104 105 const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1));106 const half = Math.floor(stroke / 2);107 for (let i = 0; i <= steps; i++) {108 const r = Math.round(r1 + ((r2 - r1) * i) / steps);109 const c = Math.round(c1 + ((c2 - c1) * i) / steps);110 fillRect(matrix, r0 + r - half, c0 + c - half, stroke, stroke);111 }112}113114function drawOthalan(115 matrix: boolean[][],116 centerRow: number,117 centerCol: number,118): void {119 120 const r0 = centerRow - 11;121 const c0 = centerCol - 11;122 const STROKE = 3;123124 125 const TOP = [1, 11];126 const RIGHT = [10, 21];127 const BOT = [19, 11];128 const LEFT = [10, 1];129130 drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE);131 drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE);132 drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE);133 drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE);134135 136 drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE);137 drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE);138139 140 fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE);141}142143144145export function isReserved(146 r: number,147 c: number,148 center: number,149 size: number,150): boolean {151 if (r < QUIET_ZONE || r >= size - QUIET_ZONE) return true;152 if (c < QUIET_ZONE || c >= size - QUIET_ZONE) return true;153 154 const half = Math.floor(RUNE_ZONE / 2);155 if (156 r >= center - half &&157 r <= center + half &&158 c >= center - half &&159 c <= center + half160 ) return true;161 162 if (size > 128) {163 if (164 r >= QUIET_ZONE && r < size - QUIET_ZONE && (r - QUIET_ZONE) % 32 === 0 &&165 r !== QUIET_ZONE166 ) return true;167 if (168 c >= QUIET_ZONE && c < size - QUIET_ZONE && (c - QUIET_ZONE) % 32 === 0 &&169 c !== QUIET_ZONE170 ) return true;171 }172 return false;173}174175176177export function encodeMayan(178 encryptedPayload: Uint8Array,179 modulePx = 3,180): MayanResult {181 if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {182 throw new Error(183 `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,184 );185 }186 const size = calculateSymbolSize(encryptedPayload.length);187 const center = Math.floor(size / 2);188189 190 const header = new Uint8Array(4);191 header[0] = MAYAN_MAGIC;192 header[1] = MAYAN_VERSION;193 header[2] = encryptedPayload.length & 0xff;194 header[3] = (encryptedPayload.length >> 8) & 0xff;195196 const fullData = new Uint8Array(header.length + encryptedPayload.length);197 fullData.set(header);198 fullData.set(encryptedPayload, header.length);199200 const withECC = reedSolomonEncode(fullData);201202 203 const bits: boolean[] = [];204 for (const byte of withECC) {205 for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);206 }207208 209 const matrix: boolean[][] = Array.from(210 { length: size },211 () => new Array<boolean>(size).fill(false),212 );213214 215 if (size > 128) {216 for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {217 for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {218 const dark = (p % 2) === 0;219 matrix[line][p] = dark;220 matrix[p][line] = dark;221 }222 }223 }224225 drawOthalan(matrix, center, center);226227 228 let bitIdx = 0;229 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {230 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {231 if (isReserved(r, c, center, size)) continue;232 matrix[r][c] = bits[bitIdx++];233 }234 }235236 const canvas = renderMatrix(matrix, size, modulePx);237 const printable = size * 0.6 <= 210; 238 return {239 canvas,240 symbolSize: size,241 payloadBytes: encryptedPayload.length,242 printable,243 };244}245246function renderMatrix(247 matrix: boolean[][],248 size: number,249 modulePx: number,250): HTMLCanvasElement {251 const canvas = document.createElement("canvas");252 canvas.width = size * modulePx;253 canvas.height = size * modulePx;254 const ctx = canvas.getContext("2d");255 if (!ctx) throw new Error("Mayan: 2D canvas unavailable");256 ctx.fillStyle = "#ffffff";257 ctx.fillRect(0, 0, canvas.width, canvas.height);258 ctx.fillStyle = "#000000";259 for (let r = 0; r < size; r++) {260 for (let c = 0; c < size; c++) {261 if (matrix[r][c]) {262 ctx.fillRect(c * modulePx, r * modulePx, modulePx, modulePx);263 }264 }265 }266 return canvas;267}268269270271export function encodeMayanMatrix(272 encryptedPayload: Uint8Array,273): { matrix: boolean[][]; size: number } {274 if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {275 throw new Error(276 `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,277 );278 }279 const size = calculateSymbolSize(encryptedPayload.length);280 const center = Math.floor(size / 2);281 const header = new Uint8Array(4);282 header[0] = MAYAN_MAGIC;283 header[1] = MAYAN_VERSION;284 header[2] = encryptedPayload.length & 0xff;285 header[3] = (encryptedPayload.length >> 8) & 0xff;286 const fullData = new Uint8Array(header.length + encryptedPayload.length);287 fullData.set(header);288 fullData.set(encryptedPayload, header.length);289 const withECC = reedSolomonEncode(fullData);290 const bits: boolean[] = [];291 for (const byte of withECC) {292 for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);293 }294 const matrix: boolean[][] = Array.from(295 { length: size },296 () => new Array<boolean>(size).fill(false),297 );298 if (size > 128) {299 for (let p = QUIET_ZONE; p < size - QUIET_ZONE; p++) {300 for (let line = QUIET_ZONE + 32; line < size - QUIET_ZONE; line += 32) {301 const dark = (p % 2) === 0;302 matrix[line][p] = dark;303 matrix[p][line] = dark;304 }305 }306 }307 drawOthalan(matrix, center, center);308 let bitIdx = 0;309 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {310 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {311 if (isReserved(r, c, center, size)) continue;312 matrix[r][c] = bits[bitIdx++];313 }314 }315 return { matrix, size };316}317318319320321322323export function decodeMayan(imageData: ImageData): Uint8Array | null {324 const binary = adaptiveBinarize(imageData);325 const W = imageData.width;326 const H = imageData.height;327328 const rune = findOthalan(binary, W, H);329 if (!rune) return null;330331 const modulePx = estimateModuleSize(binary, rune, W);332 if (!modulePx || modulePx < 1) return null;333334 const symbolModules = Math.round(Math.min(W, H) / modulePx);335 if (symbolModules < 64) return null;336337 const rotation = detectRotation(binary, rune, modulePx);338 const matrix = sampleModuleGrid(339 binary,340 { x: rune.cx, y: rune.cy },341 modulePx,342 symbolModules,343 rotation,344 );345346 return decodeMatrix(matrix, symbolModules);347}348349350export function decodeMatrix(351 matrix: boolean[][],352 symbolModules: number,353): Uint8Array | null {354 const center = Math.floor(symbolModules / 2);355356 357 const headerBits = readDataBits(matrix, center, symbolModules, 32);358 if (headerBits.length < 32) return null;359 const headerBytes = bitsToBytes(headerBits);360 if (headerBytes[0] !== MAYAN_MAGIC) return null;361 if (headerBytes[1] !== MAYAN_VERSION) return null;362 const payloadLength = headerBytes[2] | (headerBytes[3] << 8);363 if (payloadLength > MAX_PAYLOAD_BYTES) return null;364365 366 const totalDataBytes = 4 + payloadLength;367 const blocks = Math.ceil(totalDataBytes / 178);368 const totalBits = blocks * 255 * 8;369 const allBits = readDataBits(matrix, center, symbolModules, totalBits);370 if (allBits.length < totalBits) return null;371 const rawBytes = new Uint8Array(bitsToBytes(allBits));372373 let decoded: Uint8Array;374 try {375 decoded = reedSolomonDecode(rawBytes, totalDataBytes);376 } catch {377 return null;378 }379 return decoded.slice(4, 4 + payloadLength);380}381382383384function adaptiveBinarize(imageData: ImageData): boolean[][] {385 const { data, width, height } = imageData;386 const out: boolean[][] = Array.from(387 { length: height },388 () => new Array<boolean>(width).fill(false),389 );390 const R = 9;391 392 const integral = new Float64Array((width + 1) * (height + 1));393 for (let y = 0; y < height; y++) {394 let rowSum = 0;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 rowSum += gray;399 integral[(y + 1) * (width + 1) + (x + 1)] =400 integral[y * (width + 1) + (x + 1)] + rowSum;401 }402 }403 for (let y = 0; y < height; y++) {404 for (let x = 0; x < width; x++) {405 const i = (y * width + x) * 4;406 const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];407 const x0 = Math.max(0, x - R);408 const y0 = Math.max(0, y - R);409 const x1 = Math.min(width - 1, x + R);410 const y1 = Math.min(height - 1, y + R);411 const sum =412 integral[(y1 + 1) * (width + 1) + (x1 + 1)] -413 integral[(y0) * (width + 1) + (x1 + 1)] -414 integral[(y1 + 1) * (width + 1) + (x0)] +415 integral[(y0) * (width + 1) + (x0)];416 const count = (x1 - x0 + 1) * (y1 - y0 + 1);417 out[y][x] = gray < sum / count - 8;418 }419 }420 return out;421}422423424425function findOthalan(426 binary: boolean[][],427 W: number,428 H: number,429): { cx: number; cy: number; size: number } | null {430 431 432 const candidates: { cx: number; cy: number; size: number }[] = [];433434 const yStart = Math.floor(H * 0.2);435 const yEnd = Math.floor(H * 0.8);436 for (let y = yStart; y < yEnd; y += 2) {437 let x = 0;438 while (x < W) {439 while (x < W && !binary[y][x]) x++;440 if (x >= W) break;441 const darkStart = x;442 while (x < W && binary[y][x]) x++;443 const darkLen = x - darkStart;444445 const lightStart = x;446 while (x < W && !binary[y][x]) x++;447 const lightLen = x - lightStart;448449 const dark2Start = x;450 while (x < W && binary[y][x]) x++;451 const dark2Len = x - dark2Start;452453 if (454 darkLen >= 2 &&455 dark2Len >= 2 &&456 Math.abs(darkLen - dark2Len) <= darkLen * 0.5 &&457 lightLen > darkLen * 1.5458 ) {459 const cx = Math.floor((darkStart + x) / 2);460 const size = x - darkStart;461 candidates.push({ cx, cy: y, size });462 }463 }464 }465466 if (candidates.length < 3) return null;467468 469 const best = candidates.reduce((a, b) => (a.size > b.size ? a : b));470471 472 let cyTop = best.cy;473 while (cyTop > 0 && binary[cyTop][best.cx]) cyTop--;474 let cyBot = best.cy;475 while (476 cyBot < H - 1 &&477 (binary[cyBot][best.cx] ||478 (best.cx > 0 && binary[cyBot][best.cx - 1]) ||479 (best.cx < W - 1 && binary[cyBot][best.cx + 1]))480 ) cyBot++;481 const cy = Math.floor((cyTop + cyBot) / 2);482 return { cx: best.cx, cy, size: best.size };483}484485function estimateModuleSize(486 binary: boolean[][],487 rune: { cx: number; cy: number; size: number },488 W: number,489): number {490 const y = rune.cy;491 const strokeWidths: number[] = [];492 for (let x = Math.max(0, rune.cx - rune.size); x < rune.cx + rune.size; x++) {493 if (x >= W) break;494 if (binary[y][x] && (x === 0 || !binary[y][x - 1])) {495 let runLen = 0;496 let xx = x;497 while (xx < W && binary[y][xx]) { runLen++; xx++; }498 if (runLen > 1 && runLen < rune.size / 2) strokeWidths.push(runLen);499 }500 }501 if (strokeWidths.length === 0) return rune.size / 23;502 const avg = strokeWidths.reduce((a, b) => a + b, 0) / strokeWidths.length;503 return avg / 3;504}505506function detectRotation(507 binary: boolean[][],508 rune: { cx: number; cy: number; size: number },509 modulePx: number,510): number {511 512 513 const offsets: { angle: number; dr: number; dc: number }[] = [514 { angle: 0, dr: -10, dc: +10 },515 { angle: 90, dr: +10, dc: +10 },516 { angle: 180, dr: +10, dc: -10 },517 { angle: 270, dr: -10, dc: -10 },518 ];519 let bestAngle = 0;520 let bestScore = -1;521 for (const { angle, dr, dc } of offsets) {522 const pr = Math.round(rune.cy + dr * modulePx);523 const pc = Math.round(rune.cx + dc * modulePx);524 let darkCount = 0;525 for (let dr2 = -1; dr2 <= 1; dr2++) {526 for (let dc2 = -1; dc2 <= 1; dc2++) {527 const r = pr + Math.round(dr2 * modulePx);528 const c = pc + Math.round(dc2 * modulePx);529 if (r >= 0 && r < binary.length && c >= 0 && c < binary[0].length) {530 if (binary[r][c]) darkCount++;531 }532 }533 }534 if (darkCount > bestScore) {535 bestScore = darkCount;536 bestAngle = angle;537 }538 }539 return bestAngle;540}541542function sampleModuleGrid(543 binary: boolean[][],544 center: { x: number; y: number },545 modulePx: number,546 symbolModules: number,547 rotation: number,548): boolean[][] {549 const half = Math.floor(symbolModules / 2);550 const matrix: boolean[][] = Array.from(551 { length: symbolModules },552 () => new Array<boolean>(symbolModules).fill(false),553 );554 for (let r = 0; r < symbolModules; r++) {555 for (let c = 0; c < symbolModules; c++) {556 let dr = r - half;557 let dc = c - half;558 if (rotation === 90) { [dr, dc] = [dc, -dr]; }559 if (rotation === 180) { [dr, dc] = [-dr, -dc]; }560 if (rotation === 270) { [dr, dc] = [-dc, dr]; }561 const px = Math.round(center.x + dc * modulePx);562 const py = Math.round(center.y + dr * modulePx);563 if (564 py >= 0 &&565 py < binary.length &&566 px >= 0 &&567 px < binary[0].length568 ) matrix[r][c] = binary[py][px];569 }570 }571 return matrix;572}573574function readDataBits(575 matrix: boolean[][],576 center: number,577 size: number,578 count: number,579): boolean[] {580 const bits: boolean[] = [];581 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {582 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {583 if (isReserved(r, c, center, size)) continue;584 bits.push(matrix[r][c]);585 }586 }587 return bits;588}589590function bitsToBytes(bits: boolean[]): number[] {591 const bytes: number[] = [];592 for (let i = 0; i < bits.length; i += 8) {593 let byte = 0;594 for (let j = 0; j < 8 && i + j < bits.length; j++) {595 if (bits[i + j]) byte |= 1 << (7 - j);596 }597 bytes.push(byte);598 }599 return bytes;600}