【Python】フォントの文字を画像に変換して保存するコード例

フォントファイルに収録されている文字を、PNG 画像に変換して保存する Python コード例です。

使用したフォントファイルの形式は .otf, .ttf, .ttc です。

似た文字どうしを集めて、分類するために書きました。

【Python】フォントから『類似文字』の一覧を作成するコード例

その分類を『何に使うか?』ですが、たとえば検索エンジンです。

検索キーワードに、『見た目が似ている文字や言葉』を自動的に追加して、人間っぽい検索を実現しようと思ったわけです。

『見つからなかったけど、似たような単語はいくつかあったよ~。』

みたいな感じですね。

ワイルドカードだと、何でもかんでもマッチしてしまいますが、事前に分類しておいたものを使えば、より良い検索結果になるんじゃないかと、そう思ったわけです。

『M+ FONTS』の場所

M+ FONTS | JAPANESE

コード例では、『mplus-1m-regular.ttf(固定幅)』を使用しました。

『VL ゴシック』の場所

VL ゴシックフォントファミリ

コード例では、『VL-Gothic-Regular.ttf(固定幅)』を使用しました。

『Google Noto Fonts』の場所

Google Noto Fonts

コード例では、『Noto Sans CJK JP』の中の『NotoSansMonoCJKjp-Regular.otf(固定幅)』を使用しました。

Pillow マニュアルの場所

Pillow は、フォントの文字を『画像』として描画するために使用しました。

(Pillow) Windows Installation 『Pillow』のインストール方法。

ImageFont.truetype(font=None, size=10, index=0, encoding='', layout_engine=None) フォント指定。

ImageFont.FreeTypeFont.getname() フォントファミリー (font family) とフォントスタイル (font style) を取得する。

Image.new(mode, size, color=0) 画像オブジェクト作成。

ImageDraw.Draw(im, mode=None) 描画オブジェクト作成。

ImageDraw.text(xy, text, fill=None, font=None, anchor=None, spacing=4, align='left', direction=None, features=None, language=None, stroke_width=0, stroke_fill=None, embedded_color=False) テキストを描画する。

Image.getbbox() 余白を除いた領域の座標を取得。

Image.crop(box=None) 画像を切り抜く。

Image.save(fp, format=None, **params) 画像をファイルに保存する。ファイルではなくて、データとして受け取るときは、BytesIO() で作ったバッファを指定したらできました。

fontTools マニュアルの場所

fontTools は、フォントファイルに収録されている文字を『列挙れっきょ』するために使用しました。

あと、デバッグ用として、文字の幅や高さを取得するためにも使用しました。

(fontTools) Installation 『fontTools』のインストール方法。

classfontTools.ttLib.ttFont.TTFont(file=None, res_name_or_index=None, sfntVersion='\x00\x01\x00\x00', flavor=None, checkChecksums=0, verbose=None, recalcBBoxes=True, allowVID=False, ignoreDecompileErrors=False, recalcTimestamp=True, fontNumber=- 1, lazy=None, quiet=None, _tableCache=None) フォントファイルを開く。

TTFont.getBestCmap(cmapPreferences=((3, 10), (0, 6), (0, 4), (3, 1), (0, 3), (0, 2), (0, 1), (0, 0))) best Unicode cmap 辞書を取得。

TTFont.getGlyphSet(preferCFF=True) グリフセットを取得。

Python マニュアルの場所

ord(c) オード関数。『Unicode コードポイントを表す整数』を取得する。

chr(i) シーエイチアール関数(キャラクター関数)。『文字』を取得する。

min(iterable, *[, key, default]) ミン関数。

max(iterable, *[, key, default]) マックス関数。

len(s) レン関数。

getattr(object, name[, default]) ゲットアトリビュート関数。object から属性 (Attribute) を取得する。

class tuple([iterable]) タプル (tuple) を作る。生成速度がリストよりもほんの少しだけ速かった。

class range(stop) レンジ関数(イミュータブルなシーケンス型)。

enumerate(iterable, start=0) イニュムレート関数。

traceback.format_exception_only(etype, value) フォーマット エクセプション オンリー。エラーメッセージの最後の行だけを取得。

class io.BytesIO([initial_bytes]) バイツアイオー。バッファを用意する。PIL 画像 ⇒ PNG 画像の変換結果を受け取る時に使いました。

getvalue() バッファからデータを読み込む。

class zipfile.ZipFile(file, mode='r', compression=ZIP_STORED, allowZip64=True, compresslevel=None, *, strict_timestamps=True) zip ファイルを開く。

ZipFile.writestr(zinfo_or_arcname, data, compress_type=None, compresslevel=None) zip にデータを書き込む。

csv.writer(csvfile, dialect='excel', **fmtparams) シーエスブイライター。

csvwriter.writerow(row) CSV に 1 行を書き込む。

class pathlib.Path(*pathsegments) パスオブジェクトを作成。

PurePath.joinpath(*other) パスを連結する。

Path.open(mode='r', buffering=-1, encoding=None, errors=None, newline=None) ファイルを開く。

エフ f から始まる文字列 f'' は、『エフストリング (f-string)』と呼ばれるものでした。

(f-string) フォーマット済み文字列リテラル

assert アサートぶん。

コード例

フォントファイル (.otf, .ttf, .ttc) の収録文字を、PNG 画像として保存する Python コード例です。

ところで、画像ファイルをそのまま保存したら、数万ファイルにもなって、Explorer 上で削除するときにちょっと大変でした。

なので、画像は zip ファイルにまとめて保存するようにしました。

"""
フォントファイルの収録文字を、
PNG 画像として保存する Python コード例です。
"""
from pathlib import Path
from io import BytesIO
from zipfile import ZipFile
import csv
from traceback import format_exception_only
from fontTools import ttLib
from PIL import Image, ImageFont, ImageDraw


def main():
    """メイン関数です。"""
    print('start')
    import datetime
    t = datetime.datetime.now()

    font_to_png()

    print('%.1f 秒で完了しました。' % (
        datetime.datetime.now() - t).total_seconds())
    print('end')
    return


def font_to_png():
    """フォントの文字を PNG 画像として保存する関数です。"""
    # (準備 1/7) 画像の保存フォルダを決めます。
    data_dir = Path(r'F:\project\font_data')

    # (準備 2/7) フォントファイルを決めます。
    font_file = r'F:\project\fonts\mplus-1m-regular.ttf'
    # font_file = r'F:\project\fonts\VL-Gothic-Regular.ttf'
    # font_file = r'F:\project\fonts\NotoSansMonoCJKjp-Regular.otf'

    # (準備 3/7) フォントサイズを決めます。
    font_size = 96

    # (準備 4/7) 画像サイズの初期値 (正方形) を決めます。
    # とりあえず、指定した font_size で、普通の全角1文字が
    # だいたい収まるくらいのサイズにしておきます。
    initial_size = 128
    print(f'initial_size: {initial_size}')

    # (準備 5/7) さて、このままでは特殊な文字が見切れてしまった
    # 場合があったので、initial_size に余白を追加していきます。

    # 余白として、left に初期サイズ 2 個分くらいのスペースを追加します。
    margin_x = initial_size * 2

    # 余白として、top に初期サイズ 2 個分くらいのスペースを追加します。
    margin_y = initial_size * 2

    # 余白の分だけ、文字の描画位置をずらします。
    position = (margin_x, margin_y)

    # 余白を追加した画像サイズを決めます。
    # right と bottom にも、それぞれ初期サイズ 2 個分
    # くらいの余白ができるようにします。
    # カッコの中身は、左に 2 個分、中央に 1 個分、右に 2 個分、
    # という意味です。
    # 正方形なので、上下方向についても同様です。
    # このくらいの余白を持たせたところで、ようやく
    # すべての文字が見切れずに描画できるようになりました。
    image_size = initial_size * (2 + 1 + 2)
    print(f'image_size: {image_size}')
    print(f'position: {position}')

    # (準備 6/7) 『PIL ライブラリ』で、フォントファイルを読み込みます。
    # image_font は、フォントの文字を描画するために使用しました。
    # ※ もし『.ttc (TrueType Collection) 形式』のフォントを使うときは、
    # index= に 0 以上の整数を指定して、中身のフォントを選びます。
    image_font = ImageFont.truetype(font=font_file, size=font_size, index=0)
    print(f'font: {image_font.getname()}')

    # (準備 7/7) 結果を入れるリストを用意します。
    ims = [] # PIL 画像を入れるリスト。最後に PNG 画像に置き換えます。
    bboxes = [] # 余白を除いた領域の座標 (bbox) を入れるリストです。
    infos = [] # (デバッグ用) 各文字の情報を入れるリストです。

    print('描画中 ...')

    # 『fontTools ライブラリ』で、フォントファイルを開きます。
    # fontTools は、フォントの中の文字を列挙するために使用しました。
    # あと、デバッグ用として、文字の幅や高さを取得するためにも使用しました。
    # ※ もし『.ttc (TrueType Collection) 形式』のフォントを使うときは、
    # fontNumber= に 0 以上の整数を指定して、中身のフォントを選びます。
    with ttLib.ttFont.TTFont(font_file, fontNumber=0) as font:
        # フォントで使用できる文字を取得します。
        cmap = font.getBestCmap()

        # (デバッグ) 文字の形の集合 (グリフの集合) を取得します。
        glyph_set = font.getGlyphSet()

        # フォントに収録されている文字を 1 つずつ取得します。
        for (n, c) in enumerate(cmap, start=1):
            # Unicode コードポイントの整数から、文字を取得します。
            # chr() は Python の組み込み関数です。
            letter = chr(c)

            # 文字を『描画』します。
            (im, bbox, msg) = draw_letter(
                letter, image_font, image_size, position)

            if im:
                # PIL 画像をリストに追加します。
                ims.append([c, im])

                # 余白を除いた領域の座標 (bbox) をリストに追加します。
                bboxes.append(bbox)

            # (デバッグ) bbox が無かったときは None を設定します。
            if bbox is None:
                bb_left = None
                bb_top = None
                bb_right = None
                bb_bottom = None
            else:
                (bb_left, bb_top, bb_right, bb_bottom) = bbox

            # (デバッグ) 文字の形を取得します。
            glyph = glyph_set[cmap[c]]

            # (デバッグ) 文字情報をリストに追加します。
            # getattr() は Python の組み込み関数です。
            # フォントによっては属性が存在しなかったので使用しました。
            infos.append([
                n, # 列挙できた文字の連番です。
                len(ims), # 実際に描画できた文字の連番です。
                c, chr(c), cmap[c],
                getattr(glyph, 'width', None),
                getattr(glyph, 'height', None),
                getattr(glyph, 'lsb', None),
                getattr(glyph, 'tsb', None),
                bb_left, bb_top, bb_right, bb_bottom,
                msg, # 描画が失敗したときのエラーメッセージです。
            ])

    print(f'文字の数: {n}')
    print(f'画像枚数: {len(ims)}')

    if ims:
        print('保存中 ...')

        # ちょうどすべての文字がおさまる共通の bbox を取得します。
        common_bbox = get_common_bbox(bboxes)
        print(f'common_bbox: {common_bbox}')
        print('png_size: ({width}, {height})'.format(
            width=common_bbox[2] - common_bbox[0],
            height=common_bbox[3] - common_bbox[1],
        ))

        # (デバッグ) bbox の座標は image_size におさまっているはず。
        assert common_bbox[0] >= 0 # left
        assert common_bbox[1] >= 0 # top
        assert common_bbox[2] <= image_size # right
        assert common_bbox[3] <= image_size # bottom

        # ims リストの中身を png データに変更します。
        # 余白についても、common_bbox で除去します。
        ims = make_pngs(ims, common_bbox)

        # zip ファイルのパスを決めます。
        png_zip = data_dir.joinpath('png.zip')

        # 文字画像を zip ファイルに保存します。
        with ZipFile(png_zip, 'w') as z:
            for (c, png) in ims:
                z.writestr(f'{c}.png', png)

    # (デバッグ) 文字情報を CSV に保存します。
    csv_file = data_dir.joinpath('font.csv')
    head = [[
        'n',
        'im',
        'c', 'chr', 'cmap',
        'width',
        'height',
        'lsb',
        'tsb',
        'bb_left', 'bb_top', 'bb_right', 'bb_bottom',
        'msg',
    ]]
    to_csv(csv_file, head + infos)

    # 以上です。
    return


def draw_letter(letter, image_font, image_size, position):
    """文字を描画します。"""
    # (描画 1/3) 画像 (Image) オブジェクトを作ります。
    # 背景色は黒 (color=0) です。
    # 画像は 1 bit のモノクロ画像形式 (2 値画像形式) です (mode='1')。
    im = Image.new(mode='1', size=(image_size, image_size), color=0)

    # (描画 2/3) 描画 (ImageDraw) オブジェクトを作ります。
    draw = ImageDraw.Draw(im)

    # (描画 3/3) 文字 (letter) を書きます。
    try:
        # 文字は白 (fill=1) で塗りつぶします。
        draw.text(xy=position, text=letter, font=image_font, fill=1)
    except AttributeError as e:
        # (デバッグ) エラーメッセージの最後の行を取得します。
        msg = format_exception_only(type(e), e)[0].rstrip('\n')
        bbox = None
        im = None
    else:
        # 何のエラーも発生しなかった。
        msg = None
        # 余白を除いた領域の座標 (bbox) を取得します。
        bbox = im.getbbox()
    return (im, bbox, msg)


def get_common_bbox(bboxes):
    """すべての文字がおさまる共通の bbox を取得します。"""
    # bbox の座標の最小と最大を入れる辞書です。
    limits = {}

    # bbox のひだり座標について、『最小』と最大を取得します。
    # min() と max() は Python の組み込み関数です。
    left_datas = tuple(x[0] for x in bboxes if x)
    limits['left'] = (min(left_datas), max(left_datas))
    del left_datas

    # bbox のうえ座標について、『最小』と最大を取得します。
    top_datas = tuple(x[1] for x in bboxes if x)
    limits['top'] = (min(top_datas), max(top_datas))
    del top_datas

    # bbox のみぎ座標について、最小と『最大』を取得します。
    right_datas = tuple(x[2] for x in bboxes if x)
    limits['right'] = (min(right_datas), max(right_datas))
    del right_datas

    # bbox のした座標について、最小と『最大』を取得します。
    bottom_datas = tuple(x[3] for x in bboxes if x)
    limits['bottom'] = (min(bottom_datas), max(bottom_datas))
    del bottom_datas

    # あまりピッタリの bbox だと、文字が見切れているように
    # 見えてしまったので、4 px くらいの余白を追加します。
    margin = 4

    # 左座標の『最小』にマージンを追加
    left = limits['left'][0] - margin

    # 上座標の『最小』にマージンを追加
    top = limits['top'][0] - margin

    # 右座標の『最大』にマージンを追加
    right = limits['right'][1] + margin

    # 下座標の『最大』にマージンを追加
    bottom = limits['bottom'][1] + margin

    # 画像の幅と高さを 4 の倍数に揃えます。
    # 剰余をゼロにするような数を加えて調整します。
    # 別に揃えなくてもいいのですが、もしかしたら、
    # 計算上の都合が良くなるかもしれないと思ったため。
    h_padding = 4 - (right - left) % 4
    if h_padding == 1:
        right += 1
    elif h_padding == 2:
        left -= 1
        right += 1
    elif h_padding == 3:
        left -= 1
        right += 2
    elif h_padding == 4:
        pass

    v_padding = 4 - (bottom - top) % 4
    if v_padding == 1:
        bottom += 1
    elif v_padding == 2:
        top -= 1
        bottom += 1
    elif v_padding == 3:
        top -= 1
        bottom += 2
    elif v_padding == 4:
        pass

    # (デバッグ) 幅も高さも、4 の剰余(じょうよ)はゼロのはず。
    assert (right - left) % 4 == 0
    assert (bottom - top) % 4 == 0
    return (left, top, right, bottom)


def make_pngs(ims, bbox):
    """PNG 画像を生成します。"""
    # リストの中の PIL 画像を、PNG 画像に置き換えていきます。
    for i in range(len(ims)):
        # ims[i]: [0]c [1]im

        # bbox の座標で画像を切り出します。
        ims[i][1] = ims[i][1].crop(bbox)

        # 書き込み用のバッファ b を用意します。
        with BytesIO() as b:
            # バッファに PNG 形式で書き込んでもらいます。
            ims[i][1].save(b, format='PNG')

            # 最後に、バッファからデータを取り出します。
            ims[i][1] = b.getvalue()
    return ims


def to_csv(file, datas):
    """文字情報を CSV に保存します。"""
    with file.open('w', encoding='utf-8', newline='') as f:
        w = csv.writer(f)

        # 最初のラベル行は、そのまま書き込みます。
        for data in datas[:1]:
            w.writerow(data)

        # 残りの行を書き込みます。
        for data in datas[1:]:
            # data[2]: c (Unicode コードポイントの整数)
            # data[3]: chr (文字)
            if data[2] <= 127:
                # ASCII コードの範囲には制御文字が混ざっていたので、
                # 文字の部分 data[3] を None にしました。
                # これで、LibreOffice Calc で開ける CSV ができました。
                data[3] = None
            w.writerow(data)
    return


if __name__ == "__main__":
    main()

実行結果

コマンドプロンプトの表示と、PNG 画像のサムネイルです。

『文字の数』は、TTFont.getBestCmap() で取得できた数になります。

『画像枚数』は、文字の描画に成功した数です。

大きさが特殊な文字で、余白 (margin) が少なすぎると、文字の描画に失敗しました。

PNG 画像についてです。

すべての文字から共通の bbox を取得したことで、文字をほぼ中央に描画することができました。

中央寄せについては、フォントの文字情報から頑張って計算する方法も試しました。

ですが、結局『共通の bbox を取得して切り抜く方法』が一番簡単で、なおかつ、期待した通りの結果になりました。

小さな記号についても、余白を含めた位置関係を保存したまま、画像化することができました。

サムネイルには入っていませんが、半角文字などについては、左寄せになりました。

半角文字なども中央に寄せるためには、1つ1つの文字情報から文字の幅を取得するなどして、描画位置を調整する必要がありました。

mplus-1m-regular.ttf

『mplus-1m-regular.ttf』のフォントを PNG 画像にした時の結果です。

使用した PC の CPU は、Core i5-9500 です。

start
initial_size: 128
image_size: 640
position: (256, 256)
font: ('M+ 1m', 'regular')
描画中 ...
文字の数: 7761
画像枚数: 7761
保存中 ...
common_bbox: (252, 252, 356, 396)
png_size: (104, 144)
8.8 秒で完了しました。
end

文字がうまく中央にきているのは、『大きさの特殊な文字』が、1文字分のスペースに収まるようにデザインされていたためです。

たとえば、『おなじ』や『どう』で変換できる『〲』という文字です。

ひらがなの『ぐ』にそっくりな文字です。

青空文庫の本を読んでいたら、ときどき見かけました。

これは、『おどり字』と呼ばれるものでした。

参考:(Wikipedia) 踊り字(おどりじ)

この文字は、縦に2文字分くらいの長さがあって、全収録文字の bbox を作るときに、余白が広くなってしまう原因のひとつになっていました。

ですが、M+ FONTS では1文字分の高さとしてデザインされていたので、うまく描画することができました。

VL-Gothic-Regular.ttf

『VL-Gothic-Regular.ttf』のフォントを PNG 画像にした時の結果です。

start
initial_size: 128
image_size: 640
position: (256, 256)
font: ('VL Gothic', 'regular')
描画中 ...
文字の数: 16116
画像枚数: 16116
保存中 ...
common_bbox: (247, 244, 387, 388)
png_size: (140, 144)
19.4 秒で完了しました。
end

中央からズレているように見えるのは、収録文字の中に『大きさの特殊な文字』があったためです。

NotoSansMonoCJKjp-Regular.otf

『NotoSansMonoCJKjp-Regular.otf』のフォントを PNG 画像にした時の結果です。

start
initial_size: 128
image_size: 640
position: (256, 256)
font: ('Noto Sans Mono CJK JP', 'Regular')
描画中 ...
文字の数: 44683
画像枚数: 44683
保存中 ...
common_bbox: (154, 235, 494, 427)
png_size: (340, 192)
44.7 秒で完了しました。
end

余白が広かったり、中央から大きくズレているように見えるのは、収録文字の中に『大きさの特殊な文字』があったためです。

特に、縦方向の余白が広いのは、ひらがなの『ぐ』みたい形をした踊り字のせいでした。

どうやら Noto フォントは、もともとの文字の大きさに沿って、忠実にデザインされているようでした。

ほかにも、どこで使われている文字なのか分かりませんが、めちゃめちゃ左に寄ったマルとか、すごく右に長い横棒などがありました。

そういった文字も含めて共通の bbox を取得した結果、『余白の広い画像』ができていました。

この余白やズレを減らすためには、文字の幅や高さの情報を見るなどして、特殊な文字を除外する必要がありました。

しかしながら、ほかの文字との相対的な位置関係も、文字の形の一部だと思います。

なので、そこをくずしてまで余白を削ったり、中央寄せにしたりする必要があるのかどうかは、自身の目的に照らして判断する感じですね。

以上です。

タイトルとURLをコピーしました