【ルビ振り】形態素解析で取得した読みがなが原文のどの文字と対応するか調べる【javascript】

#概要
形態素解析で読みがなを取得し、取得した読みがなの各文字が原文のどの文字と対応するか調べる関数を作りました。形態素解析だけだと単語の対応までしかわからず、もっと細かい対応を知る必要があったので、作りました。主な用途はルビ振りです。

基本は、[以前書いた記事](https://qiita.com/shimajiroxyz/items/b29d4a2ac1cbecc02dcc)のjavascript版です。
今回は以下のような出力を得ることを目指します。

```
入力:str
解析したい文章。

出力:objectのlist
[{"pronunciation":"カ","surface":"解","in_surface_pos":0,"type":"nonkana"},
{"pronunciation":"イ","surface":"解","in_surface_pos":1,"type":"nonkana"},
{"pronunciation":"セ","surface":"析","in_surface_pos":0,"type":"nonkana"},
{"pronunciation":"キ","surface":"析","in_surface_pos":1,"type":"nonkana"},
{"pronunciation":"シ","surface":"し","in_surface_pos":0,"type":"hira"},
{"pronunciation":"タ","surface":"た","in_surface_pos":0,"type":"hira"},
{"pronunciation":"イ","surface":"い","in_surface_pos":0,"type":"hira"},
{"pronunciation":"ブ","surface":"文","in_surface_pos":0,"type":"nonkana"},
{"pronunciation":"ン","surface":"文","in_surface_pos":1,"type":"nonkana"},
{"pronunciation":"シ","surface":"章","in_surface_pos":0,"type":"nonkana"},
{"pronunciation":"ョ","surface":"章","in_surface_pos":1,"type":"nonkana"},
{"pronunciation":"ー","surface":"章","in_surface_pos":2,"type":"nonkana"},
{"pronunciation":"。","surface":"。","in_surface_pos":0,"type":"sign"}]
```

Github：
https://github.com/JiroShimaya/KanaCorresponadance

Demo:
https://jiroshimaya.github.io/KanaCorresponadance/

#処理の流れ
入力(漢字仮名交じり文)に対し、以下の順序で処理を行っています。

* 入力を形態素解析し、発音（カタカナ）を含む情報を取得する。
* 表層形をかな部分とかな以外の部分に分ける
* 分けられた表層形の各要素と対応する発音のかな部分を見つける。
* 発音の各文字と表層形の対応を見つける。

#準備

javascriptで形態素解析をするため、[kuromoji.js](https://github.com/takuyaa/kuromoji.js)を使えるようにします。
環境はブラウザでもnode.jsでも構いません。今回はブラウザです。

#入力の形態素解析
解析したい文章textとkuromojiのtokenizerを引数として、形態素解析結果を返す

```javascript
//kuromojiのtokenizerによる形態素解析結果を少し修正して返す
function tokenize(text, tokenizer){
  if(!text)return [];

  const result = [];
  let tokens = tokenizer.tokenize(text);
  tokens = tokens.map(token=>{
    let surface = token.surface_form;
    //あとで一括で処理するため、発音が定義されていなければ、surfaceを代入しておく
    if(!token.pronunciation){
      token.pronunciation=surface;
    }
    return token;
  });
  
  return tokens;
}


```

#表層形のかな部分とそれ以外を分ける
漢字かな交じりの入力文textを、正規表現を使って、カタカナ、ひらがな、カナ以外、の部分に分離します。

```javascript:kana_correspondance.js
//表層形のかな部分とそれ以外を分割し、タグを付けて返す。
function kanaTokenize (text) {
  //例外処理。万が一空文字であれば空のリストを返す
  if(text == "") return [];

  //正規表現の宣言
  let re = /(?<kata>[ァ-ヴー]+)|(?<hira>[ぁ-ゔー]+)|(?<nonkana>[^ぁ-ゔァ-ヴー]+)/g //カタカナ、ひらがな、カナ以外にグループマッチ
  //マッチする文字列を種類とともに取得
  let match = text.matchAll(re);
  match = [...match].map(m=>m.groups);
  let output = match.map(m=>{
    let token = {}
    for(let type in m){
      if(m[type]){
        token = {"surface":m[type],"type":type}
        break;
      }
    }
    return token;
  });
  
  return output;
}

```

#分けられた表層形の各要素と対応する発音文字列を見つける。
カタカナ、ひらがな、それ以外に分けられた表層形separated_surfaceと表層形全体の発音pronunciationを引数として、表層系の各要素がpronunciationのどの部分と対応する見つけます。


```javascript:kana_correspondance.js

//ひらがなをカタカナに変換
function hiraToKata (str) {
    return str.replace(/[\u3041-\u3096]/g, function(match) {
        var chr = match.charCodeAt(0) + 0x60;
        return String.fromCharCode(chr);
    });
}

function kanaAllocate (separated_surface, pronunciation) {
  //例外処理。万が一表層形の長さが0のとき、空の配列を返す
  if(separated_surface.length == 0) return [];
  
  //カナ始まりかどうかを取得
  let first_kana_index = 1;
  if(separated_surface[0]["type"] != "nonkana")
    first_kana_index = 0;
  let first_nonkana_index = (1-first_kana_index);

  let output = []
  let rest_text = pronunciation;
  
  for(let i=0;i<separated_surface.length;i++){
    //surfaceのカナ部分がpronunciationのどこから始まっているかを取得
    let type = separated_surface[i]["type"]
    let surface = separated_surface[i]["surface"]
    
    if(type == "nonkana") continue;

    let katakana = surface;
    if(type == "hira") katakana = hiraToKata(surface);

    let start = rest_text.indexOf(katakana);
    //カナ部分の始まりが途中からだったら、始めのカナ以外の部分を先に格納する
    if(start > 0){
      let nonkana = separated_surface[i-1]
      output.push({"surface":nonkana["surface"],"pronunciation":rest_text.slice(0,start-rest_text.length), "type":nonkana["type"]});
      rest_text = rest_text.slice(start);
    }
    //カナ部分の終わりまでを、格納する
    output.push({"surface":surface,"pronunciation":rest_text.slice(0,katakana.length),"type":type});
    rest_text = rest_text.slice(katakana.length);
  }
  //ループを終えてもカナ以外の部分が残っていたら追加する
  if(rest_text != ""){//rest_textが空文字とならないのはsurfaceがカナ以外で終わるとき
    let last = separated_surface[separated_surface.length-1];
    output.push({"surface":last["surface"], "pronunciation":rest_text,"type":last["type"]});
  }
  return output;
}


```

#漢字と発音の対応を辞書ベースで調べる
単漢字の読みがなに関する辞書を使って、熟語surfaceと発音pronunciationの対応を調べます。
辞書に情報がなかった場合は、各漢字に平均的に発音文字列を割り当てます。
今回は[Wikipedia](https://ja.wikipedia.org/wiki/%E5%B8%B8%E7%94%A8%E6%BC%A2%E5%AD%97%E4%B8%80%E8%A6%A7)から取得した情報で常用漢字辞書を作りました。

```json:kanjiyomi.json
{
  "亜": [
    "ア"
  ],
  "哀": [
    "アワ",
    "アイ"
  ],
  "挨": [
    "アイ"
  ],
  "愛": [
    "アイ"
  ],
  "曖": [
    "アイ"
  ],
  "悪": [
    "ワル",
    "アク",
    "オ"
  ],
  ...
```

```javascript:kana_correspondance.js
//辞書ベースで、漢字（熟語)と発音のなるべく細かい対応を見つける
//pronunciationはsurfaceよりも長い必要あり
function kanjiAllocate (surface, pronunciation, kanji_dict = {}) {
  let rest_text = pronunciation;
  let skipped_char = "";

  let output = [];
  for(let i=0;i<surface.length;i++){
    let char = surface[i];
    if(char in kanji_dict == false){
      skipped_char += char;
      continue;
    }

    let yomi_candidates = kanji_dict[char];//長さの降順にソート済みとする
    let start = -1;
    let yomi = "";
    for(let y of yomi_candidates){
      start = rest_text.indexOf(y);
      if(start >= 0){
        yomi = y;
        break;
      }
    }
    //マッチする読みが見つからなければスキップ
    if(start == -1){
      skipped_char += char;
      continue;
    }
    if(start > 0){
      if(output.length == 0){
        if(skipped_char != ""){
          output.push([skipped_char, rest_text.slice(0,start)]);
          skipped_char = "";
          rest_text = rest_text.slice(start);
          output.push([char, yomi]);
          rest_text = rest_text.slice(yomi.length);
        }else{
          output.push([char, rest_text.slice(0, start+yomi.length)]);
          rest_text = rest_text.slice(start+yomi.length);
        }
      }else{
        if(skipped_char != 0){
          output.push([skipped_char, rest_text.slice(0,start)]);
          skipped_char = "";
          rest_text = rest_text.slice(start);
          output.push([char, yomi]);
          rest_text = rest_text.slice(yomi.length);          
        }else{
          output[output.length-1][1]+= rest_text.slice(0, start);
          rest_text = rest_text.slice(start);
          output.push([char, yomi]);
          rest_text = rest_text.slice(yomi.length);                    
        }
      }
    }else{
      output.push([char, yomi]);
      rest_text = rest_text.slice(yomi.length);
    }
  }

  //ループで処理しきれなかった文字列の処理
  if(skipped_char != ""){
    if(rest_text != ""){
      console.log(skipped_char, rest_text);
      output.push([skipped_char, rest_text]);
    }else{
      if(output.length == 0){
        //たぶんほとんどないケース
        output.push([skipped_char, rest_text]);
      }else{
        output[output.length-1][0]+=skipped_char;        
      }
    }
  }else{
    if(rest_text != ""){
      if(output.length == 0){
        //この分岐はたぶんない
      }else{
        output[output.length-1][1] += rest_text;
      }
    }
  }
  output = output.map(function([surface, yomi]){
    return charAllocate(surface, yomi);
  });
  output = output.flat();
  return output;
}

```

#取得したペアの各要素を均等に対応付ける
表層形surfaceに発音pronuncationを均等に対応付けます。

```javascript:kana_correspondance.js

function charAllocate (surface, pronunciation) {
  let id = {surface: "surface", pronunciation: "pronunciation"}
  let text = {surface: surface, pronunciation: pronunciation}
  let longer = id.surface;
  let shorter = id.pronunciation;
  if(surface.length <= pronunciation.length){
    longer = id.pronunciation;
    shorter = id.surface;
  }
  let plusone = text[longer].length % text[shorter].length;
  let contentlen = Math.floor(text[longer].length/text[shorter].length);

  let output = [];
  let longer_pos = 0;
  for(let i = 0; i<text[shorter].length; i++){

    let longer_content_len = contentlen;
    if(i<plusone) longer_content_len += 1;
    //pronunciationが長いときと短いときで処理を変える
    if(longer == id.pronunciation){//pronunciationが長いとき、pronunciation1文字ずつに重複する1文字のsurfaceを対応させ、in_order_posで区別する
      for(let j=0;j<longer_content_len; j++){
        let info = {}
        info[longer]=text[longer][longer_pos+j];
        info[shorter] = text[shorter][i];
        info["in_surface_pos"] = j;
        output.push(info);
      }
      longer_pos += longer_content_len;
    }else{//pronunciationが短いとき、pronunciation１文字にsurface複数文字を対応させる
      let info = {}
      info[longer] = text[longer].slice(longer_pos, longer_pos + longer_content_len);
      info[shorter] = text[shorter][i];
      info["in_surface_pos"] = 0;
      longer_pos += 1;
      console.log("info",info);
      output.push(info);
    }
  }
  return output;
}

```
#上記を組み合わせて読みがなと原文の対応を得る関数を作る
これまでの関数を組み合わせて、漢字仮名交じり文surfaceとその発音pronunciationの対応を出力する関数を作ります。
pronunciationの１文字ずつに対し、当てはまるsurfaceの文字がなにか、その文字において何番目に発音されるか、surfaceの分類(ひらがな、カタカナ、記号、カナ以外)を出力します。

```javascript:kana_correspondance.js
function getCharCorrespondance(text, tokenizer, kanji_dict = {}){
  //形態素解析
  let tokens = tokenize(text, tokenizer);
  //カナ部分とカナ以外部分の対応を見つける
  let kana_correspondance = tokens.map(token=>{
    let surface = token["surface_form"]
    let pronunciation = token["pronunciation"]
    let pos = token["pos"]
    //surfaceを解析
    let separated = kanaTokenize(surface);
    //カナ、カナ以外の対応を見つける
    let correspondance = kanaAllocate(separated, pronunciation);
    //記号の場合はtypeに記号を設定する
    if(pos == "記号"){
      correspondance = correspondance.map(v=>{
        v["type"] = "sign";
        return v;
      });
    }
    return correspondance;
  });
  kana_correspondance = kana_correspondance.flat(); //1重のリストにする

  //１文字ずつの対応を見つける
  let char_correspondance = kana_correspondance.map(token => {
    let surface = token["surface"]
    let type = token["type"]
    let pronunciation = token["pronunciation"]
    let correspondance = null;
    if(type == "nonkana"){
      correspondance = kanjiAllocate(surface, pronunciation, kanji_dict);
    }else{
      correspondance = charAllocate(surface, pronunciation);
    }
    correspondance = correspondance.map(v => {
      v["type"] = type;
      return v;
    });
    return correspondance;
  });
  char_correspondance = char_correspondance.flat();

  return char_correspondance;
}
```

#参考にさせていただいた情報
[kuromoji.js](https://github.com/takuyaa/kuromoji.js)
[JavaScriptでカタカナをひらがなに変換する(その逆も)](https://qiita.com/mimoe/items/855c112625d39b066c9a)
[JavaScript: 正規表現/gと名前付きグループを併用する小技](https://qiita.com/suin/items/2975b198ed3b76573f79)