小道具：合成用の濁点(U+3099)と半濁点(U+309A)を排除する

macOS の標準アプリで日本語テキストを弄っていると何故か合成用の濁点(U+3099)や半濁点(U+309A)が入り込んできます。合成用の濁点や半濁点で文字化けする相手向けに変換が必要になったので作ってみました。

# HTML+JavaScript版

濁点(U+3099)と半濁点(U+309A)は解析部分（出力と同じ文字列）で色を付けています。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="dyjVaBN" data-user="ikiuo" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/ikiuo/pen/dyjVaBN">
  合成用(半)濁点文字[U+3099,U+309A]の変換</a> by Ikiuo (<a href="https://codepen.io/ikiuo">@ikiuo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

動作確認は Google Chrome バージョン: 108.0.5359.98 (macOS版)で行っています。

<details><summary>HTML スクリーンショット</summary><div>

![スクリーンショット.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226677/89d1f33d-202f-5902-de14-19a796a25db6.png)

</div></details>

```html:dakuten.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>合成用の濁点と半濁点を排除する</title>
  </head>
  <body>
    <h1>合成用の濁点と半濁点を排除する</h1>
    <table border="1">
      <tr>
        <th>入力</th>
        <td>
          <textarea id="inp" rows="12" cols="80" oninput="onInput()"></textarea>
        </td>
      </tr>
      <tr><th>出力</th><td id="out"></td></tr>
      <tr><th>解析</th><td id="chk"></td></tr>
    </table>
    <script>

      const dakuten = {
          'ヽ':'ヾ', 'ゝ':'ゞ',

          'か':'が', 'き':'ぎ', 'く':'ぐ', 'け':'げ', 'こ':'ご',
          'さ':'ざ', 'し':'じ', 'す':'ず', 'せ':'ぜ', 'そ':'ぞ',
          'た':'だ', 'ち':'ぢ', 'つ':'づ', 'て':'で', 'と':'ど',
          'は':'ば', 'ひ':'び', 'ふ':'ぶ', 'へ':'べ', 'ほ':'ぼ',

          'ウ':'ヴ',
          'カ':'ガ', 'キ':'ギ', 'ク':'グ', 'ケ':'ゲ', 'コ':'ゴ',
          'サ':'ザ', 'シ':'ジ', 'ス':'ズ', 'セ':'ゼ', 'ソ':'ゾ',
          'タ':'ダ', 'チ':'ヂ', 'ツ':'ヅ', 'テ':'デ', 'ト':'ド',
          'ハ':'バ', 'ヒ':'ビ', 'フ':'ブ', 'ヘ':'ベ', 'ホ':'ボ',

          '〱':'〲', '〳':'〴',
          'う':'ゔ',
          'ワ':'ヷ', 'ヰ':'ヸ', 'ヱ':'ヹ', 'ヲ':'ヺ',
      }

      const handakuten = {
          'は':'ぱ', 'ひ':'ぴ', 'ふ':'ぷ', 'へ':'ぺ', 'ほ':'ぽ',
          'ハ':'パ', 'ヒ':'ピ', 'フ':'プ', 'ヘ':'ペ', 'ホ':'ポ',
      }

      function onInput()
      {
          const tagInp = document.getElementById('inp');
          const tagOut = document.getElementById('out');

          let inp = tagInp.value;
          let out = '';
          let chk = [];
          for (let i = 0; i < inp.length; i++) {
              const code = inp.charCodeAt(i);

              if (code == 0x3099) {
                  let addchr = '゛'
                  if (i) {
                      const pre = dakuten[inp[i - 1]];
                      if (pre) {
                          out = out.slice(0, out.length - 1);
                          chk.pop();
                          addchr = pre;
                      }
                  }
                  chk.push(1);
                  out += addchr
                  continue;
              }
              if (code == 0x309a) {
                  let addchr = '゜';
                  if (i) {
                      const pre = handakuten[inp[i - 1]];
                      if (pre) {
                          out = out.slice(0, out.length - 1);
                          chk.pop();
                          addchr = pre;
                      }
                  }
                  chk.push(2);
                  out += addchr;
                  continue;
              }

              out += inp[i];
              chk.push(0);
          }
          tagOut.innerText = out;

          const tagChk = document.getElementById('chk');

          while (tagChk.firstChild)
              tagChk.removeChild(tagChk.firstChild);

          const appendSpan = function(msg, flg) {
              const span = document.createElement('span');
              if (flg) {
                  span.style.setProperty('color', '#000');
                  span.style.setProperty('background-color', '#f88');
              }
              span.innerText = msg;
              tagChk.append(span);
          }

          let last = 0;
          for (let i = 1; i < out.length; i++) {
              if (chk[i - 1] == chk[i])
                  continue;
              appendSpan(out.slice(last, i), chk[i - 1]);
              last = i;
          }
          if (last < out.length)
              appendSpan(out.slice(last, out.length), chk[out.length - 1]);
      }

    </script>
  </body>
</html>
```

# Python 版

```python:dakuten.py
#!/usr/bin/env python3

import argparse
import sys

parser = argparse.ArgumentParser()
parser.add_argument('-N', '--CHR', action='store_true', default=False, help='濁点と半濁点の合成用文字を通常文字にする')
parser.add_argument('-J', '--JIS', action='store_true', default=False, help='JIS X 0208 を濁点・半濁点変換の対象にする')
parser.add_argument('INPUT', help='入力ファイル (\'-\' で標準入力)')
parser.add_argument('OUTPUT', help='出力ファイル (\'-\' で標準出力)')

args = parser.parse_args()

onlychar = args.CHR
inpfile = args.INPUT
outfile = args.OUTPUT

ifp = sys.stdin if inpfile == '-' else open(inpfile, 'r')
ofp = sys.stdout if outfile == '-' else open(outfile, 'w')


# JIS X 0208
dakuten_jisx0208 = {
    'ヽ': 'ヾ', 'ゝ': 'ゞ',

    'か': 'が', 'き': 'ぎ', 'く': 'ぐ', 'け': 'げ', 'こ': 'ご',
    'さ': 'ざ', 'し': 'じ', 'す': 'ず', 'せ': 'ぜ', 'そ': 'ぞ',
    'た': 'だ', 'ち': 'ぢ', 'つ': 'づ', 'て': 'で', 'と': 'ど',
    'は': 'ば', 'ひ': 'び', 'ふ': 'ぶ', 'へ': 'べ', 'ほ': 'ぼ',

    'ウ': 'ヴ',
    'カ': 'ガ', 'キ': 'ギ', 'ク': 'グ', 'ケ': 'ゲ', 'ケ': 'ゲ',
    'サ': 'ザ', 'シ': 'ジ', 'ス': 'ズ', 'セ': 'ゼ', 'ソ': 'ゾ',
    'タ': 'ダ', 'チ': 'ヂ', 'ツ': 'ヅ', 'テ': 'デ', 'ト': 'ド',
    'ハ': 'バ', 'ヒ': 'ビ', 'フ': 'ブ', 'ヘ': 'ベ', 'ホ': 'ボ',
}

# CJK Miscellaneous
dakuten_unicode = {
    '〱': '〲', '〳': '〴',
    'う': 'ゔ',
    'ワ': 'ヷ', 'ヰ': 'ヸ', 'ヱ': 'ヹ', 'ヲ': 'ヺ',
}

dakuten = dakuten_jisx0208
if not args.JIS:
    for k in dakuten_unicode:
        dakuten[k] = dakuten_unicode[k]

handakuten = {
    'は': 'ぱ', 'ひ': 'ぴ', 'ふ': 'ぷ', 'へ': 'ぺ', 'ほ': 'ぽ',
    'ハ': 'パ', 'ヒ': 'ピ', 'フ': 'プ', 'ヘ': 'ペ', 'ホ': 'ポ',
}

for line in ifp.readlines():
    new_line = ''
    for ch in line:
        code = ord(ch)
        if code == 0x3099:
            if not onlychar and new_line and new_line[-1] in dakuten:
                new_line = new_line[:-1] + dakuten[new_line[-1]]
                continue
            new_line += '゛'
            continue
        if code == 0x309a:
            if not onlychar and new_line and new_line[-1] in handakuten:
                new_line = new_line[:-1] + handakuten[new_line[-1]]
                continue
            new_line += '゜'
            continue

        new_line += ch
    ofp.write(new_line)
```

**合成用濁点と半濁点を使ったサンプル**

```text:sample.txt
ヾ ゞ

が ぎ ぐ げ ご
ざ じ ず ぜ ぞ
だ ぢ づ で ど
ば び ぶ べ ぼ

ヴ
ガ ギ グ ゲ ゲ
ザ ジ ズ ゼ ゾ
ダ ヂ ヅ デ ド
バ ビ ブ ベ ボ

〱゙ 〳゙
ゔ
ヷ ヸ ヹ ヺ

ぱ ぴ ぷ ぺ ぽ
パ ピ プ ペ ポ
```

**テスト**

```console:実行結果
$ python3 dakuten.py sample.txt -
ヾ ゞ

が ぎ ぐ げ ご
ざ じ ず ぜ ぞ
だ ぢ づ で ど
ば び ぶ べ ぼ

ヴ
ガ ギ グ ゲ ゲ
ザ ジ ズ ゼ ゾ
ダ ヂ ヅ デ ド
バ ビ ブ ベ ボ

〲 〴
ゔ
ヷ ヸ ヹ ヺ

ぱ ぴ ぷ ぺ ぽ
パ ピ プ ペ ポ
```

```console:合成文字の通常文字化
$ python3 dakuten.py sample.txt - -N
ヽ゛ ゝ゛

か゛ き゛ く゛ け゛ こ゛
さ゛ し゛ す゛ せ゛ そ゛
た゛ ち゛ つ゛ て゛ と゛
は゛ ひ゛ ふ゛ へ゛ ほ゛

ウ゛
カ゛ キ゛ ク゛ ケ゛ ケ゛
サ゛ シ゛ ス゛ セ゛ ソ゛
タ゛ チ゛ ツ゛ テ゛ ト゛
ハ゛ ヒ゛ フ゛ ヘ゛ ホ゛

〱゛ 〳゛
う゛
ワ゛ ヰ゛ ヱ゛ ヲ゛

は゜ ひ゜ ふ゜ へ゜ ほ゜
ハ゜ ヒ゜ フ゜ ヘ゜ ホ゜
```

```console:JIS X 0208 のみ
$ python3 dakuten.py sample.txt - -J
ヾ ゞ

が ぎ ぐ げ ご
ざ じ ず ぜ ぞ
だ ぢ づ で ど
ば び ぶ べ ぼ

ヴ
ガ ギ グ ゲ ゲ
ザ ジ ズ ゼ ゾ
ダ ヂ ヅ デ ド
バ ビ ブ ベ ボ

〱゛ 〳゛
う゛
ワ゛ ヰ゛ ヱ゛ ヲ゛

ぱ ぴ ぷ ぺ ぽ
パ ピ プ ペ ポ
```

---

変換する文字が足りていないかもしれません…