3673ad98ff49
feat(mayan): v2 — corner anchors, homography decoder, RS(255,204), v1 dropped (v2.25.0)
- time
- 2026-05-01 19:04 UTC · 5 months ago
- machine
- koh2
- parent
- 47945ca845b0
↓ peach-browser-3673ad98.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/crypto/mayan.test.ts+88 −34src/crypto/mayan.ts+261 −454src/crypto/mayan_decode.ts+659 −0src/crypto/rs.ts+27 −15src/popup/views/Export.tsx+6 −3manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.24.0",4+ "version": "2.25.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.24.0",4+ "version": "2.25.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.19.0",3+ "version": "2.25.0",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {src/crypto/mayan.test.ts+88 −34
@@ -1,20 +1,24 @@11 import { describe, it, expect } from "vitest";22 import {33 MAYAN_MAGIC,4- MAYAN_VERSION,4+ MAYAN_V2,55 MAX_PAYLOAD_BYTES,66 RUNE_ZONE,77 QUIET_ZONE,8+ ANCHOR_FOOTPRINT,9+ RS_DATA,10+ RS_BLOCK,11+ HEADER_BYTES,812 calculateSymbolSize,913 encodeMayanMatrix,14+ encodeMayan,1015 decodeMatrix,1116 decodeMayan,1217 isReserved,1318 } from "./mayan";1419 1520 // Synthesize an ImageData from a boolean module matrix at a given module pixel16-// size. Pure black/white, no border padding. Useful for exercising the full17-// image-decode pipeline without a canvas.21+// size. Pure black/white, no border padding.1822 function matrixToImageData(1923 matrix: boolean[][],2024 size: number,@@ -43,7 +47,7 @@4347 return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;4448 }4549 46-// Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.50+// Deterministic PRNG — Mulberry32 — so tests are reproducible.4751 function mulberry32(seed: number) {4852 let state = seed >>> 0;4953 return () => {@@ -68,11 +72,10 @@6872 fraction: number,6973 seed = 1,7074 ): boolean[][] {71- const center = Math.floor(size / 2);7275 const dataPositions: { r: number; c: number }[] = [];7376 for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {7477 for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {75- if (!isReserved(r, c, center, size)) dataPositions.push({ r, c });78+ if (!isReserved(r, c, size)) dataPositions.push({ r, c });7679 }7780 }7881 const rng = mulberry32(seed);@@ -87,22 +90,26 @@8790 return out;8891 }8992 90-describe("Mayan format", () => {93+describe("Mayan v2 format", () => {9194 it("constants are coherent", () => {9295 expect(MAYAN_MAGIC).toBe(0x4d);93- expect(MAYAN_VERSION).toBe(0x01);96+ expect(MAYAN_V2).toBe(0x02);9497 expect(RUNE_ZONE).toBe(27);9598 expect(QUIET_ZONE).toBe(2);99+ expect(ANCHOR_FOOTPRINT).toBe(9);100+ expect(RS_DATA).toBe(204);101+ expect(RS_BLOCK).toBe(255);102+ expect(HEADER_BYTES).toBe(6);96103 });97104 98105 it("calculateSymbolSize returns >=64 for tiny payloads", () => {99- expect(calculateSymbolSize(0)).toBe(64);106+ expect(calculateSymbolSize(0)).toBeGreaterThanOrEqual(64);100107 expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);101108 });102109 103110 it("calculateSymbolSize grows with payload", () => {104111 const small = calculateSymbolSize(100);105- const big = calculateSymbolSize(5000);112+ const big = calculateSymbolSize(50_000);106113 expect(big).toBeGreaterThan(small);107114 });108115 @@ -110,9 +117,48 @@110117 const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);111118 expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);112119 });120+121+ it("isReserved marks anchor footprints as reserved", () => {122+ const size = 128;123+ // Top-left anchor footprint: rows/cols [QUIET_ZONE..QUIET_ZONE+8]124+ expect(isReserved(QUIET_ZONE, QUIET_ZONE, size)).toBe(true);125+ expect(isReserved(QUIET_ZONE + 5, QUIET_ZONE + 5, size)).toBe(true);126+ expect(isReserved(QUIET_ZONE + 8, QUIET_ZONE + 8, size)).toBe(true);127+ // Just outside the anchor footprint should be data area (assuming the128+ // timing strip and rune zone don't claim it)129+ const safeR = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;130+ const safeC = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;131+ // The corner (anchorEnd, anchorEnd) lies at the timing-strip intersection,132+ // so step one further in133+ expect(isReserved(safeR, safeC, size)).toBe(false);134+ });135+136+ it("isReserved marks size-bit row positions as reserved", () => {137+ const size = 128;138+ const row = QUIET_ZONE;139+ const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;140+ for (let i = 0; i < 16; i++) {141+ expect(isReserved(row, colStart + i, size)).toBe(true);142+ }143+ });144+145+ it("encodes the symbol size bits round-trippably", () => {146+ const payload = randomPayload(50);147+ const { matrix, size } = encodeMayanMatrix(payload);148+ const row = QUIET_ZONE;149+ const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;150+ let v = 0;151+ for (let i = 0; i < 16; i++) {152+ if (matrix[row][colStart + i]) v |= 1 << (15 - i);153+ }154+ expect(v).toBe(size);155+ });156+157+ // encodeMayan() requires DOM canvas (only available in browser/jsdom).158+ // Paper-size tagging is exercised end-to-end via the Export view.113159 });114160 115-describe("Mayan round-trip", () => {161+describe("Mayan v2 round-trip (matrix)", () => {116162 it("round-trips a 5-byte payload", () => {117163 const payload = new Uint8Array([1, 2, 3, 4, 5]);118164 const { matrix, size } = encodeMayanMatrix(payload);@@ -145,6 +191,14 @@145191 expect(Array.from(decoded!)).toEqual(Array.from(payload));146192 });147193 194+ it("round-trips a 20000-byte payload (realistic vault size)", () => {195+ const payload = randomPayload(20000);196+ const { matrix, size } = encodeMayanMatrix(payload);197+ const decoded = decodeMatrix(matrix, size);198+ expect(decoded).not.toBeNull();199+ expect(Array.from(decoded!)).toEqual(Array.from(payload));200+ });201+148202 it("rejects a matrix with no Mayan magic byte", () => {149203 const fake: boolean[][] = Array.from({ length: 64 }, () =>150204 new Array<boolean>(64).fill(false),@@ -154,8 +208,8 @@154208 });155209 });156210 157-describe("Mayan image pipeline", () => {158- it("decodes a 5-byte payload via the full image pipeline at modulePx=3", () => {211+describe("Mayan v2 image pipeline", () => {212+ it("decodes a 5-byte payload at modulePx=3", () => {159213 const payload = new Uint8Array([1, 2, 3, 4, 5]);160214 const { matrix, size } = encodeMayanMatrix(payload);161215 const imgData = matrixToImageData(matrix, size, 3);@@ -164,7 +218,7 @@164218 expect(Array.from(decoded!)).toEqual(Array.from(payload));165219 });166220 167- it("decodes a 100-byte payload via the full image pipeline at modulePx=3", () => {221+ it("decodes a 100-byte payload at modulePx=3", () => {168222 const payload = randomPayload(100);169223 const { matrix, size } = encodeMayanMatrix(payload);170224 const imgData = matrixToImageData(matrix, size, 3);@@ -173,7 +227,7 @@173227 expect(Array.from(decoded!)).toEqual(Array.from(payload));174228 });175229 176- it("decodes a 100-byte payload via the full image pipeline at modulePx=4", () => {230+ it("decodes a 100-byte payload at modulePx=4", () => {177231 const payload = randomPayload(100);178232 const { matrix, size } = encodeMayanMatrix(payload);179233 const imgData = matrixToImageData(matrix, size, 4);@@ -182,46 +236,46 @@182236 expect(Array.from(decoded!)).toEqual(Array.from(payload));183237 });184238 185- it("decodes a 5000-byte payload via the full image pipeline at modulePx=4", () => {186- const payload = randomPayload(5000);239+ it("decodes a 500-byte payload at modulePx=3", () => {240+ const payload = randomPayload(500);187241 const { matrix, size } = encodeMayanMatrix(payload);188- const imgData = matrixToImageData(matrix, size, 4);242+ const imgData = matrixToImageData(matrix, size, 3);189243 const decoded = decodeMayan(imgData);190244 expect(decoded).not.toBeNull();191245 expect(Array.from(decoded!)).toEqual(Array.from(payload));192246 });193247 194- it("decodes a near-max payload (20000 bytes) at modulePx=4 — the realistic backup case", () => {195- const payload = randomPayload(20000);248+ it("decodes a 5000-byte payload at modulePx=4", () => {249+ const payload = randomPayload(5000);196250 const { matrix, size } = encodeMayanMatrix(payload);197- console.log(`20000-byte payload → size=${size}`);198251 const imgData = matrixToImageData(matrix, size, 4);199252 const decoded = decodeMayan(imgData);200253 expect(decoded).not.toBeNull();201254 expect(Array.from(decoded!)).toEqual(Array.from(payload));202255 });256+});203257 204- it("decodes a 500-byte payload via the full image pipeline at modulePx=3", () => {205- const payload = randomPayload(500);258+describe("Mayan v2 RS recovery", () => {259+ it("recovers from 0.5% random bit flips in the data area", () => {260+ const payload = randomPayload(50);206261 const { matrix, size } = encodeMayanMatrix(payload);207- const imgData = matrixToImageData(matrix, size, 3);208- const decoded = decodeMayan(imgData);262+ const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);263+ const decoded = decodeMatrix(corrupted, size);209264 expect(decoded).not.toBeNull();210265 expect(Array.from(decoded!)).toEqual(Array.from(payload));211266 });212-});213267 214-describe("Mayan RS recovery", () => {215- // Recoverable threshold for RS(255,178): up to 38 byte errors per block.216- // Each byte holds 8 bits, so the bit-flip equivalent is bounded by the217- // byte coverage P(byte has ≥1 flip) = 1 - (1-p)^8. For 30% byte errors218- // (within RS limit), p ≈ 0.043 — so 1% bit flips is comfortably inside.219- it("recovers from 0.5% random bit flips in data area", () => {220- const payload = randomPayload(50);268+ it("recovers from 1% random bit flips on a multi-block payload", () => {269+ // RS(255,204) corrects up to 25 byte errors per block. With interleaving270+ // a 1% bit flip rate translates to ~7 byte errors per block — comfortably271+ // within capacity. Higher rates approach the per-block limit and are272+ // exercised in stress benchmarks separately.273+ const payload = randomPayload(500);221274 const { matrix, size } = encodeMayanMatrix(payload);222- const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);275+ const corrupted = flipRandomDataBits(matrix, size, 0.01, 7);223276 const decoded = decodeMatrix(corrupted, size);224277 expect(decoded).not.toBeNull();225278 expect(Array.from(decoded!)).toEqual(Array.from(payload));226279 });227280 });281+src/crypto/mayan.ts+261 −454
@@ -1,77 +1,186 @@11 /**2- * Mayan — Peach's proprietary vault backup matrix code.2+ * Mayan v2 — Peach's proprietary vault backup matrix code.33 *4- * Format: a square dark/light module grid with the Othalan rune ᛟ as the5- * finder pattern at center. Vault data radiates outward in a row-by-row6- * grid skipping the reserved center zone and reference grid positions.4+ * Format: a square dark/light module grid with three 7×7 corner anchor5+ * squares (TL, TR, BL — BR absent for orientation), a 16-bit symbol-size6+ * marker adjacent to the TL anchor, two L-shaped timing strips along the7+ * top and left active edges, and the Othalan rune ᛟ at the dead center.8+ * Vault data radiates outward in a row-by-row grid skipping all reserved9+ * positions.710 *8- * Per peach_mayan_spec.md:9- * - Magic byte 0x4D ('M'), version 0x01, payload length u16 LE10- * - 30% Reed-Solomon ECC (block-based RS(255,178), see ./rs.ts)11- * - 23×23 rune + 2-module padding = 27×27 reserved center12- * - 2-module quiet zone border13- * - Reference grid every 32 modules for symbols >128 modules11+ * Pipeline (caller responsibility): plaintext → zstd compress → AES-256-GCM12+ * encrypt → encodeMayan(). Compress-before-encrypt is non-negotiable; the13+ * Peach payload format already does this internally so callers passing14+ * `buildPayload()` output here are compliant.1415 *15- * Spec §11 reserves the rune as non-negotiable: every Mayan code has the16- * Othalan ᛟ at its center. Do not replace with QR/Aztec/etc.16+ * Header: 6 bytes — magic 0x4D, version 0x02, payload length u32 LE.17+ *18+ * Reed-Solomon: RS(255,204) — 204 data bytes + 51 parity per block, 20%19+ * overhead. Blocks are interleaved (byte j of block i → position20+ * i + j*numBlocks) so a localized burst of damage spreads across blocks21+ * instead of crushing one.22+ *23+ * The rune is the soul of the format. The anchors are scaffolding that24+ * makes machine decoding reliable — the decoder uses them for homography,25+ * not the rune. See peach_mayan_v2_spec.md for the full rationale.1726 */1827 19-import {20- reedSolomonEncode,21- reedSolomonDecode,22- RS_BLOCK_DATA,23- RS_BLOCK_TOTAL,24-} from "./rs";28+import { rsEncodeBlock } from "./rs";2529 2630 export const MAYAN_MAGIC = 0x4d;27-export const MAYAN_VERSION = 0x01;28-export const RUNE_ZONE = 27; // reserved center: 23×23 rune + 2-module padding each side31+export const MAYAN_V2 = 0x02;32+export const RUNE_ZONE = 27;2933 export const QUIET_ZONE = 2;30-export const ECC_RATE = 0.30;31-export const MAX_PAYLOAD_BYTES = 65535;34+export const ANCHOR_SIZE = 7;35+export const ANCHOR_FOOTPRINT = 9; // 7×7 anchor + 1-module light ring on every side36+export const RS_DATA = 204;37+export const RS_PARITY = 51;38+export const RS_BLOCK = RS_DATA + RS_PARITY; // 25539+export const HEADER_BYTES = 6;40+export const MAX_SYMBOL_SIZE = 1024;41+export const MIN_SYMBOL_SIZE = 64;42+// Comfortable cap below the 1024-module ceiling. A 1024×1024 symbol holds43+// ~130 KB of raw data bits; after 20% RS overhead + header that leaves44+// ~100 KB of payload room with margin to spare.45+export const MAX_PAYLOAD_BYTES = 100000;3246 3347 export interface MayanResult {3448 canvas: HTMLCanvasElement;3549 symbolSize: number;3650 payloadBytes: number;3751 printable: boolean;52+ paperSize: "A4" | "A3" | "A3-required";3853 }3954 4055 // ── SIZE CALCULATION ─────────────────────────────────────────────────────────4156 4257 export function calculateSymbolSize(payloadBytes: number): number {43- const headerBytes = 4;44- const totalData = payloadBytes + headerBytes;45- // Use the actual RS-encoded byte count (block-padded) instead of the46- // theoretical ECC_RATE estimate. The RS codec rounds up to whole 178-byte47- // data blocks and emits 255 bytes per block, so smaller payloads have48- // higher effective overhead than the 30% nominal rate.49- const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA));50- const dataBits = blocks * RS_BLOCK_TOTAL * 8;58+ const totalData = payloadBytes + HEADER_BYTES;59+ const numBlocks = Math.max(1, Math.ceil(totalData / RS_DATA));60+ const dataBits = numBlocks * RS_BLOCK * 8;5161 52- 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;62+ for (let size = MIN_SYMBOL_SIZE; size <= MAX_SYMBOL_SIZE; size++) {63+ if (usableModules(size) >= dataBits) return size;6164 }62- return 600;65+ return MAX_SYMBOL_SIZE;6366 }6467 65-function approxReferenceGridCount(size: number): number {66- // Reference grid lines at every 32nd row and column (excluding quiet zone).67- // Conservative overestimate: full lines, ignoring intersections with center.68- const lines = Math.floor((size - 2 * QUIET_ZONE) / 32);69- const linePos = lines * (size - 2 * QUIET_ZONE);70- return linePos * 2;68+/** Module budget for data placement at a given symbol size. Subtracts every69+ * reserved region exactly once. */70+export function usableModules(size: number): number {71+ let reserved = 0;72+ // Quiet zone: outer 2-module border73+ reserved += quietModuleCount(size);74+ // Three anchors (9×9 footprint each)75+ reserved += 3 * ANCHOR_FOOTPRINT * ANCHOR_FOOTPRINT;76+ // Size bits row (16 modules adjacent to TL anchor, on row=QUIET_ZONE)77+ reserved += 16;78+ // Timing strips (top + left), excluding overlap with rune zone79+ reserved += timingModuleCount(size);80+ // Rune zone (27×27 centered). Subtract any overlap already counted in81+ // timing strips so we don't double-count the rune-zone-vs-timing crossing.82+ reserved += runeModuleCount(size) - runeTimingOverlap(size);83+ return size * size - reserved;7184 }7285 73-// ── RUNE DRAWING ─────────────────────────────────────────────────────────────86+function quietModuleCount(size: number): number {87+ // Four QUIET_ZONE-thick strips minus the corner overlaps88+ return 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;89+}7490 91+function timingModuleCount(size: number): number {92+ // Top strip: row q+ANCHOR_FOOTPRINT, cols [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)93+ // Left strip: col q+ANCHOR_FOOTPRINT, rows [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)94+ // Strips don't overlap (one starts at the position the other occupies one off).95+ const span = size - 2 * (QUIET_ZONE + ANCHOR_FOOTPRINT);96+ return Math.max(0, 2 * span);97+}98+99+function runeModuleCount(_size: number): number {100+ return RUNE_ZONE * RUNE_ZONE;101+}102+103+function runeTimingOverlap(size: number): number {104+ // The top timing row crosses the rune zone in 27 modules iff the row index105+ // q+ANCHOR_FOOTPRINT falls within the rune row range. Same for the left106+ // timing column. Compute exactly.107+ const center = Math.floor(size / 2);108+ const half = Math.floor(RUNE_ZONE / 2);109+ const tRow = QUIET_ZONE + ANCHOR_FOOTPRINT;110+ const tCol = QUIET_ZONE + ANCHOR_FOOTPRINT;111+ const rowOverlaps = tRow >= center - half && tRow <= center + half;112+ const colOverlaps = tCol >= center - half && tCol <= center + half;113+ return (rowOverlaps ? RUNE_ZONE : 0) + (colOverlaps ? RUNE_ZONE : 0);114+}115+116+// ── ANCHOR / TIMING / SIZE BIT PLACEMENT ─────────────────────────────────────117+118+function drawAnchor(matrix: boolean[][], r0: number, c0: number): void {119+ // Clear 9×9 light ring120+ for (let dr = 0; dr < ANCHOR_FOOTPRINT; dr++) {121+ for (let dc = 0; dc < ANCHOR_FOOTPRINT; dc++) {122+ matrix[r0 + dr][c0 + dc] = false;123+ }124+ }125+ // 7×7 anchor pattern at +1, +1 inside the light ring.126+ // Outer ring dark, gap light, 3×3 dark center.127+ const PAT = [128+ [1, 1, 1, 1, 1, 1, 1],129+ [1, 0, 0, 0, 0, 0, 1],130+ [1, 0, 1, 1, 1, 0, 1],131+ [1, 0, 1, 1, 1, 0, 1],132+ [1, 0, 1, 1, 1, 0, 1],133+ [1, 0, 0, 0, 0, 0, 1],134+ [1, 1, 1, 1, 1, 1, 1],135+ ];136+ for (let dr = 0; dr < ANCHOR_SIZE; dr++) {137+ for (let dc = 0; dc < ANCHOR_SIZE; dc++) {138+ matrix[r0 + 1 + dr][c0 + 1 + dc] = PAT[dr][dc] === 1;139+ }140+ }141+}142+143+function placeAnchors(matrix: boolean[][], size: number): void {144+ const q = QUIET_ZONE;145+ drawAnchor(matrix, q, q);146+ drawAnchor(matrix, q, size - q - ANCHOR_FOOTPRINT);147+ drawAnchor(matrix, size - q - ANCHOR_FOOTPRINT, q);148+ // Bottom-right intentionally absent — orientation marker.149+}150+151+function writeSizeBits(matrix: boolean[][], size: number): void {152+ // 16 bits, MSB first, on row=QUIET_ZONE, starting one column to the right153+ // of the TL anchor footprint. Lives inside the quiet-zone-adjacent strip154+ // but outside the anchor light ring.155+ const row = QUIET_ZONE;156+ const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;157+ for (let i = 0; i < 16; i++) {158+ matrix[row][colStart + i] = ((size >> (15 - i)) & 1) === 1;159+ }160+}161+162+function sizeBitColumns(): { row: number; colStart: number; colEnd: number } {163+ const row = QUIET_ZONE;164+ const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;165+ return { row, colStart, colEnd: colStart + 16 };166+}167+168+function placeTimingStrips(matrix: boolean[][], size: number): void {169+ const q = QUIET_ZONE;170+ const start = q + ANCHOR_FOOTPRINT;171+ const end = size - q - ANCHOR_FOOTPRINT;172+ // Top timing strip — alternating dark/light starting dark on even module173+ for (let c = start; c < end; c++) {174+ matrix[start][c] = (c & 1) === 0;175+ }176+ // Left timing strip177+ for (let r = start; r < end; r++) {178+ matrix[r][start] = (r & 1) === 0;179+ }180+}181+182+// ── RUNE DRAWING (unchanged from v1) ─────────────────────────────────────────183+75184 function fillRect(76185 matrix: boolean[][],77186 r: number,@@ -100,8 +209,6 @@100209 c2: number,101210 stroke: number,102211 ): void {103- // Stroke diagonal from (r1,c1) to (r2,c2), origin at (r0,c0), as a stair104- // of stroke×stroke blocks. The half-offset centers the stroke on the line.105212 const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1));106213 const half = Math.floor(stroke / 2);107214 for (let i = 0; i <= steps; i++) {@@ -116,93 +223,119 @@116223 centerRow: number,117224 centerCol: number,118225 ): void {119- // 23×23 region centered on (centerRow, centerCol). Origin = top-left.120226 const r0 = centerRow - 11;121227 const c0 = centerCol - 11;122228 const STROKE = 3;123-124- // Diamond corners (within the 23×23 region)125229 const TOP = [1, 11];126230 const RIGHT = [10, 21];127231 const BOT = [19, 11];128232 const LEFT = [10, 1];129-130233 drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE);131234 drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE);132235 drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE);133236 drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE);134-135- // Two legs descending from below the diamond136237 drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE);137238 drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE);138-139239 // Orientation marker: 3×3 dark block at top-right of rune zone140240 fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE);141241 }142242 143243 // ── RESERVED-POSITION TEST ───────────────────────────────────────────────────144244 145-export 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- // 27×27 center zone245+export function isReserved(r: number, c: number, size: number): boolean {246+ const q = QUIET_ZONE;247+ // Quiet zone248+ if (r < q || r >= size - q || c < q || c >= size - q) return true;249+250+ // Corner anchors (9×9 footprint)251+ const aEnd = q + ANCHOR_FOOTPRINT;252+ if (r < aEnd && c < aEnd) return true;253+ if (r < aEnd && c >= size - aEnd) return true;254+ if (r >= size - aEnd && c < aEnd) return true;255+256+ // Size bits (row=q, cols colStart..colEnd-1)257+ const sb = sizeBitColumns();258+ if (r === sb.row && c >= sb.colStart && c < sb.colEnd) return true;259+260+ // Timing strips261+ const tStart = aEnd;262+ const tEnd = size - aEnd;263+ if (r === tStart && c >= tStart && c < tEnd) return true;264+ if (c === tStart && r >= tStart && r < tEnd) return true;265+266+ // Rune zone (27×27 centered)267+ const center = Math.floor(size / 2);154268 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- // Reference grid162- 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- }269+ if (r >= center - half && r <= center + half &&270+ c >= center - half && c <= center + half) return true;271+172272 return false;173273 }174274 175-// ── MATRIX BUILD ─────────────────────────────────────────────────────────────275+// ── ENCODE PIPELINE ──────────────────────────────────────────────────────────176276 177-export function encodeMayan(277+function buildHeaderAndPayload(encryptedPayload: Uint8Array): Uint8Array {278+ const header = new Uint8Array(HEADER_BYTES);279+ header[0] = MAYAN_MAGIC;280+ header[1] = MAYAN_V2;281+ const len = encryptedPayload.length;282+ header[2] = len & 0xff;283+ header[3] = (len >> 8) & 0xff;284+ header[4] = (len >> 16) & 0xff;285+ header[5] = (len >> 24) & 0xff;286+ const out = new Uint8Array(HEADER_BYTES + encryptedPayload.length);287+ out.set(header);288+ out.set(encryptedPayload, HEADER_BYTES);289+ return out;290+}291+292+/** Reed-Solomon encode + interleave. Splits `data` into RS_DATA-byte blocks293+ * (zero-padded last block), encodes each to 255 bytes, then interleaves so294+ * byte j of block i lands at position i + j*numBlocks. */295+function rsEncodeAndInterleave(data: Uint8Array): { interleaved: Uint8Array; numBlocks: number } {296+ const numBlocks = Math.ceil(data.length / RS_DATA);297+ const blocks: Uint8Array[] = [];298+ for (let i = 0; i < numBlocks; i++) {299+ const slice = data.subarray(i * RS_DATA, (i + 1) * RS_DATA);300+ const padded = slice.length === RS_DATA301+ ? slice302+ : (() => { const p = new Uint8Array(RS_DATA); p.set(slice); return p; })();303+ blocks.push(rsEncodeBlock(padded, RS_PARITY));304+ }305+ const interleaved = new Uint8Array(numBlocks * RS_BLOCK);306+ for (let i = 0; i < numBlocks; i++) {307+ for (let j = 0; j < RS_BLOCK; j++) {308+ interleaved[i + j * numBlocks] = blocks[i][j];309+ }310+ }311+ return { interleaved, numBlocks };312+}313+314+/** Build the 2-D module matrix without rendering to canvas. Used by315+ * round-trip tests to skip the binarization step. */316+export function encodeMayanMatrix(178317 encryptedPayload: Uint8Array,179- modulePx = 3,180-): MayanResult {318+): { matrix: boolean[][]; size: number } {181319 if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {182320 throw new Error(183321 `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,184322 );185323 }186324 const size = calculateSymbolSize(encryptedPayload.length);325+ if (size > MAX_SYMBOL_SIZE) {326+ throw new Error(`Mayan: symbol size ${size} exceeds max ${MAX_SYMBOL_SIZE}`);327+ }187328 const center = Math.floor(size / 2);329+ const fullData = buildHeaderAndPayload(encryptedPayload);330+ const { interleaved } = rsEncodeAndInterleave(fullData);188331 189- // Mode header (4 bytes): magic | version | length-lo | length-hi190- 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;195-196- const fullData = new Uint8Array(header.length + encryptedPayload.length);197- fullData.set(header);198- fullData.set(encryptedPayload, header.length);199-200- const withECC = reedSolomonEncode(fullData);201-202332 // Bits, MSB-first per byte203- const bits: boolean[] = [];204- for (const byte of withECC) {205- for (let i = 7; i >= 0; i--) bits.push(((byte >> i) & 1) === 1);333+ const bits: boolean[] = new Array(interleaved.length * 8);334+ for (let bi = 0; bi < interleaved.length; bi++) {335+ const byte = interleaved[bi];336+ for (let i = 0; i < 8; i++) {337+ bits[bi * 8 + i] = ((byte >> (7 - i)) & 1) === 1;338+ }206339 }207340 208341 // Build matrix@@ -211,35 +344,41 @@211344 () => new Array<boolean>(size).fill(false),212345 );213346 214- // Reference grid (alternating dark/light in fixed phase)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- }224-347+ // Structural elements first; rune drawn last so its pixels win the348+ // crossing region with the timing strips.349+ placeAnchors(matrix, size);350+ writeSizeBits(matrix, size);351+ placeTimingStrips(matrix, size);225352 drawOthalan(matrix, center, center);226353 227- // Place data bits354+ // Place data bits in row-major scan, skipping reserved positions228355 let bitIdx = 0;229356 for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {230357 for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {231- if (isReserved(r, c, center, size)) continue;358+ if (isReserved(r, c, size)) continue;232359 matrix[r][c] = bits[bitIdx++];233360 }234361 }235362 363+ return { matrix, size };364+}365+366+export function encodeMayan(367+ encryptedPayload: Uint8Array,368+ modulePx = 3,369+): MayanResult {370+ const { matrix, size } = encodeMayanMatrix(encryptedPayload);236371 const canvas = renderMatrix(matrix, size, modulePx);237- const printable = size * 0.6 <= 210; // fits on A4 width at 0.6mm/module372+ const paperSize: MayanResult["paperSize"] =373+ size <= 350 ? "A4" :374+ size <= 500 ? "A3" :375+ "A3-required";238376 return {239377 canvas,240378 symbolSize: size,241379 payloadBytes: encryptedPayload.length,242- printable,380+ printable: paperSize === "A4",381+ paperSize,243382 };244383 }245384 @@ -266,341 +405,9 @@266405 return canvas;267406 }268407 269-/** Expose the matrix in addition to the canvas — for round-trip tests that270- * skip the binarization step. */271-export 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-}408+// ── DECODE RE-EXPORTS ────────────────────────────────────────────────────────409+// The decoder lives in mayan_decode.ts so the encoder can be imported without410+// pulling in the homography / image-processing code. Callers that want the411+// historical `import { decodeMayan } from "./mayan"` ergonomics get it here.317412 318-// ── DECODER ──────────────────────────────────────────────────────────────────319-320-/** Decode a Mayan code from raw image data.321- * Returns null when the image isn't a Mayan code (no rune found, magic byte322- * mismatch, or unknown version) or when RS correction fails. */323-export function decodeMayan(imageData: ImageData): Uint8Array | null {324- const binary = adaptiveBinarize(imageData);325- const W = imageData.width;326- const H = imageData.height;327-328- const rune = findOthalan(binary, W, H);329- if (!rune) return null;330-331- const modulePx = estimateModuleSize(binary, rune, W);332- if (!modulePx || modulePx < 1) return null;333-334- const symbolModules = Math.round(Math.min(W, H) / modulePx);335- if (symbolModules < 64) return null;336-337- 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- );345-346- return decodeMatrix(matrix, symbolModules);347-}348-349-/** Decode a Mayan code from a sampled module matrix (for tests). */350-export function decodeMatrix(351- matrix: boolean[][],352- symbolModules: number,353-): Uint8Array | null {354- const center = Math.floor(symbolModules / 2);355-356- // Read the first 32 data bits to recover the mode header357- 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;364-365- // Read all data bits — round up to whole RS(255,178) blocks366- 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));372-373- let decoded: Uint8Array;374- try {375- decoded = reedSolomonDecode(rawBytes, totalDataBytes);376- } catch {377- return null;378- }379- return decoded.slice(4, 4 + payloadLength);380-}381-382-// ── BINARIZATION ─────────────────────────────────────────────────────────────383-384-function 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- // Integral image of grayscale for O(1) box mean per pixel.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-}422-423-// ── RUNE LOCATION ───────────────────────────────────────────────────────────424-425-function findOthalan(426- binary: boolean[][],427- W: number,428- H: number,429-): { cx: number; cy: number; size: number } | null {430- // Scan horizontally for a dark-light-dark pattern with the diamond's431- // expected proportions: each side ~4-5 modules wide, light interior ≥1.5×.432- //433- // Sliding-window: each dark blob is considered as a potential darkStart.434- // We peek light + dark2 ahead but only consume dark1 — so on a failed435- // match the next iteration's darkStart slides forward to dark2's position436- // (instead of leaping past it). This is critical because the data area437- // immediately left of the rune can contain dark blobs that would otherwise438- // eat the rune's left vertex on a non-sliding scan.439- const candidates: { cx: number; cy: number; size: number }[] = [];440-441- const yStart = Math.floor(H * 0.2);442- const yEnd = Math.floor(H * 0.8);443- for (let y = yStart; y < yEnd; y += 2) {444- let x = 0;445- while (x < W) {446- while (x < W && !binary[y][x]) x++;447- if (x >= W) break;448- const darkStart = x;449- while (x < W && binary[y][x]) x++;450- const darkLen = x - darkStart;451-452- // Peek ahead: light run, then next dark run. Don't consume them — the453- // next loop iteration will re-skip the light and treat dark2 as dark1.454- let p = x;455- while (p < W && !binary[y][p]) p++;456- const lightLen = p - x;457- const dark2Start = p;458- while (p < W && binary[y][p]) p++;459- const dark2Len = p - dark2Start;460-461- if (462- darkLen >= 4 &&463- dark2Len >= 4 &&464- Math.abs(darkLen - dark2Len) <= darkLen * 0.3 &&465- lightLen > darkLen * 1.5 &&466- lightLen < darkLen * 12467- ) {468- const cx = Math.floor((darkStart + p) / 2);469- const size = p - darkStart;470- candidates.push({ cx, cy: y, size });471- }472- // x stays at end of dark1; loop's skip-light advances us into dark2.473- }474- }475-476- if (candidates.length < 3) return null;477-478- // The rune diamond's three widest scan rows (rune-rows 9, 10, 11) all match479- // the dark-light-dark pattern with size = 23 modules. Pick all candidates480- // tied within ~1 module of the global max, then average their cy/cx to481- // recover the diamond's geometric center robustly.482- const maxSize = candidates.reduce((m, c) => Math.max(m, c.size), 0);483- const modulePx = Math.max(1, Math.round(maxSize / 23));484- const top = candidates.filter((c) => c.size >= maxSize - modulePx);485- let sumCx = 0;486- let sumCy = 0;487- for (const c of top) { sumCx += c.cx; sumCy += c.cy; }488- const cxAvg = sumCx / top.length;489- const cyAvg = sumCy / top.length;490-491- // The averaged cy lands on the diamond's geometric vertical center492- // (rune-row 10, the LEFT/RIGHT vertex row). The code-grid center is at493- // rune-row 11 — exactly one module below — because drawOthalan offsets494- // the diamond up by one to make room for the legs. Shift down 1 module.495- return {496- cx: Math.round(cxAvg),497- cy: Math.round(cyAvg + modulePx),498- size: maxSize,499- };500-}501-502-function estimateModuleSize(503- _binary: boolean[][],504- rune: { cx: number; cy: number; size: number },505- _W: number,506-): number {507- // Rune spans ~23 modules at its widest horizontal cross-section.508- // Round to integer: module boundaries are always integer pixels.509- return Math.round(rune.size / 23);510-}511-512-function detectRotation(513- binary: boolean[][],514- rune: { cx: number; cy: number; size: number },515- modulePx: number,516-): number {517- // Orientation marker is a 3×3 dark block at (rune-row 1-3, rune-col 20-22)518- // = 10 modules right and 10 modules above center at 0°.519- const offsets: { angle: number; dr: number; dc: number }[] = [520- { angle: 0, dr: -10, dc: +10 },521- { angle: 90, dr: +10, dc: +10 },522- { angle: 180, dr: +10, dc: -10 },523- { angle: 270, dr: -10, dc: -10 },524- ];525- let bestAngle = 0;526- let bestScore = -1;527- for (const { angle, dr, dc } of offsets) {528- const pr = Math.round(rune.cy + dr * modulePx);529- const pc = Math.round(rune.cx + dc * modulePx);530- let darkCount = 0;531- for (let dr2 = -1; dr2 <= 1; dr2++) {532- for (let dc2 = -1; dc2 <= 1; dc2++) {533- const r = pr + Math.round(dr2 * modulePx);534- const c = pc + Math.round(dc2 * modulePx);535- if (r >= 0 && r < binary.length && c >= 0 && c < binary[0].length) {536- if (binary[r][c]) darkCount++;537- }538- }539- }540- if (darkCount > bestScore) {541- bestScore = darkCount;542- bestAngle = angle;543- }544- }545- return bestAngle;546-}547-548-function sampleModuleGrid(549- binary: boolean[][],550- center: { x: number; y: number },551- modulePx: number,552- symbolModules: number,553- rotation: number,554-): boolean[][] {555- const half = Math.floor(symbolModules / 2);556- const matrix: boolean[][] = Array.from(557- { length: symbolModules },558- () => new Array<boolean>(symbolModules).fill(false),559- );560- for (let r = 0; r < symbolModules; r++) {561- for (let c = 0; c < symbolModules; c++) {562- let dr = r - half;563- let dc = c - half;564- if (rotation === 90) { [dr, dc] = [dc, -dr]; }565- if (rotation === 180) { [dr, dc] = [-dr, -dc]; }566- if (rotation === 270) { [dr, dc] = [-dc, dr]; }567- const px = Math.round(center.x + dc * modulePx);568- const py = Math.round(center.y + dr * modulePx);569- if (570- py >= 0 &&571- py < binary.length &&572- px >= 0 &&573- px < binary[0].length574- ) matrix[r][c] = binary[py][px];575- }576- }577- return matrix;578-}579-580-function readDataBits(581- matrix: boolean[][],582- center: number,583- size: number,584- count: number,585-): boolean[] {586- const bits: boolean[] = [];587- for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {588- for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {589- if (isReserved(r, c, center, size)) continue;590- bits.push(matrix[r][c]);591- }592- }593- return bits;594-}595-596-function bitsToBytes(bits: boolean[]): number[] {597- const bytes: number[] = [];598- for (let i = 0; i < bits.length; i += 8) {599- let byte = 0;600- for (let j = 0; j < 8 && i + j < bits.length; j++) {601- if (bits[i + j]) byte |= 1 << (7 - j);602- }603- bytes.push(byte);604- }605- return bytes;606-}413+export { decodeMayan, decodeMatrix } from "./mayan_decode";src/crypto/mayan_decode.ts+659 −0
@@ -0,0 +1,659 @@1+/**2+ * Mayan v2 decoder — anchor-finder + homography pipeline.3+ *4+ * Pipeline:5+ * binarize → find 3 corner anchors via 1:1:3:1:1 run-length pattern →6+ * classify TL/TR/BL by quadrant → compute DLT homography (pixel↔module)7+ * from 4 control points (TL, TR, BL, extrapolated BR) → sample grid →8+ * read 16-bit size marker → re-sample at exact size if different →9+ * probe `numBlocks` from the interleaved header → RS-decode each block →10+ * strip header, return encrypted payload.11+ */12+13+import { rsDecodeBlock } from "./rs";14+import {15+ MAYAN_MAGIC,16+ MAYAN_V2,17+ RS_DATA,18+ RS_PARITY,19+ RS_BLOCK,20+ HEADER_BYTES,21+ QUIET_ZONE,22+ ANCHOR_FOOTPRINT,23+ MIN_SYMBOL_SIZE,24+ MAX_SYMBOL_SIZE,25+ isReserved,26+} from "./mayan";27+28+// ── PUBLIC ENTRY POINTS ──────────────────────────────────────────────────────29+30+/** Decode a Mayan code (v1 or v2) from raw image data. Returns the31+ * encrypted-and-compressed payload byte stream — caller is responsible32+ * for AES-GCM decrypt and zstd decompress (or, in Peach, parsePayload).33+ *34+ * Returns null when no Mayan code is found, magic byte mismatches, or35+ * Reed-Solomon correction fails. */36+export function decodeMayan(imageData: ImageData): Uint8Array | null {37+ const binary = adaptiveBinarize(imageData);38+ const W = imageData.width;39+ const H = imageData.height;40+41+ const anchors = findAnchors(binary, W, H);42+ if (anchors.length < 3) return null;43+44+ const orient = classifyAnchors(anchors);45+ if (!orient) return null;46+ const { TL, TR, BL } = orient;47+ const modulePx = orient.modulePx;48+ if (modulePx < 1) return null;49+50+ // Estimate symbol size from anchor spacing. Anchor inner-3×3 centers are51+ // at module (q+4, q+4) for TL — so the distance TL→TR in modules is52+ // (size - 2q - 9) and we recover size = round(distance/modulePx) + 2q + 9.53+ const distH = Math.hypot(TR.x - TL.x, TR.y - TL.y);54+ const distV = Math.hypot(BL.x - TL.x, BL.y - TL.y);55+ const span = (distH + distV) / 2 / modulePx;56+ let estSize = Math.round(span) + 2 * QUIET_ZONE + ANCHOR_FOOTPRINT;57+ estSize = clamp(estSize, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE);58+59+ const matrix = sampleAndReadExact(binary, TL, TR, BL, estSize);60+ if (!matrix) return null;61+62+ return decodeMatrix(matrix.matrix, matrix.size);63+}64+65+/** Decode a Mayan v2 module matrix directly. Used by tests that bypass66+ * binarization and homography. */67+export function decodeMatrix(68+ matrix: boolean[][],69+ symbolSize: number,70+): Uint8Array | null {71+ // Read every available data bit. The number of blocks is unknown until72+ // we parse the header, but the header is itself interleaved across73+ // blocks — so we read everything, probe `numBlocks` against the header,74+ // then RS-decode at the right blocking.75+ const allBits = readDataBits(matrix, symbolSize, Infinity);76+ const interleaved = new Uint8Array(bitsToBytes(allBits));77+78+ if (interleaved.length === 0) return null;79+ const maxK = Math.floor(interleaved.length / RS_BLOCK);80+ // Magic byte sits at interleaved[0] regardless of numBlocks (block 0,81+ // byte 0 → position 0). Skip the fast probe if it's already wrong; the82+ // slow probe still gets a chance to RS-correct a flipped magic byte.83+ // Fast probe first: for each candidate k, the header bytes live at84+ // positions [0, k, 2k, 3k, 4k, 5k]. Check version + length consistency.85+ for (let k = 1; interleaved[0] === MAYAN_MAGIC && k <= maxK; k++) {86+ if (5 * k >= interleaved.length) break;87+ const version = interleaved[k];88+ if (version !== MAYAN_V2) continue;89+ const length = interleaved[2 * k]90+ | (interleaved[3 * k] << 8)91+ | (interleaved[4 * k] << 16)92+ | (interleaved[5 * k] << 24);93+ if (length < 0 || length > 100_000_000) continue;94+ if (Math.ceil((length + HEADER_BYTES) / RS_DATA) !== k) continue;95+ const decoded = rsDecodeAtBlocks(interleaved, k);96+ if (!decoded) continue;97+ // Re-validate header from the corrected stream (in case the fast98+ // probe accepted on un-corrected bytes that happened to be valid).99+ if (decoded[0] !== MAYAN_MAGIC || decoded[1] !== MAYAN_V2) continue;100+ const len2 = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);101+ if (len2 < 0 || len2 + HEADER_BYTES > decoded.length) continue;102+ return decoded.slice(HEADER_BYTES, HEADER_BYTES + len2);103+ }104+105+ // Slow probe: header may be corrupted. RS-decode block 0 at every106+ // candidate k and check the corrected header.107+ for (let k = 1; k <= maxK; k++) {108+ const decoded = rsDecodeAtBlocks(interleaved, k);109+ if (!decoded) continue;110+ if (decoded[0] !== MAYAN_MAGIC) continue;111+ if (decoded[1] !== MAYAN_V2) continue;112+ const len = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);113+ if (len < 0 || len + HEADER_BYTES > decoded.length) continue;114+ if (Math.ceil((len + HEADER_BYTES) / RS_DATA) !== k) continue;115+ return decoded.slice(HEADER_BYTES, HEADER_BYTES + len);116+ }117+118+ return null;119+}120+121+/** Deinterleave the first `numBlocks * 255` bytes and RS-decode each block.122+ * Returns the concatenated `numBlocks * 204`-byte data payload (with123+ * header still prepended), or null if any block is uncorrectable. */124+function rsDecodeAtBlocks(125+ interleaved: Uint8Array,126+ numBlocks: number,127+): Uint8Array | null {128+ if (interleaved.length < numBlocks * RS_BLOCK) return null;129+ const out = new Uint8Array(numBlocks * RS_DATA);130+ const block = new Uint8Array(RS_BLOCK);131+ for (let i = 0; i < numBlocks; i++) {132+ for (let j = 0; j < RS_BLOCK; j++) {133+ block[j] = interleaved[i + j * numBlocks];134+ }135+ const corrected = rsDecodeBlock(block, RS_PARITY);136+ if (!corrected) return null;137+ out.set(corrected, i * RS_DATA);138+ }139+ return out;140+}141+142+// ── BINARIZATION ─────────────────────────────────────────────────────────────143+144+function adaptiveBinarize(imageData: ImageData): boolean[][] {145+ const { data, width, height } = imageData;146+ const out: boolean[][] = Array.from(147+ { length: height },148+ () => new Array<boolean>(width).fill(false),149+ );150+ const R = 9;151+ const integral = new Float64Array((width + 1) * (height + 1));152+ for (let y = 0; y < height; y++) {153+ let rowSum = 0;154+ for (let x = 0; x < width; x++) {155+ const i = (y * width + x) * 4;156+ const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];157+ rowSum += gray;158+ integral[(y + 1) * (width + 1) + (x + 1)] =159+ integral[y * (width + 1) + (x + 1)] + rowSum;160+ }161+ }162+ for (let y = 0; y < height; y++) {163+ for (let x = 0; x < width; x++) {164+ const i = (y * width + x) * 4;165+ const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];166+ const x0 = Math.max(0, x - R);167+ const y0 = Math.max(0, y - R);168+ const x1 = Math.min(width - 1, x + R);169+ const y1 = Math.min(height - 1, y + R);170+ const sum =171+ integral[(y1 + 1) * (width + 1) + (x1 + 1)] -172+ integral[(y0) * (width + 1) + (x1 + 1)] -173+ integral[(y1 + 1) * (width + 1) + (x0)] +174+ integral[(y0) * (width + 1) + (x0)];175+ const count = (x1 - x0 + 1) * (y1 - y0 + 1);176+ out[y][x] = gray < sum / count - 8;177+ }178+ }179+ return out;180+}181+182+// ── ANCHOR DETECTION ─────────────────────────────────────────────────────────183+184+interface AnchorMatch {185+ cx: number;186+ cy: number;187+ modulePx: number;188+}189+190+interface RawCandidate {191+ cx: number;192+ cy: number;193+ modulePx: number;194+}195+196+/** Scan rows for the 1:1:3:1:1 light-dark-light-dark-light pattern that197+ * characterises the corner anchor (outer dark frame, light gap, inner198+ * 3-module dark block, light gap, outer dark frame). Confirm vertically199+ * at the same column, then verify the candidate's center 3×3-module block200+ * is solidly dark. Returns clustered, deduplicated anchor centers. */201+function findAnchors(binary: boolean[][], W: number, H: number): AnchorMatch[] {202+ const candidates: RawCandidate[] = [];203+204+ for (let y = 0; y < H; y += 2) {205+ scanLineForAnchor(206+ (i) => binary[y]?.[i] ?? false,207+ W,208+ (cxRel, modulePx) => candidates.push({ cx: cxRel, cy: y, modulePx }),209+ );210+ }211+212+ const confirmed: AnchorMatch[] = [];213+ for (const c of candidates) {214+ if (c.cx < 0 || c.cx >= W) continue;215+ const hits: { cy: number; modulePx: number }[] = [];216+ scanLineForAnchor(217+ (i) => binary[i]?.[c.cx] ?? false,218+ H,219+ (cyRel, modulePx) => hits.push({ cy: cyRel, modulePx }),220+ );221+ let best: { cy: number; modulePx: number } | null = null;222+ for (const h of hits) {223+ if (Math.abs(h.cy - c.cy) > c.modulePx * 5) continue;224+ if (!best || Math.abs(h.cy - c.cy) < Math.abs(best.cy - c.cy)) best = h;225+ }226+ if (!best) continue;227+ const cy = Math.round((c.cy + best.cy) / 2);228+ const modulePx = (c.modulePx + best.modulePx) / 2;229+ if (!verifyAnchorCenter(binary, c.cx, cy, modulePx, W, H)) continue;230+ confirmed.push({ cx: c.cx, cy, modulePx });231+ }232+233+ return clusterAnchors(confirmed);234+}235+236+/** Verify that the candidate's central 3×3-module block is solidly dark237+ * (≥80% of sampled pixels). The horizontal and vertical 1:1:3:1:1 scans238+ * alone match many false positives in random data — but the real anchor's239+ * central 3×3 must be a uniform dark block. This kills the false positives240+ * cheaply. */241+function verifyAnchorCenter(242+ binary: boolean[][],243+ cx: number, cy: number,244+ modulePx: number,245+ W: number, H: number,246+): boolean {247+ // Sample a 3×3 grid of points spanning the central 3 modules248+ const step = modulePx;249+ let dark = 0, total = 0;250+ for (let dr = -1; dr <= 1; dr++) {251+ for (let dc = -1; dc <= 1; dc++) {252+ const x = Math.round(cx + dc * step);253+ const y = Math.round(cy + dr * step);254+ if (x < 0 || x >= W || y < 0 || y >= H) return false;255+ total++;256+ if (binary[y][x]) dark++;257+ }258+ }259+ if (dark < total * 0.8) return false;260+261+ // And verify the immediate ring outside (at ±2 modules) has at least262+ // some light points — the real anchor has a light gap there.263+ let light = 0, ringTotal = 0;264+ for (let dr = -2; dr <= 2; dr++) {265+ for (let dc = -2; dc <= 2; dc++) {266+ if (Math.abs(dr) < 2 && Math.abs(dc) < 2) continue; // only the ring267+ const x = Math.round(cx + dc * step);268+ const y = Math.round(cy + dr * step);269+ if (x < 0 || x >= W || y < 0 || y >= H) continue;270+ ringTotal++;271+ if (!binary[y][x]) light++;272+ }273+ }274+ return ringTotal > 0 && light >= ringTotal * 0.5;275+}276+277+/** Scan a single 1-D line via a `pixel(i)` accessor for the 1:1:3:1:1278+ * pattern. Calls `onMatch(centerPosition, modulePx)` for every match. */279+function scanLineForAnchor(280+ pixel: (i: number) => boolean,281+ N: number,282+ onMatch: (center: number, modulePx: number) => void,283+): void {284+ // Sliding-window run-length scan: maintain a rolling history of the last285+ // 5 alternating runs (light, dark, light, dark, light). On each new run,286+ // evaluate the trailing 5-run window for the 1:1:3:1:1 ratio.287+ const runs: { len: number; dark: boolean; start: number }[] = [];288+ let i = 0;289+ let cur = pixel(0);290+ let runStart = 0;291+ while (i <= N) {292+ const px = i < N ? pixel(i) : !cur;293+ if (px !== cur || i === N) {294+ runs.push({ len: i - runStart, dark: cur, start: runStart });295+ runStart = i;296+ cur = px;297+ // Keep only last 5298+ if (runs.length > 5) runs.shift();299+ if (runs.length === 5) {300+ const [r0, r1, r2, r3, r4] = runs;301+ // We need light, dark, light, dark, light302+ if (!r0.dark && r1.dark && !r2.dark && r3.dark && !r4.dark) {303+ const total = r0.len + r1.len + r2.len + r3.len + r4.len;304+ const unit = total / 7;305+ if (unit >= 1 &&306+ within(r0.len, unit, 0.5) &&307+ within(r1.len, unit, 0.5) &&308+ within(r2.len, unit * 3, 0.8) &&309+ within(r3.len, unit, 0.5) &&310+ within(r4.len, unit, 0.5)) {311+ const center = r2.start + r2.len / 2;312+ onMatch(Math.round(center), unit);313+ }314+ }315+ // Also accept the case where the line starts inside a dark region:316+ // dark, light, dark, light, dark — with the middle dark being the317+ // 3-unit center block.318+ if (r0.dark && !r1.dark && r2.dark && !r3.dark && r4.dark) {319+ const total = r0.len + r1.len + r2.len + r3.len + r4.len;320+ const unit = total / 7;321+ if (unit >= 1 &&322+ within(r0.len, unit, 0.5) &&323+ within(r1.len, unit, 0.5) &&324+ within(r2.len, unit * 3, 0.8) &&325+ within(r3.len, unit, 0.5) &&326+ within(r4.len, unit, 0.5)) {327+ const center = r2.start + r2.len / 2;328+ onMatch(Math.round(center), unit);329+ }330+ }331+ }332+ }333+ i++;334+ }335+}336+337+function within(value: number, target: number, tolerance: number): boolean {338+ return Math.abs(value - target) <= target * tolerance;339+}340+341+/** Cluster nearby anchor matches into single representatives. Two matches342+ * are the same anchor if they are within ~2 modules of each other. */343+function clusterAnchors(matches: AnchorMatch[]): AnchorMatch[] {344+ const clusters: AnchorMatch[][] = [];345+ for (const m of matches) {346+ let placed = false;347+ for (const cluster of clusters) {348+ const ref = cluster[0];349+ const distSq = (m.cx - ref.cx) ** 2 + (m.cy - ref.cy) ** 2;350+ if (distSq <= (ref.modulePx * 3) ** 2) {351+ cluster.push(m);352+ placed = true;353+ break;354+ }355+ }356+ if (!placed) clusters.push([m]);357+ }358+ return clusters.map((cluster) => {359+ let sx = 0, sy = 0, sm = 0;360+ for (const c of cluster) { sx += c.cx; sy += c.cy; sm += c.modulePx; }361+ return {362+ cx: sx / cluster.length,363+ cy: sy / cluster.length,364+ modulePx: sm / cluster.length,365+ };366+ });367+}368+369+// ── ORIENTATION CLASSIFICATION ───────────────────────────────────────────────370+371+interface Orientation {372+ TL: Point;373+ TR: Point;374+ BL: Point;375+ modulePx: number;376+}377+378+interface Point { x: number; y: number }379+380+/** Pick the three anchors corresponding to the genuine corner squares.381+ *382+ * Strategy: the real anchors share the same modulePx (they're the same383+ * physical pattern photographed in the same image). False positives — the384+ * 1:1:3:1:1 run-length artefacts that random data sometimes produces —385+ * scatter across modulePx values. Cluster candidates by modulePx, keep386+ * the dominant bucket, then among that bucket pick the triplet that best387+ * matches a right-angle isoceles triangle with equal legs (TL→TR ≈ TL→BL,388+ * angle at TL ≈ 90°). */389+function classifyAnchors(anchors: AnchorMatch[]): Orientation | null {390+ if (anchors.length < 3) return null;391+392+ let best: {393+ TL: AnchorMatch; TR: AnchorMatch; BL: AnchorMatch; score: number;394+ } | null = null;395+396+ const N = anchors.length;397+ for (let i = 0; i < N; i++) {398+ for (let j = 0; j < N; j++) {399+ if (j === i) continue;400+ for (let k = 0; k < N; k++) {401+ if (k === i || k === j) continue;402+ const tl = anchors[i];403+ const a = anchors[j];404+ const b = anchors[k];405+ const va = { x: a.cx - tl.cx, y: a.cy - tl.cy };406+ const vb = { x: b.cx - tl.cx, y: b.cy - tl.cy };407+ const lenA = Math.hypot(va.x, va.y);408+ const lenB = Math.hypot(vb.x, vb.y);409+ if (lenA < 1 || lenB < 1) continue;410+ // Order: TR has positive cross product (rightward then downward in411+ // y-down image coords).412+ const cross = va.x * vb.y - va.y * vb.x;413+ if (cross <= 0) continue;414+ // Right-angle at TL415+ const cos = (va.x * vb.x + va.y * vb.y) / (lenA * lenB);416+ const angularScore = -Math.abs(cos);417+ // Equal legs418+ const legRatio = Math.min(lenA, lenB) / Math.max(lenA, lenB);419+ const lengthScore = -Math.abs(1 - legRatio);420+ // Module size consistency — real anchors share one modulePx.421+ const meanM = (tl.modulePx + a.modulePx + b.modulePx) / 3;422+ const varM = (423+ (tl.modulePx - meanM) ** 2 +424+ (a.modulePx - meanM) ** 2 +425+ (b.modulePx - meanM) ** 2426+ ) / 3;427+ const stdM = Math.sqrt(varM);428+ const moduleScore = -stdM / Math.max(meanM, 1);429+ // Triangle leg ≈ symbol pixel span. Real anchors are far apart.430+ const sizeScore = Math.log(lenA + lenB) * 0.1;431+ const score =432+ angularScore * 5 +433+ lengthScore * 5 +434+ moduleScore * 8 +435+ sizeScore;436+ if (!best || score > best.score) {437+ best = { TL: tl, TR: a, BL: b, score };438+ }439+ }440+ }441+ }442+ if (!best) return null;443+ const modulePx = (best.TL.modulePx + best.TR.modulePx + best.BL.modulePx) / 3;444+ return {445+ TL: { x: best.TL.cx, y: best.TL.cy },446+ TR: { x: best.TR.cx, y: best.TR.cy },447+ BL: { x: best.BL.cx, y: best.BL.cy },448+ modulePx,449+ };450+}451+452+// ── HOMOGRAPHY (DLT, 4 point pairs) ──────────────────────────────────────────453+454+/** Solve a 3×3 homography H mapping src points (sx, sy) to dst points455+ * (dx, dy) such that `dst = H * src` in homogeneous coords. Uses DLT with456+ * h22 fixed at 1 — sufficient for non-degenerate quadrilaterals.457+ * Returns the flat 9-element matrix [h00..h22] or null on singular input. */458+function computeHomography(src: Point[], dst: Point[]): number[] | null {459+ if (src.length !== 4 || dst.length !== 4) return null;460+ // 8 unknowns: h00,h01,h02,h10,h11,h12,h20,h21461+ // Per pair: dx = h00*sx + h01*sy + h02 - h20*sx*dx - h21*sy*dx462+ // dy = h10*sx + h11*sy + h12 - h20*sx*dy - h21*sy*dy463+ const A: number[][] = [];464+ const b: number[] = [];465+ for (let i = 0; i < 4; i++) {466+ const sx = src[i].x, sy = src[i].y;467+ const dx = dst[i].x, dy = dst[i].y;468+ A.push([sx, sy, 1, 0, 0, 0, -sx * dx, -sy * dx]);469+ b.push(dx);470+ A.push([0, 0, 0, sx, sy, 1, -sx * dy, -sy * dy]);471+ b.push(dy);472+ }473+ const x = solve8(A, b);474+ if (!x) return null;475+ return [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1];476+}477+478+/** Solve an 8×8 linear system via partial-pivot Gaussian elimination. */479+function solve8(A: number[][], b: number[]): number[] | null {480+ const n = 8;481+ const M: number[][] = A.map((row, i) => [...row, b[i]]);482+ for (let col = 0; col < n; col++) {483+ let pivot = col;484+ for (let r = col + 1; r < n; r++) {485+ if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;486+ }487+ if (Math.abs(M[pivot][col]) < 1e-12) return null;488+ [M[col], M[pivot]] = [M[pivot], M[col]];489+ for (let r = 0; r < n; r++) {490+ if (r === col) continue;491+ const factor = M[r][col] / M[col][col];492+ if (factor === 0) continue;493+ for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];494+ }495+ }496+ const x: number[] = new Array(n);497+ for (let i = 0; i < n; i++) x[i] = M[i][n] / M[i][i];498+ return x;499+}500+501+function applyHomography(H: number[], px: number, py: number): Point {502+ const w = H[6] * px + H[7] * py + H[8];503+ return {504+ x: (H[0] * px + H[1] * py + H[2]) / w,505+ y: (H[3] * px + H[4] * py + H[5]) / w,506+ };507+}508+509+function invertHomography(H: number[]): number[] | null {510+ const a = H[0], b = H[1], c = H[2];511+ const d = H[3], e = H[4], f = H[5];512+ const g = H[6], h = H[7], i = H[8];513+ const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);514+ if (Math.abs(det) < 1e-12) return null;515+ const inv = 1 / det;516+ return [517+ (e * i - f * h) * inv,518+ (c * h - b * i) * inv,519+ (b * f - c * e) * inv,520+ (f * g - d * i) * inv,521+ (a * i - c * g) * inv,522+ (c * d - a * f) * inv,523+ (d * h - e * g) * inv,524+ (b * g - a * h) * inv,525+ (a * e - b * d) * inv,526+ ];527+}528+529+// ── GRID SAMPLING ────────────────────────────────────────────────────────────530+531+/** Anchor inner-3×3-block module coordinates for a given symbol size.532+ * These are the *center* coordinates (module index + 0.5) used as the533+ * destination side of the homography. */534+function anchorModuleCenter(size: number, corner: "TL" | "TR" | "BL" | "BR"): Point {535+ const q = QUIET_ZONE;536+ // Inner 3×3 dark block centers at module (q+4, q+4) for TL etc. Add 0.5537+ // for module-center coordinates.538+ const tlx = q + 4 + 0.5;539+ const tly = q + 4 + 0.5;540+ const farx = size - q - 5 + 0.5; // size - q - ANCHOR_FOOTPRINT + 4 + 0.5541+ const fary = size - q - 5 + 0.5;542+ switch (corner) {543+ case "TL": return { x: tlx, y: tly };544+ case "TR": return { x: farx, y: tly };545+ case "BL": return { x: tlx, y: fary };546+ case "BR": return { x: farx, y: fary };547+ }548+}549+550+/** Sample the binary image at the ideal module grid for a given symbol551+ * size, using a homography fitted from the three known anchors plus an552+ * extrapolated BR. */553+function sampleGrid(554+ binary: boolean[][],555+ TL: Point, TR: Point, BL: Point,556+ size: number,557+): boolean[][] | null {558+ // BR ≈ TL + (TR - TL) + (BL - TL)559+ const BR: Point = {560+ x: TL.x + (TR.x - TL.x) + (BL.x - TL.x),561+ y: TL.y + (TR.y - TL.y) + (BL.y - TL.y),562+ };563+ // src = pixel coords of anchors; dst = module-center coords.564+ const src = [TL, TR, BL, BR];565+ const dst = [566+ anchorModuleCenter(size, "TL"),567+ anchorModuleCenter(size, "TR"),568+ anchorModuleCenter(size, "BL"),569+ anchorModuleCenter(size, "BR"),570+ ];571+ // We need module → pixel sampling; build H_inv directly by swapping src/dst.572+ const Hinv = computeHomography(dst, src);573+ if (!Hinv) return null;574+575+ const matrix: boolean[][] = Array.from(576+ { length: size },577+ () => new Array<boolean>(size).fill(false),578+ );579+ const H = binary.length;580+ const W = binary[0]?.length ?? 0;581+ for (let r = 0; r < size; r++) {582+ for (let c = 0; c < size; c++) {583+ const p = applyHomography(Hinv, c + 0.5, r + 0.5);584+ const ix = Math.round(p.x);585+ const iy = Math.round(p.y);586+ if (iy >= 0 && iy < H && ix >= 0 && ix < W) {587+ matrix[r][c] = binary[iy][ix];588+ }589+ }590+ }591+ return matrix;592+}593+594+/** Two-pass sampling: read the size bits at the estimated size, then595+ * re-sample at the exact size if they differ. */596+function sampleAndReadExact(597+ binary: boolean[][],598+ TL: Point, TR: Point, BL: Point,599+ estSize: number,600+): { matrix: boolean[][]; size: number } | null {601+ const rough = sampleGrid(binary, TL, TR, BL, estSize);602+ if (!rough) return null;603+ const exact = readSizeBits(rough);604+ if (exact >= MIN_SYMBOL_SIZE && exact <= MAX_SYMBOL_SIZE && exact !== estSize) {605+ const final = sampleGrid(binary, TL, TR, BL, exact);606+ if (!final) return null;607+ return { matrix: final, size: exact };608+ }609+ if (exact < MIN_SYMBOL_SIZE || exact > MAX_SYMBOL_SIZE) {610+ // Fall back to the estimated size — RS may still recover.611+ return { matrix: rough, size: estSize };612+ }613+ return { matrix: rough, size: exact };614+}615+616+function readSizeBits(matrix: boolean[][]): number {617+ const row = QUIET_ZONE;618+ const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;619+ let val = 0;620+ for (let i = 0; i < 16; i++) {621+ if (matrix[row]?.[colStart + i]) val |= 1 << (15 - i);622+ }623+ return val;624+}625+626+// ── BIT / BYTE HELPERS ───────────────────────────────────────────────────────627+628+function readDataBits(629+ matrix: boolean[][],630+ size: number,631+ count: number,632+): boolean[] {633+ const bits: boolean[] = [];634+ for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {635+ const row = matrix[r];636+ if (!row) continue;637+ for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {638+ if (isReserved(r, c, size)) continue;639+ bits.push(row[c] ?? false);640+ }641+ }642+ return bits;643+}644+645+function bitsToBytes(bits: boolean[]): number[] {646+ const bytes: number[] = [];647+ for (let i = 0; i < bits.length; i += 8) {648+ let byte = 0;649+ for (let j = 0; j < 8 && i + j < bits.length; j++) {650+ if (bits[i + j]) byte |= 1 << (7 - j);651+ }652+ bytes.push(byte);653+ }654+ return bytes;655+}656+657+function clamp(v: number, lo: number, hi: number): number {658+ return Math.max(lo, Math.min(hi, v));659+}src/crypto/rs.ts+27 −15
@@ -2,23 +2,15 @@22 * Reed-Solomon codec — GF(2^8) with primitive polynomial 0x11D33 * (x^8 + x^4 + x^3 + x^2 + 1, the standard CCSDS / NASA polynomial).44 *5- * Block-based RS(255, 178): 178 data bytes + 77 parity bytes per block.6- * Yields 30.2% ECC, matching the Mayan spec's ECC_RATE = 0.30.5+ * Two API surfaces:76 *8- * The Mayan spec (peach_mayan_spec.md §4) cites "30% ECC" and9- * "RS(255,223)" in the same sentence, but those numbers contradict —10- * RS(255,223) is 12.5% ECC. The encoder code in the spec uses11- * ECC_RATE = 0.30 consistently, so we honor the rate (30%) and pick12- * standard 255-byte blocks at the matching ratio.7+ * 1. Per-block primitives (Mayan v2): rsEncodeBlock / rsDecodeBlock with8+ * arbitrary parity count. Used for RS(255,204) — 204 data + 51 parity —9+ * where the Mayan layer does its own blocking and interleaving.1310 *14- * The exposed API matches the call sites in the Mayan spec:15- *16- * reedSolomonEncode(data, totalParityBytes)17- * reedSolomonDecode(received, dataBytes, parityBytes)18- *19- * `totalParityBytes` is the spec's `Math.ceil(data.length * ECC_RATE)`;20- * we treat it as advisory and instead generate 77 parity bytes per21- * 178-byte block so the symbol-size math matches what the spec expected.11+ * 2. Legacy block-mode RS(255,178): reedSolomonEncode / reedSolomonDecode12+ * auto-block a payload into 178/77 blocks. Used by Mayan v1 codes only.13+ * New v2 codes do not go through this path.2214 */2315 2416 const FIELD_SIZE = 256;@@ -326,3 +318,23 @@326318 export const RS_BLOCK_DATA = BLOCK_DATA;327319 export const RS_BLOCK_PARITY = BLOCK_PARITY;328320 export const RS_BLOCK_TOTAL = BLOCK_TOTAL;321+322+// ── Per-block API (Mayan v2 uses these directly with parity=51) ─────────────323+324+/**325+ * Encode a single shortened RS codeword: append `parity` parity bytes to326+ * `data`, returning a `data.length + parity`-byte block. Caller controls327+ * the block geometry; for RS(255,204) pass 204-byte data + parity=51.328+ */329+export function rsEncodeBlock(data: Uint8Array, parity: number): Uint8Array {330+ return encodeBlock(data, parity);331+}332+333+/**334+ * Decode a single shortened RS codeword. Returns the data portion (without335+ * parity) on success, or null if uncorrectable. Block length and parity must336+ * match what was used at encode time.337+ */338+export function rsDecodeBlock(received: Uint8Array, parity: number): Uint8Array | null {339+ return decodeBlock(received, parity);340+}src/popup/views/Export.tsx+6 −3
@@ -310,10 +310,13 @@310310 // On-screen render at 3px/module; download render at 4px/module.311311 const screen = encodeMayan(env.bytes, 3);312312 const print = encodeMayan(env.bytes, 4);313- if (!screen.printable) {314- // Visible warning but do not abort — user explicitly chose Mayan.313+ if (screen.paperSize === "A3") {315314 setError(316- `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — too large for one A4 page. Save as PNG for digital backup.`,315+ `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — print on A3 paper for best results.`,316+ );317+ } else if (screen.paperSize === "A3-required") {318+ setError(319+ `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — A3 paper required to print at full resolution. Save as PNG for digital backup.`,317320 );318321 }319322 if (canvasHost) {