【JS体操第3問ヒント④】「コードポイント」を「文字列」に変換する22の方法

こんにちは!面白プロデュース事業部のおばらです。
今回は JS体操第3問「Zalgo Text の生成」の問題のヒントにもなるかもしれないシリーズ第4弾。

JavaScript で「Unicode のコードポイント」を「文字列」に変換する方法を、実用性のあるものからほぼネタのものまで22通りご紹介します。


JS体操第3問「Zalgo Text の生成」 とこれまでのヒント記事①②③については以下をご覧ください。

JS体操第3問「Zalgo Text の生成」
JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com

techblog.kayac.com


目次


コードポイントとは

「コードポイント」は文字コードで文字に紐づく固有の数字、ID のことです。詳しくは以下の記事をご覧ください。 techblog.kayac.com

以降本記事では「コードポイント」は「Unicode のコードポイント」を指します。


コードポイントを文字列に変換する22の方法

サンプルコードの説明

本記事で紹介するコードポイントを文字列に変換する方法は、以下のざっくりなテストを行っています。ざっくりなテストしか行っていないのでコードポイントの組み合わせや順番によっては変換が上手くいかない場合があるかもしれませんがご了承ください。

async function test(fromCodePoint) {
  for (let p = 0; p <= 0x10FFFF;) {
    const codePoints = [];

    for (let i = 0; i < 100; ++i, ++p) {
      if (p < 0x000000 || 0x10FFFF < p) { // Unicode 範囲外
        break;
      }

      if (
          (0x000000 <= p && p <= 0x00001F) || // 制御文字(Control Characters)
          (0x00007F === p                ) || // 制御文字(DEL)
          (0x000080 <= p && p <= 0x00009F) || // 制御文字(PAD, HOP, BPH, NBH, IND, NEL, SSA, ESA, HTS, HTJ, VTS, PLD, PLU, RI, SS2, SS3DCS, PU1, PU2, STS, CCH, MW, SPA, EPA, SOS, SGCI, SCI, CSI, ST, OSC, PM, APC)
          (0x00D800 <= p && p <= 0x00DB7F) || // 上位サロゲート(High Surrogates)
          (0x00DB80 <= p && p <= 0x00DBFF) || // 上位私用サロゲート(High Private Use Surrogates)
          (0x00DC00 <= p && p <= 0x00DFFF) || // 下位サロゲート(Low Surrogates)
          (0x00E000 <= p && p <= 0x00F8FF) || // 私用領域(Private Use Area)
          (0x00FFF0 <= p && p <= 0x00FFFF) || // 特殊用途文字(Specials)
          (0x0F0000 <= p && p <= 0x0FFFFF) || // 補助私用領域A(Supplementary Private Use Area-A)
          (0x100000 <= p && p <= 0x10FFFF)    // 補助私用領域B(Supplementary Private Use Area-B)
      ) {
        ; // 何もしない
      } else {
        codePoints.push(p);
      }
    }

    const a = String.fromCodePoint(...codePoints);
    const b = await fromCodePoint(...codePoints);

    console.assert(a === b, codePoints);
    console.log('Testing...');
  }

  console.log('COMPLETE');
}

test(fromCodePoint);

※ ネストが深くなり読みづらくなるのを避けるため一部ブラウザでは非対応の Promise.withResolvers() を使用しています
※ 最新の Chrome でのみ確認しています

developer.mozilla.org ja.wikipedia.org


① String.fromCodePoint()

まずは王道、ビルトインで用意されている String.fromCodePoint() を素直に使いましょう。

String.fromCodePoint(
  171581, 12362, 12356, 12375, 12356, 128031,
); // => '𩸽おいしい🐟'

developer.mozilla.org


② eval() + Unicode エスケープシーケンス

\x12\u1234\u{12345} などの「Unicode エスケープシーケンス」を動的に作ってみます。

「Unicode エスケープシーケンス」には以下の3つの記法があります。

  • 16進数で2桁の場合は \x12
  • 16進数で4桁の場合は \u1234
  • 16進数で2桁でも4桁でもない場合は \u{12345}


普通に文字列結合するとうまくいきいません。

'\\u' + '1234' // => '\\u1234'

テンプレートリテラルでも無理。

`\\u${1234}` // => '\\u1234'


よって動的に生成した文字列を、eval() で JavaScript の(静的な)文字列として評価します。

eval(`\\u${1234}`); // => 'ሴ'


汎用的にすると以下です。

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');

  return eval(`"${ str }"`);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を eval() にわたすのは危険なのでやめましょう

developer.mozilla.org


③ Function() + Unicode エスケープシーケンス

Function コンストラクタも文字列を JavaScript として評価してくれます。

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');
  const fnc = Function(`return "${ str }";`);

  return fnc();
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を Function() にわたすのは危険なのでやめましょう

developer.mozilla.org


④ setTimeout() + Unicode エスケープシーケンス

setTimeout() も文字列を JavaScript として評価してくれます。非同期になってしまうのが厄介ですが、無理やり使ってみましょう。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();
  const globalResolveAlias = `__resolve_${ Date.now() }_${ Math.random()*1e9 | 0 }__`; // あんまりかぶらなそうなエイリアス名
  const str = codePoints.map(n => `\\u{${n.toString(16)}}`).join('');

  window[globalResolveAlias] = resolve; // setTimeout() に渡した文字列はグローバルコンテキストで評価されるので

  setTimeout(`${ globalResolveAlias }("${ str }")`);

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、特に第三者が作成した文字列を setTimeout() にわたすのは危険なのでやめましょう
setInterval でも頑張ればできそうですが本記事では割愛します

developer.mozilla.org developer.mozilla.org


⑤ JSON.parse() + Unicode エスケープシーケンス

まず、JSON の仕様を JSON RFC 8259 Section 7 で確認してみます。

JSON ではコードポイントが 0x000xFF までの「基本多言語面(BMP: Basic Multilingual Plane)」の文字は \u1234 のような(6文字の)エスケープ記法で記述できます。

Any character may be escaped. If the character is in the Basic Multilingual Plane (U+0000 through U+FFFF), then it may be represented as a six-character sequence: a reverse solidus, followed by the lowercase letter u, followed by four hexadecimal digits that encode the character's code point.

一方コードポイントが「基本多言語面(BMP: Basic Multilingual Plane)」以外の文字(コードポイントが 0xFF を超える文字)は UTF-16 のサロゲートペアで、つまり \uD867\uDE3D のような(12文字の)エスケープ記法で記述できます。

To escape an extended character that is not in the Basic Multilingual Plane, the character is represented as a 12-character sequence, encoding the UTF-16 surrogate pair.

試しに JavaScript の文字列で使える前述の3つの Unicode エスケープ記法を JSON.parse() に渡してみます。仕様通り \u0041 の16進数4桁限定の記法のみをパースしてくれます。

JSON.parse('"\\x41"'); // => SyntaxError: Bad escaped character in JSON
JSON.parse('"\\u0041"'); // => 'A'
JSON.parse('"\\u{41}"'); // => SyntaxError: Bad Unicode escape in JSON

「𩸽」(U+29E3D)でも試してみましょう。やはり16進数表記が4桁を超える場合は UTF-16 のサロゲートペアの2つのコードユニットで記述する必要があります。

JSON.parse('"\\u{29E3D}"'); // => SyntaxError: Bad Unicode escape in JSON
JSON.parse('"\\uD867\\uDE3D"'); // => '𩸽'


以上より、0x10000 以上のコードポイントを UTF-16 のサロゲートペア、つまり2つの UTF-16 のコードユニットに分割したうえで、\u1234 のような4桁限定の Unicode エスケープシーケンス記法で書き換えて JSON.parse() に渡してみます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const str = codeUnits.map(n => `\\u${n.toString(16).padStart(4, '0')}`).join('');

  return JSON.parse(`"${ str }"`);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

www.rfc-editor.org developer.mozilla.org ja.wikipedia.org


⑥ CSS + content プロパティ + Unicode エスケープシーケンス

CSS の content プロパティにも Unicode エスケープシーケンスは使えます。CSS での Unicode エスケープシーケンスの記法は \12\123\1234\12345 のように Unicode のコードポイントの16進数表記の左にバックスラッシュ \ のみを加えたものです。(u{} は不要)

const style = document.createElement('style');

document.head.appendChild(style);

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => '\\' + n.toString(16)).join('');

  style.textContent = `
    body::after {
      content: "${ str }";
    }
  `.trim();

  const content = document.defaultView.getComputedStyle(
    document.body,
    '::after',
  ).content;

  return (
    content
      .replace(/^"/, '')
      .replace(/"$/, '')
      .replace(/\\"/g, '"')
      .replace(/\\\\/g, '\\')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'


content プロパティは before / after 疑似要素専用のプロパティではないので疑似要素を介さず、例えば直接 document.body.stylecontent プロパティを使っても良いです。

const style = document.body.style;

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => '\\' + n.toString(16)).join('');

  style.content = `"${ str }"`;

  return (
    style.content
      .replace(/^"/, '')
      .replace(/"$/, '')
      .replace(/\\"/g, '"')
      .replace(/\\\\/g, '\\')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑦ DOMParser + HTML 文字参照

「Unicode エスケープシーケンス」に代わり、ここからしばらくは「HTML 文字参照(HTML Character Reference)」シリーズです。
コードポイントを &#1234; のような「数値文字参照(Numeric Character Reference)」にエンコードし、それを DOMParser にデコードさせてみましょう。

function fromCodePoint(...codePoints) {
  return new DOMParser().parseFromString(
    codePoints.map(n => `&#${n};`).join(''),
    'text/html',
  ).body.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

なお、よくわからない文字列、第三者が入力した文字列を安易に HTML としてパースし、生成されたノードを元のコンテキストなどに挿入するのは危険です。以下にその例を示します。

document.body.appendChild(new DOMParser().parseFromString(
  '<img src="" onerror="alert(`hoge`)">',
  'text/html',
).body.firstChild); // => alert() (などの JavaScript)が実行されてしまう

developer.mozilla.org ja.wikipedia.org developer.mozilla.org


⑧ innerHTML + HTML 文字参照

DOMParser の代わりに innerHTML も使えます。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.innerHTML = codePoints.map(n => `&#${n};`).join('');

  return (
    div.innerHTML
      .replace(/&amp;/g,  '&')
      .replace(/&lt;/g,   '<')
      .replace(/&gt;/g,   '>')
      .replace(/&nbsp;/g, '\xa0')
  );
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

パース後の文字列を取り出すのに innerHTML を使うと&amp;&lt;&gt;&nbsp; を特別扱いする必要がありますが textContent を使うとその必要はなくなります。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.innerHTML = codePoints.map(n => `&#${n};`).join('');

  return div.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

ちなみに入力に textContent を使うと、innerHTML or textContent のどちらで取得しても「HTML 文字参照」のままになります。

※ よくわからない文字列、第三者が入力した文字列を安易に innerHTML に突っ込むのは危険です。ちなみに渡された文字列を無害化(Sanitize)する setHTML() メソッドは Experimental → Deprecated となったようです

developer.mozilla.org developer.mozilla.org


⑨ setHTMLUnsafe() + HTML 文字参照

innerHTML の代わりに setHTMLUnsafe() でも良いでしょう。

const div = document.createElement('div');

function fromCodePoint(...codePoints) {
  div.setHTMLUnsafe(codePoints.map(n => `&#${n};`).join(''));

  return div.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に setHTMLUnsafe() に渡すのは("Unsafe" の名のとおり)危険です

developer.mozilla.org


⑩ createContextualFragment() + HTML 文字参照

RangecreateContextualFragment() でも。

const range = document.createRange();

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => `&#${n};`).join('');
  const df = range.createContextualFragment(str);

  return df.textContent;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に createContextualFragment() に渡すのは危険です

developer.mozilla.org developer.mozilla.org


⑪ iframe + HTML 文字参照

iframe も試してみましたが 半角スペース '&#32;'(U+0020)が空文字 '' となってしまいます。 U+0020 の「文字実体参照(Character Entity Reference)」があれば良いのですが、残念ながら「数値文字参照(Numeric Character Reference)」しかありません。 (※ ちなみに '&nbsp;' は U+0020 ではなく U+00A0 の文字実体参照です)

そこで '&#32;'(U+0020)が '' になってしまう問題はかなり強引に hogehogehoge で解決してみました。

const iframe = document.createElement('iframe');

document.body.appendChild(iframe);

const idoc = iframe.contentDocument;

function fromCodePoint(...codePoints) {
  const str = codePoints.map(n => n === 32 ? 'hogehogehoge' : `&#${n};`).join('');

  idoc.open();
  idoc.write(str);
  idoc.close();

  return idoc.body.innerText.replace('hogehogehoge', ' ');
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ よくわからない文字列、第三者が入力した文字列を安易に HTML としてパースするのは危険です

developer.mozilla.org developer.mozilla.org ja.wikipedia.org

ja.wikipedia.org


⑫ UTF-16 エンコード + String.fromCharCode()

コードポイントを UTF-16 エンコードすれば、つまり UTF-16 のコードユニットに変換すれば String.fromCharCode() が使えます。 0x10000 以上のコードポイントを UTF-16 のサロゲートペア、つまり2つのコードユニットに分割します。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  return String.fromCharCode(...codeUnits);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org ja.wikipedia.org ja.wikipedia.org


⑬ UTF-16 エンコード + FileReader

非同期にはなってしまいますが FileReader も使えそうです。 FileReader が UTF-32 でパースしてくれたら楽だったのですが、仕様的に難しそう(たぶん)なので UTF-16 にエンコードしちゃいましょう。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();

  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const bin = new Uint16Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const reader = new FileReader;

  reader.onload = () => {
    resolve(reader.result);
  };
  reader.readAsText(blob, 'UTF-16');

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org developer.mozilla.org developer.mozilla.org


⑭ UTF-8 エンコード + FileReader

FileReader を使うなら UTF-8 にエンコードしても良さそうですね。UTF-16 にエンコードするより面倒ですが。
UTF-8 の仕様は https://en.wikipedia.org/wiki/UTF-8 の以下の図を参考にします。

function fromCodePoint(...codePoints) {
  const { promise, resolve } = Promise.withResolvers();

  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const reader = new FileReader;

  reader.onload = () => {
    resolve(reader.result);
  };
  reader.readAsText(blob, 'UTF-8');

  return promise;
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

en.wikipedia.org


⑮ UTF-8 エンコード + Blob + URL.createObjectURL() + fetch()

UTF-8 なら Blob から URL.createObjectURL() で動的に「オブジェクト URL」を生成し、それを fetch() しても良さそうです。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob(
    [bin],
    { type: 'text/plain' },
  );
  const url = URL.createObjectURL(blob);

  return fetch(url).then(res => res.text());
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ 実務では不要になった「オブジェクト URL」を URL.revokeObjectURL() で解放するのを忘れずに

developer.mozilla.org developer.mozilla.org


⑯ UTF-8 エンコード + Blob + URL.createObjectURL() + XMLHttpRequest

fetch() を(非同期/同期の指定が可能な)XMLHttpRequest で置き換えれば非同期ではなく同期にできますね。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob([bin], { type: 'text/plain' });
  const url = URL.createObjectURL(blob);
  const xhr = new XMLHttpRequest;

  xhr.open('GET', url, false);
  xhr.send(null);

  return xhr.responseText;
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ メインのスレッドで XMLHttpRequest を同期で使うとブラウザが一時的に固まるなどの可能性があるので控えましょう

developer.mozilla.org


⑰ UTF-8 エンコード + Blob + text()

回りくどいことをしてきましたが、UTF-8 であれば Blob の(UTF-8 専用の)text() で良いですね。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const bin = new Uint8Array(codeUnits);
  const blob = new Blob([bin], { type: 'text/plain' });

  return blob.text();
}

await fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑱ UTF-8 エンコード + TextDecoder

なんと TextDecoder を使えばシンプルに、且つ同期での変換が可能です。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const arr = new Uint8Array(codeUnits);
  const utf8 = new TextDecoder();

  return utf8.decode(arr);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

developer.mozilla.org


⑲ UTF-16 エンコード + TextDecoder

UTF-16 でも TextDecoder にパースしてもらえます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const arr = new Uint16Array(codeUnits);
  const utf16 = new TextDecoder('UTF-16');

  return utf16.decode(arr);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

じゃあもしかして TextDecoder は UTF-32 でもパースしてくれるかな?と試してみると、、、

new TextDecoder('UTF-32'); // => RangeError: Failed to construct 'TextDecoder': The encoding label provided ('UTF-32') is invalid.

残念。

developer.mozilla.org


⑳ UTF-16 エンコード + % エンコード + unescape()

%XX%uXXXX などの「% エンコード」を使ってみます。 unescape() は UTF-16 のコードユニットを「% エンコード」した文字列をデコードします。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if (0x10000 <= n) {
      n -= 0x10000;

      return [
        0b110110_0000000000 | (n >> 10 & 0b1111111111),
        0b110111_0000000000 | (n >>  0 & 0b1111111111),
      ];
    } else {
      return n;
    }
  });

  const str = codeUnits.map(n => `%u${n.toString(16).padStart(4, '0')}`).join('');

  return unescape(str);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

※ ただしunescape() は「非標準」の関数で現在は(ほぼ)「非推奨(Deprecated)」です。

developer.mozilla.org


㉑ UTF-8 エンコード + % エンコード + decodeURIComponent()

unescape() は「非標準」なので代わりに decodeURIComponent() を使ってみます。

unescape() が UTF-16 のコードユニットを「% エンコード」した文字列をデコードするのと違い
decodeURIComponent() は UTF-8 のコードユニットを「% エンコード」した文字列をデコードすることに注意してください。

「% エンコード」の記法も %XX のみです。

unescape('%u0041'); // => 'A'
decodeURIComponent('%u0041'); // => URIError: URI malformed
unescape('%41'); // => 'A'
decodeURIComponent('%41'); // => 'A'
function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const str = codeUnits.map(n => `%${n.toString(16).padStart(2, '0')}`).join('');

  return decodeURIComponent(str);
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

decodeURI() は一部の文字をデコードしないことにも注意が必要です。

ja.wikipedia.org developer.mozilla.org developer.mozilla.org


㉒ UTF-8 エンコード + % エンコード + URLSearchParams

いよいよ最後!
URLSearchParams も「% エンコード」された文字列をデコードしてくれます。

function fromCodePoint(...codePoints) {
  const codeUnits = codePoints.flatMap(n => {
    if        (n <= 0x00007F) {
      return n;
    } else if (n <= 0x0007FF) {
      return [
        (n >>  6) & 0b000_11111 | 0b110_00000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x00FFFF) {
      return [
        (n >> 12) & 0b0000_1111 | 0b1110_0000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    } else if (n <= 0x10FFFF) {
      return [
        (n >> 18) & 0b00000_111 | 0b11110_000,
        (n >> 12) & 0b00_111111 | 0b10_000000,
        (n >>  6) & 0b00_111111 | 0b10_000000,
        (n >>  0) & 0b00_111111 | 0b10_000000,
      ];
    }
  });

  const str = codeUnits.map(n => `%${n.toString(16).padStart(2, '0')}`).join('');
  const params = new URLSearchParams(`q=${str}`);

  return params.get('q');
}

fromCodePoint(171581, 12362, 12356, 12375, 12356, 128031); // => '𩸽おいしい🐟'

ちなみに URLhashsearch(の getter)は「% エンコード」された文字列をデコードしてくれませんでした。

const url = new URL('https://www.kayac.com/?q=%F0%A9%B8%BD');

url.search; // => '?q=%F0%A9%B8%BD'
url.searchParams.get('q'); // => '𩸽'
const url = new URL('https://www.kayac.com/');

url.search = '?q=%F0%A9%B8%BD';
url.searchParams.get('q'); // => '𩸽'
const url = new URL('https://www.kayac.com/#%F0%A9%B8%BD');

url.hash; // => '#%F0%A9%B8%BD'

developer.mozilla.org



まとめ

とても長くなりましたが最後まで読んでいただき、ありがとうございます。

今回は「コードポイント」を「文字列」にする22の方法をまとめてみました。 のっぴきならない理由で String.fromCodePoint() が使えない、使いたくない、という方には有用な記事となっております。 変換する方法はまだまだあると思うので、他にもこんな面白い方法がある!という方はぜひ教えてください。

本記事の内容は、もしかしたら現在開催中のJS体操第3問「Zalgo Textの生成」のヒントにもなるかもしれません。

『JS体操』とは面白法人カヤックが主催する JavaScript のコードゴルフ大会。
その第3問は任意の文字列を「Zalgo Text」に変換する JavaScript の長〜いコードをできるだけ短くする!というお題です。 元々2222文字もあるコードを、なんと115文字以下まで減らせます。(現状の最短記録は114文字です)

みなさまのご参加をお待ちしております!

JS体操第3問「Zalgo Text の生成」

JS体操第3問「Zalgo Text の生成」編集画面 JS体操第3問「Zalgo Text の生成」編集画面(エラー)

hubspot.kayac.com

techblog.kayac.com

techblog.kayac.com

techblog.kayac.com