Files
smpp-js/src/defs/encodings.ts
T
lilleman ca1a7473ed Declare the alphabet a GSM message is actually written in (#100)
* Regression tests for the alphabet a GSM message declares

* Declare the alphabet a GSM message is actually written in

* Reflow the composing-by-hand paragraph

* Drop the ASCII alias SMPP's flat table shares with the option's own

* Bound the receipt test's wait and tighten the notes around it
2026-09-09 21:30:55 +02:00

201 lines
5.9 KiB
TypeScript

export type EncodingName = 'ASCII' | 'LATIN1' | 'UCS2';
export type Encoding = {
decode: (buffer: Uint8Array) => string;
encode: (value: string) => Buffer;
match: (value: string) => boolean;
};
const gsmChars =
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
const gsmRegex =
/^[@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&'()*+,\-./0-9:;<=>?¡A-ZÄÖÑܧ¿a-zäöñüà\f^{}\\[~\]|€]*$/;
const gsmExtended = /[\f^{}\\[~\]|€]/g;
const gsmEscaped = /\x1B([\nΛ()/<=>¡e])/g;
// Characters reachable only via an ESC prefix, paired with the base character that follows it.
const gsmExtendedPairs: [string, string][] = [
['\f', '\n'],
['^', 'Λ'],
['{', '('],
['}', ')'],
['\\', '/'],
['[', '<'],
['~', '='],
[']', '>'],
['|', '¡'],
['€', 'e'],
];
const gsmCharCodes = new Map<string, number>();
const gsmExtChars = new Map<string, string>();
// Indexed by code unit rather than code point: every entry in the table is one octet on the wire.
for (let code = 0; code < gsmChars.length; code++) {
gsmCharCodes.set(gsmChars.charAt(code), code);
}
for (const [extended, base] of gsmExtendedPairs) {
gsmExtChars.set(extended, base);
gsmExtChars.set(base, extended);
}
const ascii: Encoding = {
decode(buffer) {
let result = '';
for (const byte of buffer) {
result += gsmChars[byte] ?? ' ';
}
return result.replace(gsmEscaped, (match, escaped: string) => gsmExtChars.get(escaped) ?? match);
},
encode(value) {
const escaped = value.replace(gsmExtended, match => `\x1B${gsmExtChars.get(match) ?? ''}`);
const result: number[] = [];
for (const char of escaped) {
result.push(gsmCharCodes.get(char) ?? 0x20);
}
return Buffer.from(result);
},
match(value) {
return gsmRegex.test(value);
},
};
const latin1: Encoding = {
decode(buffer) {
return Buffer.from(buffer).toString('latin1');
},
encode(value) {
return Buffer.from(value, 'latin1');
},
// Deliberately never selected by detect(); Latin-1 is decoded when a peer asks for it, never
// chosen for outgoing messages.
match() {
return false;
},
};
const ucs2: Encoding = {
decode(buffer) {
// A peer-controlled sm_length can cut a character in half, and swap16() refuses odd lengths.
const whole = buffer.length - (buffer.length % 2);
return Buffer.from(buffer.subarray(0, whole)).swap16().toString('utf16le');
},
encode(value) {
return Buffer.from(value, 'utf16le').swap16();
},
match() {
return true;
},
};
export const encodings: Record<EncodingName, Encoding> = {
ASCII: ascii,
LATIN1: latin1,
UCS2: ucs2,
};
export const encodingNames: readonly string[] = Object.keys(encodings);
export function isEncodingName(value: unknown): value is EncodingName {
return typeof value === 'string' && Object.hasOwn(encodings, value);
}
export function detect(value: string): EncodingName {
if (encodings.ASCII.match(value)) return 'ASCII';
if (encodings.LATIN1.match(value)) return 'LATIN1';
return 'UCS2';
}
export type Unencodable = { char: string; index: number };
/** What an alphabet could not carry, said the one way: the character, its code point and its index. */
export function unencodableText(at: Unencodable): string {
const point = (at.char.codePointAt(0) ?? 0).toString(16).toUpperCase().padStart(4, '0');
return `${JSON.stringify(at.char)} (U+${point}) at index ${String(at.index)}`;
}
/** The first character `encoding` cannot carry, or undefined where it carries every one of them. */
export function unencodable(message: string, encoding: EncodingName): Unencodable | undefined {
const codec = encodings[encoding];
let index = 0;
for (const char of message) {
if (codec.decode(codec.encode(char)) !== char) return { char, index };
index += char.length;
}
return undefined;
}
/**
* The GSM 03.38 section 4 message class a `data_coding` octet carries, in bits 1-0, or undefined
* where its coding group carries none. Below 0x80 bit 4 says whether one is there; 0xF0 always is.
*/
export function messageClassOf(dataCoding: number): number | undefined {
if ((dataCoding & 0x80) === 0) {
return (dataCoding & 0x10) === 0x10 ? dataCoding & 0x03 : undefined;
}
return (dataCoding & 0xF0) === 0xF0 ? dataCoding & 0x03 : undefined;
}
// A class is the only evidence a peer below 0x80 is spelling 03.38 rather than SMPP's flat table,
// which contradicts it and wins: 0x03 is Latin-1 here, GSM 7-bit there.
/** A class group puts the alphabet in bits 3-2, or in bit 2 alone above 0xF0. */
function messageClassEncoding(dataCoding: number): EncodingName | undefined {
if (messageClassOf(dataCoding) === undefined) return undefined;
if ((dataCoding & 0xF0) === 0xF0) {
return (dataCoding & 0x04) === 0x04 ? 'LATIN1' : 'ASCII';
}
const alphabet = (dataCoding >> 2) & 0x03;
if (alphabet === 0x01) return 'LATIN1';
return alphabet === 0x02 ? 'UCS2' : 'ASCII';
}
/**
* SMPP data_coding is a flat table for 0x00-0x0E, and the message class ranges are how a flash UCS2
* message arrives as 0x18. The 8-bit binary codings resolve to LATIN1, the one codec here that maps
* every octet to a code point and back unchanged, so a binary payload survives; alphabets with no
* codec fall back to ASCII.
*/
export function encodingByDataCoding(dataCoding: number): EncodingName {
const messageClass = messageClassEncoding(dataCoding);
if (messageClass) return messageClass;
if (dataCoding === 0x08) return 'UCS2';
// 0x02 and 0x04 are 8-bit binary, 0x03 is Latin-1.
return dataCoding >= 0x02 && dataCoding <= 0x04 ? 'LATIN1' : 'ASCII';
}
/**
* The `data_coding` an alphabet is written under, the mirror of `encodingByDataCoding()`. GSM 03.38
* takes 0x00, the SMSC default alphabet, rather than SMPP 3.4 5.2.19's 0x01, which is IA5.
*/
export const dataCodingByEncoding: Readonly<Record<EncodingName, number>> = {
ASCII: 0x00,
LATIN1: 0x03,
UCS2: 0x08,
};