/** * Pure, DOM-free SMS encoding + segmentation helpers. * * SMS messages are plain text, but how many "segments" (billable parts) a * message costs depends on its characters: * * - **GSM-7**: the default 7-bit alphabet. 160 chars in a single segment, 153 * per segment once the message is split (the extra 7 bits per segment go to * the multipart UDH header). A handful of characters (`^ { } \ [ ] ~ | €` and * form-feed) live in an "extension table" and cost **two** characters each. * - **UCS-2**: used when any character is outside GSM-7 (e.g. emoji, many * non-Latin scripts). 70 chars single, 67 per segment when split. Counted in * UTF-16 code units, so emoji built from surrogate pairs cost two. * * Reference: 3GPP TS 23.038. */ /** Which physical encoding the carrier will use for the message. */ export type SmsEncoding = 'GSM-7' | 'UCS-2'; /** A full segmentation report for a piece of text. */ export interface SmsSegmentInfo { /** Encoding the message forces. */ encoding: SmsEncoding; /** Weighted character count (GSM-7 extension chars count as 2). */ length: number; /** Number of billable segments. */ segments: number; /** Capacity of a single segment for the active encoding (160 or 70). */ maxSingle: number; /** Capacity per segment once the message is multipart (153 or 67). */ maxMulti: number; /** Characters left before the next segment boundary. */ remaining: number; } /** GSM-7 default alphabet (each char costs one septet). Includes space. */ const GSM7_BASIC = new Set( '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'.split( '', ), ); /** GSM-7 extension table (each char costs two septets: ESC + char). */ const GSM7_EXTENDED = new Set(['^', '{', '}', '\\', '[', ']', '~', '|', '€', '\f']); const SINGLE = { 'GSM-7': 160, 'UCS-2': 70 } as const; const MULTI = { 'GSM-7': 153, 'UCS-2': 67 } as const; /** * Determine whether `text` can be sent as GSM-7 or must fall back to UCS-2. */ export function detectEncoding(text: string): SmsEncoding { for (const ch of text) { if (!GSM7_BASIC.has(ch) && !GSM7_EXTENDED.has(ch)) { return 'UCS-2'; } } return 'GSM-7'; } /** * Weighted length of `text` for the given encoding. In GSM-7, extension-table * characters count as two. In UCS-2, the count is in UTF-16 code units (so a * surrogate-pair emoji counts as two). */ export function countChars(text: string, encoding: SmsEncoding): number { if (encoding === 'UCS-2') { return text.length; } let count = 0; for (const ch of text) { count += GSM7_EXTENDED.has(ch) ? 2 : 1; } return count; } /** * Produce a full segmentation report for `text`: encoding, weighted length, * segment count, per-segment capacity and remaining characters. */ export function segment(text: string): SmsSegmentInfo { const encoding = detectEncoding(text); const length = countChars(text, encoding); const maxSingle = SINGLE[encoding]; const maxMulti = MULTI[encoding]; let segments: number; if (length === 0) { segments = 0; } else if (length <= maxSingle) { segments = 1; } else { segments = Math.ceil(length / maxMulti); } const capacity = segments <= 1 ? maxSingle : segments * maxMulti; const remaining = segments === 0 ? maxSingle : capacity - length; return { encoding, length, segments, maxSingle, maxMulti, remaining }; }