Pure-JavaScript, zero-dependency encoder, decoder and image
scanner for Hexatess Code (specification v0.3). All are 1:1 ports of
the reference Python implementation: the encoder produces
byte-identical symbols for uncompressed payloads (compressed
payloads use a self-contained fixed-Huffman DEFLATE compressor that any
spec-conforming inflator, including Python’s zlib, decodes), the
decoder reads back any conforming symbol — including those written by
Python’s zlib -9 (dynamic-Huffman DEFLATE) — and the scanner locates
and reads symbols inside clean renders and photographs.
The browser playground demo.html lives at the repository root
(one level up) — double-click it, no server needed: live encoding with
SVG/PNG export, instant decode round trip, and decoding of uploaded
images (PNG fully in JavaScript, so it also works on file:// pages;
photos with uneven lighting, noise, mild tilt and 60°-step rotations
are handled).
| File | Purpose |
|---|---|
hexatess-encoder.js |
Encoder library (UMD: browser global Hexatess + Node require) |
hexatess-decoder.js |
Decoder library (browser global HexatessDecode, merged into Hexatess; Node require) |
hexatess-scanner.js |
Image scanner + pure-JS PNG decoder (browser global HexatessScan, merged into the others; Node require) |
../demo.html |
Playground — encode + decode, at the repository root |
test_encoder.js |
Encoder conformance tests (node test_encoder.js) |
test_decoder.js |
Decoder conformance tests (node test_decoder.js) |
test_scanner.js |
Scanner tests, incl. photo-like conditions (node test_scanner.js) |
package.json |
Metadata for a future npm release |
<script src="javascript/hexatess-encoder.js"></script>
<script src="javascript/hexatess-decoder.js"></script>
<script src="javascript/hexatess-scanner.js"></script>
<script>
var out = Hexatess.encode("Hello, Hexatess!", { ecPct: 30 });
document.body.innerHTML = Hexatess.renderSVG(out.grid);
var dec = Hexatess.decode(out.grid); // merged from HexatessDecode
console.log(dec.text); // "Hello, Hexatess!"
console.log(dec.stats); // { rmax, mask, ec, blocks,
// dataLen, compressed, repairBits }
</script>
const Hexatess = require("./hexatess-encoder.js");
const HexatessDecode = require("./hexatess-decoder.js");
const { grid, params } = Hexatess.encode("Zdravo 🐝", { ecPct: 25 });
const { text, stats } = HexatessDecode.decode(grid);
console.log(text === "Zdravo 🐝", stats.repairBits); // true 0
encode(input, options?) → { grid, params }| Option | Default | Meaning |
|---|---|---|
input |
— | string (UTF-8 encoded) or Uint8Array of bytes |
options.ecPct |
30 |
Error-correction budget, 5–90 in steps of 5 |
options.mask |
"auto" |
Force mask 0–7, or let the encoder pick |
options.minRings |
— | Force a minimum symbol radius (1–31) |
options.compress |
"auto" |
"auto" = deflate when strictly smaller; true = force; false = raw UTF-8 (byte-identical to spec v0.2 symbols) |
grid is a Map keyed by axial coordinates "q,r" with values 0|1
(1 = dark module). params reports rmax, mask, ec, blocks
([dataBytes, eccBytes] pairs), dataLen (stored bytes) and
compressed.
renderSVG(grid, options?) → stringOptions: size (module radius px, default 18), quiet (quiet zone in
modules, default 1.5), dark, light (CSS colors), background
(false disables the light rectangle).
canonicalHex(grid, rmax) — canonical bitstream hex (the conformance
format used by test_vectors/vectors_v0.3.json).
gridToJSON(grid) — plain object {"q,r": bit}.
Hexatess.internals — GF(256), Reed-Solomon, header packing, masks,
geometry and the DEFLATE compressor, exposed for testing and for
third-party implementations.
decode(grid, options?) → { text, stats }grid is the encoder’s Map or a plain object with "q,r" keys.
Throws on uncorrectable damage, damaged zlib streams or invalid UTF-8.
stats mirrors the Python decoder: rmax, mask, ec, blocks,
dataLen, compressed and repairBits (total Hamming distance the
Reed–Solomon layer had to correct inside the data bytes; header and
ECC repairs are absorbed silently).
Erasure decoding (v0.4.3): pass options.erasures — an array of
"q,r" keys (or a Set) marking cells the sampler could not read
confidently. Every payload byte that contains an erased cell is then
corrected as an erasure, which costs 1 EC symbol instead of 2
(2e + v ≤ ecc per block; Forney syndromes + erasure-aware
Berlekamp–Massey). Header bytes can be erased as well (RS(10,5)).
Unknown keys are ignored; stats.erasedBits counts the erased bits
that landed inside header/payload bytes. Without the option the
behaviour is identical to earlier releases.
decodeHex(hex, rmax?), gridFromHex(hex, rmax?), payloadToText(bytes, compressed)gridFromHex parses the canonical conformance hex (rmax inferred
from the length when omitted); decodeHex = decode(gridFromHex(...)).
payloadToText inflates (when compressed) and strictly validates
UTF-8. HexatessDecode.internals exposes the RS decoder (syndromes →
Berlekamp–Massey → Chien → Forney), the full zlib/DEFLATE inflator and
the strict UTF-8 decoder.
scanImageData(img, options?) → { text, stats, scan }img is { width, height, data } with data a RGBA Uint8Array
(a canvas ImageData works as-is). Returns the decoder result plus a
scan report: { cx, cy, s, rmax, rotation, candidate, homo, finder,
sampling, erasures, … } — homo: true marks a photo-mode decode,
sampling is plain | jitter | poly. Options: normalize
(default true), rotations (default true), onAttempt
(progress callback). Throws when no candidate decodes.
Robust to uneven illumination, camera noise, blur, JPEG artefacts,
glare, busy backgrounds, arbitrary in-plane rotation and
moderate perspective: photo mode sweeps the full circle with a
91-cell margin objective, fits an 8-DOF homography (pattern search +
DLT refinement), samples cells through its Jacobian with median-reading
discs, re-samples through a quadratic correction surface fitted on the
finder + RS-verified header cells, marks unresolvable cells as RS
erasures, and retries tiny prints on a bilinear zoom crop. Very small
prints (a few pixels per cell) and extreme angles remain the domain of
the Python hexatess decode camera pipeline.
decodePNG(bytes) → { width, height, data }Pure-JS PNG decoder (8/16-bit; gray, RGB, palette, gray+alpha, RGBA;
non-interlaced). Lets a browser decode PNG uploads without a canvas —
no file:// tainted-canvas SecurityError — and works in Node for
testing.
downscaleImageData(img, maxDim) → imgInteger box downscale used to cap huge photos before scanning.
const Scan = require("./hexatess-scanner.js");
const png = Scan.decodePNG(fs.readFileSync("koda.png"));
const { text, scan } = Scan.scanImageData(Scan.downscaleImageData(png, 2000));
console.log(text, scan.rotation, scan.s);
test_vectors/vectors_v0.3.json — run
node test_encoder.js (92 checks).node test_decoder.js (136 checks).zlib -9 on natural text,
and occasionally fewer (e.g. 80 digits: 20 B vs 21 B).node test_scanner.js) — clean renders,
compressed symbols, PNG round-trips (RGBA, palette, 16-bit gray),
downscale, illumination gradient, noise, gradient+noise, five 60°
rotations, busy background, low resolution and API errors; plus a
29-image cross-implementation battery (Python- and JS-rendered,
rotated / gradient / noisy / JPEG / tilted / busy variants) decoded
by both the JS scanner and the Python camera pipeline.The SVG renderer draws the same pointy-top hexagonal lattice as the
Python renderer (x = s·√3·(q + r/2), y = s·1.5·r), with the same
default colors (dark #181612 = RGB 24,22,18) and a 1.5-module quiet
zone. demo.html additionally exports PNG via canvas with 3×
supersampling, mirroring the Python PNG pipeline, and its Decode panel
runs the full image scanner from hexatess-scanner.js.