しろもじメモランダム

文字についてあれこれと。

fontTools のペンを使ってグリフのアウトラインを取得する

先日 Twitter で、「グリフのアウトラインの座標列を取りたいんだけど」「それ pen protocol でできるよ!」というやりとりをしました。この記事では、pen protocol に対応したペンでアウトラインを取得する方法について、具体的に解説してみます。

pen protocol とペン

さて、そもそも pen protocol とは何でしょう? 今回はプロトコルの詳細には触れず、概観だけ説明します。

フォント界隈では Python が共通言語になっています。しかし、グリフを表すためのオブジェクトは、フォントエディタ(Glyphs, RoboFont, FontLab, ...)やライブラリ(fontTools, ufoLib, defcon, fontParts, ...)ごとにそれぞれ独自に定義されています。ざっくり言ってしまうと、「どんなグリフオブジェクトであれ、共通のインターフェイスでアウトラインを読み書きできると楽だよね」「SVG や PDF やいろんな描画 API にも対応したいよね」というのが pen protocol の発想です。pen protocol によってグリフからアウトラインを得たり、アウトラインを描いたりするためのオブジェクトをペン(pen)と呼びます。

pen protocol が広まった結果、現在までにいろいろなペンが実装されてきました。以下にいくつか例を挙げますが、これ以外にも存在しています。

なお、pen protocol には segment pen protocolpoint pen protocol の2系統があり、前者に対応したペンは HogePen、後者は HogePointPen のようなクラス名になっているのが通例です。この記事では、前者 segment pen protocol のペンをとり上げます*1

フォントを読み込んでグリフを準備する

以下、環境は Python 3.6.3, fontTools 3.20.1 です。fontTools は $ pip install fonttools でインストールできます。

ペンのことは一旦あと回しにして、まずはフォントファイルを読み込みます。今回は例として、源ノ角ゴシック Regular SourceHanSans-Regular.otf を使いました。

from fontTools.ttLib import TTFont
font = TTFont('SourceHanSans-Regular.otf')

次に、glyphSet と呼ばれる辞書様オブジェクトと、cmap(文字コードとグリフ名の対応)を取得しておきます。後者に関しては、従来 cmap テーブル font['cmap'] からサブテーブルを選んで辿っていく必要がありましたが、最近追加されたお手軽便利メソッド getBestCmap() でいい感じに取得できます。

glyph_set = font.getGlyphSet()  # {グリフ名: グリフ} っぽいオブジェクト
cmap = font.getBestCmap()       # {Unicode: グリフ名}

これらを使って、文字 char に対応したグリフオブジェクトを返す関数を作ります。

def get_glyph(glyph_set, cmap, char):
    glyph_name = cmap[ord(char)]
    return glyph_set[glyph_name]

例として、文字 "L" に対応するグリフオブジェクトを、 L としておきましょう。

L = get_glyph(glyph_set, cmap, 'L')

これでグリフの準備はできました。

RecordingPen でアウトラインの内容を得る

さて、アウトラインの内容を取得するためには、fontTools に同梱されている RecordingPen というペンを使います。まずはペンのインスタンスを作成します。

from fontTools.pens.recordingPen import RecordingPen
recording_pen = RecordingPen()

次に、このペンをグリフ上で動かします。pen protocol に対応したグリフオブジェクトは draw() メソッドを持っていますので、これにペンを渡して実行します。先ほどの "L" のグリフ L を使ってみましょう。

L.draw(recording_pen)

ペンで "draw" と言われると、どこかに描く・書き込むのかと思ってしまいがちですが、ここではグリフのアウトラインを「なぞる」行為のことだと捉えてください。この RecordingPen では value 属性になぞった結果が入っていますので、見てみましょう。

print(recording_pen.value)
[('moveTo', ((100, 0),)),
 ('lineTo', ((513, 0),)),
 ('lineTo', ((513, 79),)),
 ('lineTo', ((193, 79),)),
 ('lineTo', ((193, 733),)),
 ('lineTo', ((100, 733),)),
 ('closePath', ())]

目的どおりアウトラインの内容が出てきました。"L" の左下の点からスタートし、反時計回りにパスが構成されているのが分かります。

もうひとつ、"い" のグリフで試してみるとこうなります。

い = get_glyph(glyph_set, cmap, 'い')
recording_pen = RecordingPen()
い.draw(recording_pen)
print(recording_pen.value)
[('moveTo', ((226, 696),)),
 ('lineTo', ((130, 698),)),
 ('curveTo', ((135, 674), (136, 633), (136, 610))),
 ('curveTo', ((136, 552), (137, 432), (147, 346))),
 ('curveTo', ((174, 89), (264, -4), (357, -4))),
 ('curveTo', ((425, -4), (486, 53), (545, 221))),
 ('lineTo', ((482, 293),)),
 ('curveTo', ((456, 193), (410, 91), (359, 91))),
 ('curveTo', ((289, 91), (241, 200), (225, 366))),
 ('curveTo', ((218, 447), (217, 538), (218, 600))),
 ('curveTo', ((219, 626), (222, 672), (226, 696))),
 ('closePath', ()),
 ('moveTo', ((742, 669),)),
 ('lineTo', ((664, 642),)),
 ('curveTo', ((758, 526), (818, 330), (835, 152))),
 ('lineTo', ((916, 184),)),
 ('curveTo', ((902, 351), (831, 554), (742, 669))),
 ('closePath', ())]

曲線を中心とした、2つのパスで構成されています。

SVGPathPen を利用してSVGを作成する

今度は応用として、SVGPathPen というペンを使ってみましょう。このペンは、SVGパスデータ文字列を組み立ててくれます。SVGPathPen のコンストラクタは引数に glyphSet をとりますので、最初の方で用意した glyph_set を渡します。

from fontTools.pens.svgPathPen import SVGPathPen
svg_path_pen = SVGPathPen(glyph_set)

グリフ L をなぞった後、getCommands() メソッドでパスデータ文字列を取得します。

L.draw(svg_path_pen)
print(svg_path_pen.getCommands())
M100 0H513V79H193V733H100Z

パスデータ文字列が表示されました。左下の点 100 0 からスタートし、水平線 H と垂直線 V でパスが構成されています。

以下のようにガワを手書きして、独立したSVGファイルを作ります。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
    <path d="M100 0H513V79H193V733H100Z"/>
</svg>

このSVGファイルをブラウザで表示してみると、こんな感じです。

一応「L」のグリフが表示できました。が、上下逆さです。OpenType の座標系では y 軸が上方向に延びていますが、SVG では下方向に延びているため、そのままだと上下がひっくり返ってしまいます。transform 属性で上下を逆にし、viewBox 属性も調整しましょう。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1000 1000 1000">
    <path d="M100 0H513V79H193V733H100Z" transform="scale(1, -1)"/>
</svg>

これで正立しました。

最後に、もうちょっといい感じのSVGファイルを生成する関数を定義してみます。

from textwrap import dedent

def save_as_svg(font, char, output_path):
    '''TTFont オブジェクトを受け取り、指定した文字のグリフを SVG として保存する'''
    
    glyph_set = font.getGlyphSet()
    cmap = font.getBestCmap()
    
    # グリフのアウトラインを SVGPathPen でなぞる
    glyph = get_glyph(glyph_set, cmap, char)
    svg_path_pen = SVGPathPen(glyph_set)
    glyph.draw(svg_path_pen)

    # メトリクスを取得
    ascender = font['OS/2'].sTypoAscender
    descender = font['OS/2'].sTypoDescender
    width = glyph.width
    height = ascender - descender
    
    content = dedent(f'''\
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 {-ascender} {width} {height}">
            <g transform="scale(1, -1)">
                <!-- ボディの枠 -->
                <rect x="0" y="{descender}" width="{width}" height="{height}"
                    stroke="cyan" fill="none"/>
                <!-- グリフ座標系の原点 -->
                <circle cx="0" cy="0" r="5" fill="blue"/>
                <!-- グリフのアウトライン -->
                <path d="{svg_path_pen.getCommands()}"/>
            </g>
        </svg>
    ''')
    
    with open(output_path, 'w') as f:
        f.write(content)

"L" と "い" で実行してみます。

save_as_svg(font, 'L', 'L.svg')
save_as_svg(font, 'い', 'い.svg')

こんな感じの SVG ファイルができました。めでたしめでたし。

おわりに

この記事では pen protocol の概要を説明し、ペンを使ってアウトラインを取得したり、SVG として表示する方法について見てきました。一方、今回触れなかった話題としては、

  • pen protocol の詳細とペンの定義方法
  • グリフへの書き込み
  • ペンを利用したアウトラインの加工
  • さまざまなペンの紹介

などがあります。これらに関しては、またいずれ気が向いたときに記事を書くかもしれません。

なお、今回のファイルは以下のリポジトリにまとめておきました。どうぞご利用ください。

github.com

*1:若干紛らわしいのですが、segment pen protocol は単に pen protocol と呼ばれることがあります。