字形分析して和文オプティカルカーニングを行うJavaScriptライブラリの開発


はじめに

経緯

Webページにおいて、Google Fonts + 日本語早期アクセスの提供する等幅和文フォントでプロポーショナルな和文のレイアウトを行いたいと思い、文字形状を分析して文字をプロポーショナル幅にしつつ、文字同士の間隔をオプティカルカーニングにより自動調整するJavaScriptライブラリを開発しました。いわゆるタイトルだけでなく、本文のテキストにも適用する想定です。

開発したライブラリ

optical-kerning.jsとして公開しています。

注意

OpenTypeフォントには、以下のようなCSSプロパティで、プロポーショナル幅やカーニングを有効にできるものもあります。これらを利用できるときは、そのほうがフォント制作者の意図する表示になるでしょう。

{
    font-feature-settings : "pwid" 1, "kern" 1;
}

アイデア

文字一つ一つをspan要素に分解します。隣り合う文字について、それぞれcanvas上に、HTMLで指定されたフォントで左右に描画し、Y軸方向のピクセル数全てについて、X軸方向に文字間の空白の長さを測ります。それらのうち最短のものを文字間の元々の距離とし、その距離が0(カーニング強度の係数が1.0の場合)になるようにletter-spacing CSSプロパティを設定します。

問題

文字の食い込み

例えば、「ト」と「フ」が隣接しているとき、字形そのままで距離を測って間隔を狭めると、「ト」の右側が、「フ」の左側に入り込んでしまい、視覚的にかなり違和感があります。一方「ダ」と「イ」の隣接において、「ダ」の右下に「イ」が入り込んでいるのは、さほど違和感は感じないと思います。

この問題を解決するため、文字間の距離を調べる際には、文字を凸包で囲い、その凸包同士の距離を求めるようにしています。これにより、文字の中央部分に隣の文字が入り込む問題は無くなりました。

リガチャの判定

特に欧文では、"fi"のような特定の文字の列に対して、美しい字形を保つため専用の字形を用意しており、リガチャと呼ばれます。リガチャを構成している文字をspan要素で分断してしまうと、リガチャを使わないレンダリングが行われて、表示品質を損ねてしまいます。

そこでリガチャ部分は分断しないような処理が必要になります。たとえば、文字を個別にcanvasにレンダリングした場合と、文字列をまとめてレンダリングした場合の結果を比較する、などの方法があり得ますが、残念ながら誤検出が多く発生しました。

そこで、リガチャ判定はあきらめ、また、そもそも欧文フォントにおいてはフォントにカーニング用の設定がされておりメトリクスカーニングされている可能性も高いので、オプション設定によりアルファベット等の文字を本ライブラリの適用対象から除外できるようにしました。

パフォーマンス改善

各ブラウザでパフォーマンスを確認し、結果的に以下の処理時間になっています。(約15000文字のサンプルテキストに対する処理時間)

  • Chrome (Windows, Core i7 6700 3.4GHz)
    • 450ms
  • Firefox (Windows, Core i7 6700 3.4GHz)
    • 1000ms
  • Edge (Windows, Core i7 6700 3.4GHz)
    • 2200ms
  • Internet Explorer (Windows, Core i7 6700 3.4GHz)
    • 2600ms
  • Safari (macOS, Core M 1.2GHz)
    • 650ms
  • Safari (iPhone 6s)
    • 1000ms

パフォーマンス改善を行った内容を以下に挙げます。

getImageData()の影響

CanvasRenderingContext2D.getImageData()の呼び出しが、Chrome以外のブラウザでパフォーマンスを大きく落とす要因となっていました。そこで、文字の描画と分析を一つ一つ順に行うのではなく、多数の文字に対する描画を大きいcanvasに対してまとめて行った後getImageData()を呼び出し、ピクセルデータの分析を行うようにしました。

ループ最深部のインデックス計算

ピクセルデータの分析において、最深部のループでは配列アクセスのためのインデックスの計算の影響が大きかったため、インデックス計算を加減算のみで行うようにしました。

修正前
for (var x = sx + width - 1; x >= sx; --x) {
    if (image.data[3 + 4 * (x + y * image.width)] !== 0) {
        // ...
    }
}
修正後
var i = 3 + 4 * ((sx + width - 1) + y * image.width);
for (var x = sx + width - 1; x >= sx; --x, i -= 4) {
    if (image.data[i] !== 0) {
        // ...
    }
}

凸包走査

凸包を求めるアルゴリズムは、三角関数を使って全頂点に対する角度を求めていくナイーブなものでは時間がかかっていたので、Andrewの凸包走査と呼ばれるアルゴリズムを用いるようにしました。

まとめ

手動で文字間隔の指定をすること無く、カーニングを行うことができました。実行時間も実用上問題ないものにできたかと思います。