Skip to content

Commit b28830c

Browse files
committed
feat: add Han Xin Unicode mode
1 parent 1007554 commit b28830c

5 files changed

Lines changed: 155 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ The app controller and UI. Manages state, binds DOM events, and provides the int
4747
The `feat/han-xin-core` branch adds a native ISO/IEC 20830-oriented Han Xin
4848
encoder without changing the QR studio UI. It supports versions 1–84, ECC
4949
levels L1–L4, automatic mode segmentation, GB18030 text, raw bytes, GS1, all four
50-
masks and a neutral SVG matrix renderer. See [docs/HANXIN.md](docs/HANXIN.md)
50+
masks, dedicated UTF-8 Unicode compression and a neutral SVG matrix renderer.
51+
See [docs/HANXIN.md](docs/HANXIN.md)
5152
for the public API and reference notes.
5253

5354
## Installation & Usage

docs/HANXIN.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const symbol = new HanXinCore('汉信码 123456', {
1717
eci: 0, // optional ECI assignment number, 0–999999
1818
gs1: false, // true for a GS1 element string
1919
uri: false, // true for compact URI/URL encoding
20+
unicode: false, // true for dedicated UTF-8 Unicode compression
2021
}).generate()
2122

2223
const svg = new HanXinSvgRenderer(symbol, {
@@ -46,6 +47,7 @@ silently selecting a different symbol.
4647
- ECI headers
4748
- GS1 framing and FNC1 element separators
4849
- URI-A, URI-B, URI-C and compact `%XX` byte sequences
50+
- dedicated Unicode mode with adaptive 1-, 2-, 3- and 4-byte grouping
4951
- all 84 versions and ECC levels L1–L4
5052
- ISO-style structural information, assistant/alignment patterns, RS blocks,
5153
picket-fence reordering and all four data masks
@@ -64,9 +66,16 @@ built-in fragments such as `https://`, `www.`, `.com` and `.org`. Existing
6466
must therefore be percent-encoded by the caller. URI mode cannot be combined
6567
with GS1 or ECI.
6668

67-
The optional dedicated Unicode mode is not currently exposed. One ECI value
68-
applies to the complete ordinary input; a public multi-segment ECI API is not
69-
yet provided.
69+
Set `unicode: true` for the dedicated Han Xin Unicode mode. It converts string
70+
input to strict UTF-8, chooses cost-optimal 1-, 2-, 3- and 4-byte groups, and
71+
compresses each byte column as a minimum value plus unsigned differences.
72+
This differs from ordinary string input, which uses GB18030 and may select the
73+
Chinese region modes. Unicode mode accepts strings only and cannot be combined
74+
with GS1, URI or ECI. Its result segments are measured in UTF-8 bytes and expose
75+
their `byteWidth` and group `count`.
76+
77+
One ECI value applies to the complete ordinary input; a public multi-segment
78+
ECI API is not yet provided.
7079

7180
## References and verification
7281

@@ -78,6 +87,9 @@ yet provided.
7887
installed by this repository and is not imported by production code.
7988
- GS1 framing, FNC1 separator encoding and numeric-boundary optimization are
8089
verified against BWIPP's MIT-licensed Han Xin PostScript reference.
90+
- Unicode framing, counters and compression codewords are verified against
91+
Aspose.BarCode for Java as an external development oracle. Aspose is not
92+
installed by this repository and is never a runtime dependency.
8193

8294
BWIPP currently writes an alternating pattern into the final six structural
8395
information bits. ISO/IEC 20830:2021 and current Zint clear those bits. This

libs/HanXinCompaction.js

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ export function normalizeHanXinInput(input) {
9292
throw new TypeError('Han Xin data must be a string, Uint8Array, typed-array view, or ArrayBuffer');
9393
}
9494

95+
export function normalizeHanXinUnicodeInput(input) {
96+
if (typeof input !== 'string') throw new TypeError('Han Xin Unicode mode requires string input');
97+
if (!input.length) throw new RangeError('Han Xin data must not be empty');
98+
const units = [];
99+
for (const character of input) {
100+
const codePoint = character.codePointAt(0);
101+
if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
102+
throw new RangeError(`Han Xin Unicode mode cannot encode an unpaired surrogate U+${codePoint.toString(16).toUpperCase()}`);
103+
}
104+
if (codePoint < 0x80) units.push(codePoint);
105+
else if (codePoint < 0x800) units.push(0xc0 | codePoint >>> 6, 0x80 | codePoint & 0x3f);
106+
else if (codePoint < 0x10000) units.push(0xe0 | codePoint >>> 12, 0x80 | codePoint >>> 6 & 0x3f, 0x80 | codePoint & 0x3f);
107+
else units.push(0xf0 | codePoint >>> 18, 0x80 | codePoint >>> 12 & 0x3f,
108+
0x80 | codePoint >>> 6 & 0x3f, 0x80 | codePoint & 0x3f);
109+
}
110+
return { units, inputType: 'text', encoding: 'UTF-8' };
111+
}
112+
95113
const isDigit = (value) => value >= 0x30 && value <= 0x39;
96114
const isUpper = (value) => value >= 0x41 && value <= 0x5a;
97115
const isLower = (value) => value >= 0x61 && value <= 0x7a;
@@ -204,6 +222,75 @@ class BitBuffer {
204222
}
205223
}
206224

225+
const unicodeCountWidth = (count) => count <= 7 ? 4 : count <= 63 ? 8 : count <= 511 ? 12 : count <= 4095 ? 16 : 20;
226+
227+
function appendUnicodeCount(output, count) {
228+
if (count < 1 || count > 32767) throw new RangeError('A Han Xin Unicode segment cannot exceed 32767 byte groups');
229+
if (count <= 7) output.append(count, 4);
230+
else if (count <= 63) output.append(0x80 | count, 8);
231+
else if (count <= 511) output.append(0xc00 | count, 12);
232+
else if (count <= 4095) output.append(0xe000 | count, 16);
233+
else output.append(0xf0000 | count, 20);
234+
}
235+
236+
function compactUnicodeHanXin(units, eci) {
237+
if (eci) throw new RangeError('Han Xin Unicode mode does not support an ECI header');
238+
const costs = Array(units.length + 1).fill(Number.POSITIVE_INFINITY);
239+
const choices = Array(units.length).fill(null);
240+
costs[units.length] = 0;
241+
242+
for (let start = units.length - 1; start >= 0; start--) {
243+
for (let width = 1; width <= 4; width++) {
244+
const minima = Array(width).fill(0xff);
245+
const maxima = Array(width).fill(0);
246+
const maximumGroups = Math.min(32767, Math.floor((units.length - start) / width));
247+
for (let count = 1; count <= maximumGroups; count++) {
248+
const groupStart = start + (count - 1) * width;
249+
for (let column = 0; column < width; column++) {
250+
const value = units[groupStart + column];
251+
minima[column] = Math.min(minima[column], value);
252+
maxima[column] = Math.max(maxima[column], value);
253+
}
254+
const differenceWidths = minima.map((minimum, column) => {
255+
const difference = maxima[column] - minimum;
256+
return difference ? Math.floor(Math.log2(difference)) + 1 : 0;
257+
});
258+
const end = start + count * width;
259+
const bitLength = 4 + unicodeCountWidth(count) + width * 12 +
260+
count * differenceWidths.reduce((sum, value) => sum + value, 0);
261+
const candidate = bitLength + costs[end];
262+
if (candidate < costs[start]) {
263+
costs[start] = candidate;
264+
choices[start] = { width, count, end, minima: [...minima], differenceWidths };
265+
}
266+
}
267+
}
268+
}
269+
270+
const output = new BitBuffer();
271+
const modes = Array(units.length);
272+
const segments = [];
273+
output.append(9, 4);
274+
for (let start = 0; start < units.length;) {
275+
const choice = choices[start];
276+
output.append(choice.width, 4);
277+
appendUnicodeCount(output, choice.count);
278+
for (const width of choice.differenceWidths) output.append(width, 4);
279+
for (const minimum of choice.minima) output.append(minimum, 8);
280+
for (let position = start; position < choice.end; position += choice.width) {
281+
for (let column = 0; column < choice.width; column++) {
282+
output.append(units[position + column] - choice.minima[column], choice.differenceWidths[column]);
283+
}
284+
}
285+
modes.fill(`u${choice.width}`, start, choice.end);
286+
segments.push({ mode: `u${choice.width}`, start, end: choice.end, length: choice.end - start,
287+
byteWidth: choice.width, count: choice.count });
288+
start = choice.end;
289+
}
290+
output.append(15, 4);
291+
return { bits: output.bits, modes, segments };
292+
}
293+
207294
const URI_A_TOKENS = [
208295
...'abcdefghijklmnopqrstuvwxyz0123456789./-_~:@?#=+$&',
209296
'http://', 'https://', 'ftp://', 'mailto:', 'ldap://', 'tel:', 'urn:', 'www.',
@@ -420,11 +507,12 @@ function segmentsFromModes(modes) {
420507
return result;
421508
}
422509

423-
export function compactHanXin(units, { eci = 0, gs1 = false, uri = false } = {}) {
510+
export function compactHanXin(units, { eci = 0, gs1 = false, uri = false, unicode = false } = {}) {
424511
if (!Number.isInteger(eci) || eci < 0 || eci > 999999) throw new RangeError('ECI must be an integer from 0 to 999999');
425-
if (gs1 && uri) throw new RangeError('Han Xin GS1 and URI modes cannot be combined');
512+
if ([gs1, uri, unicode].filter(Boolean).length > 1) throw new RangeError('Han Xin GS1, URI, and Unicode modes cannot be combined');
426513
if (gs1) return compactGs1HanXin(units, eci);
427514
if (uri) return compactUriHanXin(units, eci);
515+
if (unicode) return compactUnicodeHanXin(units, eci);
428516
const modes = selectHanXinModes(units);
429517
const segments = segmentsFromModes(modes);
430518
const output = new BitBuffer();

libs/HanXinCore.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { compactHanXin, normalizeHanXinInput } from './HanXinCompaction.js';
1+
import { compactHanXin, normalizeHanXinInput, normalizeHanXinUnicodeInput } from './HanXinCompaction.js';
22
import { addHanXinErrorCorrection } from './HanXinErrorCorrection.js';
33
import { createHanXinGrid, populateAndMaskHanXin } from './HanXinMatrix.js';
44
import { HAN_XIN_DATA_CODEWORDS, HAN_XIN_TOTAL_CODEWORDS } from './HanXinTables.js';
@@ -38,10 +38,12 @@ export class HanXinCore {
3838
const requestedMask = parseMask(this.options.mask);
3939
if (this.options.gs1 != null && typeof this.options.gs1 !== 'boolean') throw new TypeError('Han Xin gs1 must be a boolean');
4040
if (this.options.uri != null && typeof this.options.uri !== 'boolean') throw new TypeError('Han Xin uri must be a boolean');
41+
if (this.options.unicode != null && typeof this.options.unicode !== 'boolean') throw new TypeError('Han Xin unicode must be a boolean');
4142
const gs1 = this.options.gs1 === true;
4243
const uri = this.options.uri === true;
43-
const normalized = normalizeHanXinInput(this.data);
44-
const compacted = compactHanXin(normalized.units, { eci: this.options.eci ?? 0, gs1, uri });
44+
const unicode = this.options.unicode === true;
45+
const normalized = unicode ? normalizeHanXinUnicodeInput(this.data) : normalizeHanXinInput(this.data);
46+
const compacted = compactHanXin(normalized.units, { eci: this.options.eci ?? 0, gs1, uri, unicode });
4547
const requiredCodewords = Math.ceil(compacted.bits.length / 8);
4648
let version = forcedVersion;
4749
if (version == null) {
@@ -71,6 +73,7 @@ export class HanXinCore {
7173
encoding: normalized.encoding,
7274
gs1,
7375
uri,
76+
unicode,
7477
eci: this.options.eci ?? 0,
7578
bitLength: compacted.bits.length,
7679
dataCodewords,
@@ -81,6 +84,7 @@ export class HanXinCore {
8184
segments: compacted.segments.map((segment) => ({ ...segment, name: {
8285
n: 'numeric', t: 'text', b: 'binary', 1: 'region-one', 2: 'region-two', d: 'double-byte', f: 'four-byte', g: 'gs1-separator',
8386
ua: 'uri-a', ub: 'uri-b', uc: 'uri-c', up: 'uri-percent',
87+
u1: 'unicode-1-byte', u2: 'unicode-2-byte', u3: 'unicode-3-byte', u4: 'unicode-4-byte',
8488
}[segment.mode] })),
8589
capacity: {
8690
dataCodewords: dataCapacity,

tests/hanxin-core.test.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,47 @@ test('validates URI mode combinations and character set', () => {
158158
assert.throws(() => new HanXinCore('https://example.com/a b', { uri: true }).generate(), /character sets/);
159159
});
160160

161+
test('matches independent Han Xin Unicode reference codewords', () => {
162+
const vectors = [
163+
['A', [0x91, 0x10, 0x41, 0xf0]],
164+
['Привет', [0x92, 0x61, 0x6d, 0x08, 0x03, 0xf0, 0x1c, 0x32, 0x6b, 0x0b, 0xc0]],
165+
['🙂', [0x91, 0x47, 0x82, 0xdc, 0x74, 0xb8, 0x0f]],
166+
];
167+
for (const [text, expected] of vectors) {
168+
const result = new HanXinCore(text, { unicode: true, version: 10, errorCorrection: 'L1', mask: 0 }).generate();
169+
assert.deepEqual(result.dataCodewords.slice(0, expected.length), expected);
170+
assert.equal(result.unicode, true);
171+
assert.equal(result.encoding, 'UTF-8');
172+
}
173+
});
174+
175+
test('uses Unicode byte-column compression and variable-length group counters', () => {
176+
const repeated = new HanXinCore('a'.repeat(12), { unicode: true }).generate();
177+
assert.equal(repeated.bitLength, 32);
178+
assert.deepEqual(repeated.segments.map(({ name, byteWidth, count, length }) => ({ name, byteWidth, count, length })), [
179+
{ name: 'unicode-1-byte', byteWidth: 1, count: 12, length: 12 },
180+
]);
181+
assert.equal(new HanXinCore('a'.repeat(7), { unicode: true }).generate().bitLength, 28);
182+
assert.equal(new HanXinCore('a'.repeat(8), { unicode: true }).generate().bitLength, 32);
183+
184+
const cyrillic = new HanXinCore('Привет', { unicode: true }).generate();
185+
assert.deepEqual(cyrillic.segments.map(({ name, byteWidth, count }) => ({ name, byteWidth, count })), [
186+
{ name: 'unicode-2-byte', byteWidth: 2, count: 6 },
187+
]);
188+
assert.ok(cyrillic.bitLength < new HanXinCore('Привет').generate().bitLength);
189+
assert.equal(new HanXinCore('汉'.repeat(6), { unicode: true }).generate().segments[0].name, 'unicode-3-byte');
190+
assert.equal(new HanXinCore('🙂'.repeat(6), { unicode: true }).generate().segments[0].name, 'unicode-4-byte');
191+
});
192+
193+
test('validates Unicode input and mutually exclusive specialized modes', () => {
194+
assert.throws(() => new HanXinCore(Uint8Array.of(0x41), { unicode: true }).generate(), /string input/);
195+
assert.throws(() => new HanXinCore('\ud800', { unicode: true }).generate(), /unpaired surrogate/);
196+
assert.throws(() => new HanXinCore('text', { unicode: 'yes' }).generate(), /boolean/);
197+
assert.throws(() => new HanXinCore('text', { unicode: true, eci: 26 }).generate(), /ECI/);
198+
assert.throws(() => new HanXinCore('text', { unicode: true, gs1: true }).generate(), /cannot be combined/);
199+
assert.throws(() => new HanXinCore('text', { unicode: true, uri: true }).generate(), /cannot be combined/);
200+
});
201+
161202
test('maps representative Unicode points to GB18030 without a runtime codec', () => {
162203
assert.deepEqual(unicodeToGb18030(0x41), [0x41]);
163204
assert.deepEqual(unicodeToGb18030('汉'.codePointAt(0)), [0xbaba]);

0 commit comments

Comments
 (0)