cjk.ts

5.8 KB · raw

1/**2 * CJK Phrase Encoding3 * -------------------4 *5 * Packs arbitrary binary data into a sequence of CJK Unified Ideographs.6 *7 * Each character encodes exactly 14 bits. The alphabet is the first 16,3848 * code points starting at U+4E00 (一), ending at U+8DFF. This is a fixed9 * power-of-two subset chosen for maximum font coverage across desktop and10 * mobile operating systems.11 *12 * Power-of-two alignment (2^14 = 16,384) means there is no modulo bias, no13 * padding at the character level, and `codepoint = ALPHABET_START + index`14 * is a direct arithmetic mapping — no lookup table required.15 *16 * NOTE ON SPEC DEVIATION: The implementation spec cites ALPHABET_END as17 * U+87FF, which is inconsistent with ALPHABET_SIZE = 16384 (U+4E00 + 1638418 * lands at U+8DFF, not U+87FF). The power-of-two invariant is load-bearing,19 * so this file treats U+8DFF as the correct upper bound. See the project20 * summary note for details.21 */2223export const ALPHABET_START = 0x4e00;24export const ALPHABET_SIZE = 16384; // 2^14 — every character packs exactly 14 bits25export const ALPHABET_END = ALPHABET_START + ALPHABET_SIZE - 1; // 0x8DFF2627/**28 * Number of meaningful bits in the final CJK character of an encoding.29 * `totalBits = byteCount * 8`. If that is an exact multiple of 14 the last30 * character is fully used and this returns 0. Otherwise it returns the31 * remainder in the range 1..13.32 */33export function remainingBits(byteCount: number): number {34  const r = (byteCount * 8) % 14;35  return r;36}3738/**39 * Encode a byte array into a CJK phrase string.40 *41 * Bytes are fed into a bit accumulator 8 bits at a time. Whenever the42 * accumulator holds at least 14 bits, the top 14 are extracted and emitted43 * as one character. Any leftover bits at the end become the most significant44 * bits of one final character, with the low bits zero-padded. The decoder45 * uses `remainingBits` to discard that padding.46 */47export function cjkEncode(bytes: Uint8Array): string {48  let bits = 0; // number of meaningful bits currently in the accumulator49  let buffer = 0; // bit accumulator — at peak can hold up to 21 bits50  const out: string[] = [];5152  for (let i = 0; i < bytes.length; i++) {53    buffer = (buffer << 8) | bytes[i];54    bits += 8;5556    while (bits >= 14) {57      bits -= 14;58      const index = (buffer >>> bits) & 0x3fff;59      out.push(String.fromCodePoint(ALPHABET_START + index));60    }61  }6263  if (bits > 0) {64    // Leftover 1..13 bits: left-align into a 14-bit slot (zero-pad low bits)65    const index = (buffer << (14 - bits)) & 0x3fff;66    out.push(String.fromCodePoint(ALPHABET_START + index));67  }6869  return out.join("");70}7172/**73 * Decode a CJK phrase string back into bytes. `remainingBitsValue` must be74 * the value computed by `remainingBits(originalByteLength)` at encode time,75 * and is stored in the binary header at §2.1.76 *77 * Throws on any character outside the 16,384-entry alphabet, or if the78 * phrase length is inconsistent with `remainingBitsValue`.79 */80export function cjkDecode(phrase: string, remainingBitsValue: number): Uint8Array {81  let bits = 0;82  let buffer = 0;83  const out: number[] = [];8485  // Spread handles surrogate pairs safely — every CJK ideograph in our86  // alphabet fits in a single BMP code unit but we spread defensively.87  const chars = [...phrase];88  const lastIdx = chars.length - 1;8990  for (let i = 0; i < chars.length; i++) {91    const cp = chars[i].codePointAt(0)!;92    const index = cp - ALPHABET_START;9394    if (index < 0 || index >= ALPHABET_SIZE) {95      throw new Error(96        `INVALID_CHARACTER: U+${cp.toString(16).toUpperCase().padStart(4, "0")} at position ${i}`97      );98    }99100    const isLast = i === lastIdx;101    const bitsInThisChar = isLast && remainingBitsValue > 0 ? remainingBitsValue : 14;102103    // The encoder left-aligned partial bits into the 14-bit slot; undo that104    // by right-shifting to discard the zero-padding on the last character.105    const value =106      isLast && remainingBitsValue > 0 ? index >>> (14 - remainingBitsValue) : index;107108    buffer = (buffer << bitsInThisChar) | value;109    bits += bitsInThisChar;110111    while (bits >= 8) {112      bits -= 8;113      out.push((buffer >>> bits) & 0xff);114    }115  }116117  if (bits !== 0) {118    throw new Error("DECODE_ALIGNMENT_ERROR: residual bits after full decode");119  }120121  return new Uint8Array(out);122}123124/**125 * True if every code point in `phrase` is within the 16,384-entry alphabet.126 * Used by the paste-validator before attempting a full decode.127 */128export function isValidPhraseCharset(phrase: string): boolean {129  for (const ch of phrase) {130    const cp = ch.codePointAt(0)!;131    if (cp < ALPHABET_START || cp > ALPHABET_END) return false;132  }133  return true;134}135136/**137 * Decode the first `charCount` characters of a phrase assuming they all use138 * the full 14 bits (remaining_bits = 0), and return whatever bytes fully139 * accumulated. Residual sub-byte bits are discarded — this is the bootstrap140 * path used by the import pipeline to read the binary header before the141 * true `remaining_bits` value is available.142 *143 * Throws on any character outside the alphabet. Unlike `cjkDecode` this does144 * NOT enforce that the accumulator lands on an 8-bit boundary, because the145 * caller is slicing out of the middle of a longer phrase.146 */147export function cjkDecodeBootstrap(phrase: string, charCount: number): Uint8Array {148  let bits = 0;149  let buffer = 0;150  const out: number[] = [];151152  const chars = [...phrase];153  const n = Math.min(charCount, chars.length);154155  for (let i = 0; i < n; i++) {156    const cp = chars[i].codePointAt(0)!;157    const index = cp - ALPHABET_START;158    if (index < 0 || index >= ALPHABET_SIZE) {159      throw new Error(160        `INVALID_CHARACTER: U+${cp.toString(16).toUpperCase().padStart(4, "0")} at position ${i}`161      );162    }163    buffer = (buffer << 14) | index;164    bits += 14;165166    while (bits >= 8) {167      bits -= 8;168      out.push((buffer >>> bits) & 0xff);169    }170  }171172  return new Uint8Array(out);173}