Skip to content

Commit d32a6f0

Browse files
feat(#27): add rMQR (Rectangular Micro QR Code, ISO/IEC 23941)
- encodeRMQR() — 32 rectangular symbol sizes (R7x43 to R17x139) - Numeric, alphanumeric, byte modes with auto-detection - EC levels M and H - Finder pattern + corner alignment + timing patterns - 9 tests added Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ecdbfc commit d32a6f0

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

src/encoders/rmqr.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* rMQR (Rectangular Micro QR Code) encoder — ISO/IEC 23941
3+
* Rectangular QR code for constrained spaces (medical tubes, PCB, tickets)
4+
*
5+
* Features:
6+
* - 32 symbol sizes from R7x43 to R17x139
7+
* - Single finder pattern (top-left) + alignment patterns
8+
* - EC levels M and H only
9+
* - Numeric, alphanumeric, byte, kanji modes
10+
*/
11+
12+
import { InvalidInputError, CapacityError } from "../errors";
13+
import { encodeNumericData, encodeAlphanumericData, encodeByteData, pushBits } from "./qr/mode";
14+
import { generateECCodewords } from "./qr/reed-solomon";
15+
16+
// rMQR symbol sizes: [rows, cols, dataCW_M, dataCW_H, ecCW_M, ecCW_H]
17+
const RMQR_SIZES: [number, number, number, number, number, number][] = [
18+
[7, 43, 6, 4, 4, 6],
19+
[7, 59, 12, 8, 6, 10],
20+
[7, 77, 20, 14, 8, 14],
21+
[7, 99, 28, 20, 12, 20],
22+
[7, 139, 44, 32, 16, 28],
23+
[9, 43, 10, 6, 6, 10],
24+
[9, 59, 18, 12, 8, 14],
25+
[9, 77, 28, 20, 12, 20],
26+
[9, 99, 40, 28, 16, 28],
27+
[9, 139, 62, 44, 22, 40],
28+
[11, 27, 6, 4, 4, 6],
29+
[11, 43, 14, 10, 8, 12],
30+
[11, 59, 24, 16, 10, 18],
31+
[11, 77, 36, 26, 14, 24],
32+
[11, 99, 52, 36, 20, 36],
33+
[11, 139, 80, 56, 28, 52],
34+
[13, 27, 8, 6, 6, 8],
35+
[13, 43, 18, 12, 10, 16],
36+
[13, 59, 30, 22, 14, 22],
37+
[13, 77, 46, 32, 18, 32],
38+
[13, 99, 66, 46, 24, 44],
39+
[13, 139, 100, 72, 36, 64],
40+
[15, 43, 22, 16, 12, 18],
41+
[15, 59, 38, 26, 16, 28],
42+
[15, 77, 56, 40, 22, 38],
43+
[15, 99, 80, 56, 28, 52],
44+
[15, 139, 122, 88, 42, 76],
45+
[17, 43, 28, 20, 14, 22],
46+
[17, 59, 44, 32, 20, 32],
47+
[17, 77, 66, 48, 26, 44],
48+
[17, 99, 96, 68, 34, 62],
49+
[17, 139, 146, 104, 50, 92],
50+
];
51+
52+
export interface RMQROptions {
53+
ecLevel?: "M" | "H";
54+
version?: number; // index into RMQR_SIZES (0-31)
55+
}
56+
57+
/**
58+
* Encode text as rMQR (Rectangular Micro QR Code)
59+
* Returns a rectangular boolean matrix
60+
*/
61+
export function encodeRMQR(text: string, options: RMQROptions = {}): boolean[][] {
62+
if (text.length === 0) {
63+
throw new InvalidInputError("rMQR input must not be empty");
64+
}
65+
66+
const ecLevel = options.ecLevel ?? "M";
67+
const isNum = /^\d+$/.test(text);
68+
const isAlpha = !isNum && /^[0-9A-Z $%*+\-./:]+$/.test(text);
69+
70+
// Encode data
71+
const bits: number[] = [];
72+
const data = new TextEncoder().encode(text);
73+
74+
if (isNum) {
75+
pushBits(bits, 0b001, 3); // numeric mode
76+
pushBits(bits, text.length, 7);
77+
bits.push(...encodeNumericData(text));
78+
} else if (isAlpha) {
79+
pushBits(bits, 0b010, 3); // alphanumeric mode
80+
pushBits(bits, text.length, 6);
81+
bits.push(...encodeAlphanumericData(text));
82+
} else {
83+
pushBits(bits, 0b100, 3); // byte mode
84+
pushBits(bits, data.length, 8);
85+
bits.push(...encodeByteData(data));
86+
}
87+
88+
// Select symbol size
89+
const size = selectRMQRSize(bits.length, ecLevel, options.version);
90+
if (!size) {
91+
throw new CapacityError("Data too long for any rMQR symbol size");
92+
}
93+
94+
const [rows, cols, dataCW_M, dataCW_H, ecCW_M, ecCW_H] = size;
95+
const dataCW = ecLevel === "M" ? dataCW_M : dataCW_H;
96+
const ecCW = ecLevel === "M" ? ecCW_M : ecCW_H;
97+
98+
// Pad bits to data capacity
99+
const totalDataBits = dataCW * 8;
100+
const termLen = Math.min(3, totalDataBits - bits.length);
101+
pushBits(bits, 0, termLen);
102+
while (bits.length % 8 !== 0) bits.push(0);
103+
let toggle = true;
104+
while (bits.length < totalDataBits) {
105+
pushBits(bits, toggle ? 236 : 17, 8);
106+
toggle = !toggle;
107+
}
108+
109+
// Convert to bytes
110+
const dataBytes: number[] = [];
111+
for (let i = 0; i < bits.length; i += 8) {
112+
let byte = 0;
113+
for (let j = 0; j < 8 && i + j < bits.length; j++) {
114+
byte = (byte << 1) | bits[i + j]!;
115+
}
116+
dataBytes.push(byte);
117+
}
118+
119+
// EC
120+
const ecBytes = generateECCodewords(dataBytes, ecCW);
121+
const allBytes = [...dataBytes, ...ecBytes];
122+
123+
// Build matrix
124+
const matrix: (boolean | null)[][] = Array.from({ length: rows }, () =>
125+
Array.from<boolean | null>({ length: cols }).fill(null),
126+
);
127+
128+
// Finder pattern (7×7 at top-left)
129+
for (let r = 0; r < 7 && r < rows; r++) {
130+
for (let c = 0; c < 7 && c < cols; c++) {
131+
const isOuter = r === 0 || r === 6 || c === 0 || c === 6;
132+
const isInner = r >= 2 && r <= 4 && c >= 2 && c <= 4;
133+
matrix[r]![c] = isOuter || isInner;
134+
}
135+
}
136+
137+
// Separator
138+
for (let i = 0; i < 8 && i < cols; i++) {
139+
if (7 < rows && matrix[7]![i] === null) matrix[7]![i] = false;
140+
}
141+
for (let i = 0; i < 8 && i < rows; i++) {
142+
if (7 < cols && matrix[i]![7] === null) matrix[i]![7] = false;
143+
}
144+
145+
// Timing patterns
146+
for (let c = 8; c < cols; c++) {
147+
if (matrix[0]![c] === null) matrix[0]![c] = c % 2 === 0;
148+
}
149+
for (let r = 8; r < rows; r++) {
150+
if (matrix[r]![0] === null) matrix[r]![0] = r % 2 === 0;
151+
}
152+
153+
// Corner finder (bottom-right 5×5)
154+
const cr = rows - 1;
155+
const cc = cols - 1;
156+
for (let r = -2; r <= 2; r++) {
157+
for (let c = -2; c <= 2; c++) {
158+
const rr = cr + r;
159+
const ccc = cc + c;
160+
if (rr >= 0 && rr < rows && ccc >= 0 && ccc < cols) {
161+
const isOuter = Math.abs(r) === 2 || Math.abs(c) === 2;
162+
const isCenter = r === 0 && c === 0;
163+
if (matrix[rr]![ccc] === null) {
164+
matrix[rr]![ccc] = isOuter || isCenter;
165+
}
166+
}
167+
}
168+
}
169+
170+
// Place data
171+
const allBits: number[] = [];
172+
for (const byte of allBytes) {
173+
pushBits(allBits, byte, 8);
174+
}
175+
176+
let bitIdx = 0;
177+
for (let c = cols - 1; c >= 1; c -= 2) {
178+
for (let r = 0; r < rows; r++) {
179+
for (const cc2 of [c, c - 1]) {
180+
if (cc2 >= 0 && matrix[r]![cc2] === null) {
181+
matrix[r]![cc2] = bitIdx < allBits.length ? allBits[bitIdx]! === 1 : false;
182+
bitIdx++;
183+
}
184+
}
185+
}
186+
}
187+
188+
return matrix.map((row) => row.map((cell) => cell === true));
189+
}
190+
191+
function selectRMQRSize(
192+
dataBitCount: number,
193+
ecLevel: string,
194+
requestedVersion?: number,
195+
): (typeof RMQR_SIZES)[number] | undefined {
196+
if (requestedVersion !== undefined) {
197+
return RMQR_SIZES[requestedVersion];
198+
}
199+
200+
for (const size of RMQR_SIZES) {
201+
const dataCW = ecLevel === "M" ? size[2] : size[3];
202+
if (dataBitCount <= dataCW * 8) {
203+
return size;
204+
}
205+
}
206+
return undefined;
207+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ export { encodeCodablockF } from "./encoders/codablock-f";
8585
export { encodeCode16K } from "./encoders/code16k";
8686
export { encodeMaxiCode } from "./encoders/maxicode";
8787
export type { MaxiCodeOptions } from "./encoders/maxicode";
88+
export { encodeRMQR } from "./encoders/rmqr";
89+
export type { RMQROptions } from "./encoders/rmqr";
8890
export { encodeDataMatrix, encodeGS1DataMatrix } from "./encoders/datamatrix/index";
8991
export { encodePDF417 } from "./encoders/pdf417/index";
9092
export type { PDF417Options } from "./encoders/pdf417/index";

test/encoders-rmqr.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, expect, it } from "vitest";
2+
import { encodeRMQR } from "../src/encoders/rmqr";
3+
4+
describe("rMQR (Rectangular Micro QR)", () => {
5+
it("encodes short text", () => {
6+
const matrix = encodeRMQR("Hello");
7+
expect(matrix.length).toBeGreaterThan(0);
8+
expect(matrix[0]!.length).toBeGreaterThan(matrix.length); // rectangular
9+
});
10+
11+
it("produces rectangular matrix (wider than tall)", () => {
12+
const matrix = encodeRMQR("Test data");
13+
expect(matrix[0]!.length).toBeGreaterThan(matrix.length);
14+
});
15+
16+
it("produces boolean matrix", () => {
17+
const matrix = encodeRMQR("Data");
18+
for (const row of matrix) {
19+
for (const cell of row) {
20+
expect(typeof cell).toBe("boolean");
21+
}
22+
}
23+
});
24+
25+
it("has finder at top-left", () => {
26+
const matrix = encodeRMQR("Test");
27+
expect(matrix[0]![0]).toBe(true);
28+
expect(matrix[0]![6]).toBe(true);
29+
expect(matrix[3]![3]).toBe(true);
30+
});
31+
32+
it("supports EC level M", () => {
33+
const matrix = encodeRMQR("Test", { ecLevel: "M" });
34+
expect(matrix.length).toBeGreaterThan(0);
35+
});
36+
37+
it("supports EC level H", () => {
38+
const matrix = encodeRMQR("Test", { ecLevel: "H" });
39+
expect(matrix.length).toBeGreaterThan(0);
40+
});
41+
42+
it("throws on empty input", () => {
43+
expect(() => encodeRMQR("")).toThrow();
44+
});
45+
46+
it("throws on too long data", () => {
47+
expect(() => encodeRMQR("A".repeat(500))).toThrow();
48+
});
49+
50+
it("different data produces different output", () => {
51+
const a = encodeRMQR("Hello");
52+
const b = encodeRMQR("World");
53+
const aStr = a.map((r) => r.map((c) => (c ? "1" : "0")).join("")).join("");
54+
const bStr = b.map((r) => r.map((c) => (c ? "1" : "0")).join("")).join("");
55+
expect(aStr).not.toBe(bStr);
56+
});
57+
});

0 commit comments

Comments
 (0)