先日 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 protocol と point 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()
これらを使って、文字 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()
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