指定ディレクトリ配下の Markdown ファイルに含まれる NFD・NFC 文字を一括相互変換する

Windows ユーザと Mac ユーザが入り混じって、Markdown ファイルを書いていた時に起こった、俗に NFD 問題と言われるアレ。


Mac の Finder で表示されるディレクトリ名やファイル名は、「NFD」という形式で Unicode 正規化されている。平たくいうと、「ガ」とか「プ」とかのように濁点・半濁点が付いた文字列を、 「カ」「゛」、「フ」「゜」 と2文字に分割して表現する、という仕様。見た目には違いがほぼ分からないのだが、Mac の Finder からファイル名をコピペすると、このように NFD 正規化された文字列が取得できてしまう。

対して、Windows のエクスプローラで見えるフォルダ名やファイル名は、「NFC」という形式で Unicode 正規化されている。コチラは通常どおり、「ガ」で1文字、「プ」で1文字とする仕様だ。

複数の Markdown ファイルを書いていて、Markdown ファイル同士のリンクを書いていたのだが、Mac ユーザは Finder から、Windows ユーザはエクスプローラから、リンク先のファイル名をコピペして Markdown リンクを書いていたため、リンクパスに NFD 正規化された文字列と NFC 正規化された文字列とが混在してしまうことになった。こうなると、Mac 環境では Windows ユーザが書いた Markdown リンクが効かないし、Windows ユーザは Mac 環境で書いたリンクが効かない。

コレは不便だということで、指定のディレクトリ配下にある Markdown ファイルを全て拾い上げて、その中に NFD 文字が出てきたら NFC 文字に変換する Node.js スクリプトと、その逆に NFC 文字を NFD 文字に変換する Node.js スクリプトの2つを作った。

ソースコードは以下の4ファイル分。

/*!
 * Markdown リンクや画像貼付部分の文字列から NFD 文字を検索し NFC 文字に変換するスクリプト
 */

// ファイル置換用に使用するパッケージ
const replace = require('replace');
// NFD 文字を生成するためのパッケージ
const unorm = require('unorm');

// unorm パッケージの変換結果から NFD 形式の文字列で使用される濁点と半濁点を取得し、コレを検索する
const strs = 'ビ'.normalize('NFD')[1] + 'ピ'.normalize('NFD')[1];

replace({
  // Markdown リンクや画像貼付で登場する「](」「)」の間に検索対象文字が含まれている箇所を検索する
  regex: new RegExp(   '('
                         + '\\]\\('        // "]("
                         + '(?!(#|http))'  // "#" (ページ内リンク) か "http" (外部リンク) 始まりを除外する
                         + '.*?'           // 任意文字列 (閉じカッコ ")" までの最短マッチ用「?」)
                     + ')'
                     + '([' + strs + '])'  // 検索対象文字列 (NFD の濁点 or 半濁点) のいずれかを含む
                     + '('
                         + '.*?'           // 任意文字列 (閉じカッコ ")" までの最短マッチ用「?」)
                         + '\\)'           // ")"
                     + ')',
                     'g'
                   ),
  replacement: false,              // 置換文字列を指定しない (funcFile 使用時は false 指定が要る)
  funcFile: 'nfd-to-nfc-func.js',  // 文字列を置換する関数を記述したファイルの指定
  paths: ['./texts'],              // 検索対象ファイルの指定
  include: '*.md',                 // 検索対象ファイルに含める拡張子
  recursive: true                  // サブディレクトリも検索する
});
function(match) {
  /*!
   * ヒットした NFD 文字 (変数 match) を NFC 文字に変換して返却する
   * 
   * replace パッケージの内部処理で、このファイルの中身全体を eval で変数に詰め込んで使うので、この function 外にコードを記述してはならない
   */
  
  // NFD 文字を生成するためのパッケージ
  const unorm = require('unorm');
  
  // NFC 文字列
  const nfcStrs = 'がぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽガギグゲゴザジズゼゾダヂヅデドバビブベボパピプペポヴ';
  nfcStrs.split('').forEach((nfcStr) => {
    // NFD 文字列を生成する
    const nfdStr = nfcStr.normalize('NFD');
    // NFD 文字列を NFC 文字列に変換する
    match = match.replace(new RegExp(nfdStr, 'g'), nfcStr);
  });
  
  return match;
}
/*!
 * Markdown リンクや画像貼付部分の文字列から NFC 文字を検索し NFD 文字に変換するスクリプト
 */

// ファイル置換用に使用するパッケージ
const replace = require('replace');

// 検索対象文字 (NFC)
const strs = 'がぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽガギグゲゴザジズゼゾダヂヅデドバビブベボパピプペポヴ';

replace({
  // Markdown リンクや画像貼付で登場する「](」「)」の間に検索対象文字が含まれている箇所を検索する
  // このやり方だと1行に複数回リンク文字列が登場した時にひとまとめに検出してしまうので、funcFile 内で分割して変換している
  regex: new RegExp(   '('
                         + '\\]\\('        // "]("
                         + '(?!(#|http))'  // "#" (ページ内リンク) か "http" (外部リンク) 始まりを除外する
                         + '.*?'           // 任意文字列 (閉じカッコ ")" までの最短マッチ用「?」)
                     + ')'
                     + '([' + strs + '])'  // 検索対象文字列のいずれかを含む
                     + '('
                         + '.*?'           // 任意文字列 (閉じカッコ ")" までの最短マッチ用「?」)
                         + '\\)'           // ")"
                     + ')',
                     'g'
                   ),
  replacement: false,              // 置換文字列を指定しない (funcFile 使用時は false 指定が要る)
  funcFile: 'nfc-to-nfd-func.js',  // 文字列を置換する関数を記述したファイルの指定
  paths: ['./texts'],              // 検索対象ファイルの指定
  include: '*.md',                 // 検索対象ファイルに含める拡張子
  recursive: true                  // サブディレクトリも検索する
});
function(match) {
  /*!
   * ヒットした NFC 文字 (変数 match) を NFD 文字に変換して返却する
   * 
   * replace パッケージの内部処理で、このファイルの中身全体を eval で変数に詰め込んで使うので、この function 外にコードを記述してはならない
   */
  
  // NFD 文字を生成するためのパッケージ
  const unorm = require('unorm');
  // NFC 文字列
  const nfcStrs = 'がぎぐげござじずぜぞだぢづでどばびぶべぼぱぴぷぺぽガギグゲゴザジズゼゾダヂヅデドバビブベボパピプペポヴ';
  
  // 置換結果文字列
  let result = '';
  
  // マッチした文字列を、Markdown リンクを構成する ")" か "]" の文字で分割し、個別に処理する
  // (1行に複数のリンクがあった場合にリンク外の文字を置換しないようにするため)
  const split = match.split(/\)|\]/g);
  
  split.forEach((str, index) => {
    // "(" で始まる要素がリンクパスを保持している文字列 = 置換対象となる
    if(str.startsWith('(')) {
      // split() でちぎった文字列 "]" 自体は失われるので入れておく
      // ただし直前に閉じカッコ ")" がある場合は付与しない (Markdown の仕様上リンク文字列に閉じカッコは含まれず、リンク文字列ではないカッコ書きを認識している)
      if(!result.endsWith(')')) {
        result += ']';
      }
      
      if(!( new RegExp('[' + nfcStrs + ']', 'g').test(str) )) {
        // 検出されたリンクの中に置換対象となる NFC 文字列がない場合は結合のみ
        // (1行に複数のリンクが書かれていた場合に、リンク外の文字から置換対象を検知してしまった場合)
        result += str;
      }
      else {
        // リンク文字列部分のみ置換する
        nfcStrs.split('').forEach((nfcStr) => {
          // NFD 文字列を生成する
          const nfdStr = nfcStr.normalize('NFD');
          // NFD 文字列を NFC 文字列に変換する
          str = str.replace(new RegExp(nfcStr, 'g'), nfdStr);
        });
        result += str;
      }
      
      // split() でちぎった文字列 ")" 自体は失われるので入れておく
      result += ')';
    }
    else {
      // 置換対象でなければ結合するだけ (split() の対象文字が含まれていた要素は空文字になるので、空文字も結合される)
      result += str;
    }
  });
  
  // 置換後の文字列の末尾が閉じカッコで終わっていない場合は閉じカッコを付与する
  if(!result.endsWith(')')) {
    result += ')';
  }
  
  // 置換後の文字列を返却する
  return result;
}

4つファイルがあるが、以下のように2ファイルずつ使う。

このスクリプトを使うには、replaceunorm という2つの npm パッケージが必要なので、以下のようにインストールしておく。

$ npm install --save-dev replace unorm

複数ファイルを対象に一括置換する操作は replace ライブラリに任せている。replace ライブラリで置換処理を細かく実装する都合上、*-func.js という別ファイルを作っている。

unorm パッケージは、NFD 濁点、NFD 半濁点を取得するために使用している。NFD 正規化した時に使われる濁点・半濁点は「結合文字」と呼ばれる、特殊な文字コードのモノになる。

拙作の Angular Utilities に「Normalize To NFC」というツールを作ってあるが、NFD 濁点は ゙、NFD 半濁点は ゙ という文字コードになる。通常「だくてん」「はんだくてん」と変換して出てくる、全角の濁点文字は ゚、半濁点文字は ゜ となっている。この他に半角の濁点、半濁点も別の文字コードで定義されていたりする。

ひとまずこんなスクリプトを書いて、NFD・NFC が混在する状態は解消できた。一般的には、Mac Finder で用いられる「NFD」の仕様の方が嫌われるので (「ガ」が「カ゛」と記述されると「ガ」で検索してもヒットしない)、NFC 形式に合わせておくのが良いだろう。

ちなみに、Markdown でテキストを書いている時に NFD 文字が含まれているかチェックするには、textlint-rule-no-nfd が簡単。

以上。ハ゛イハ゛イ。