フォント編集ソフト OTEdit問題

フォント編集ソフト「OTEdit」で作成されたフォントの空グリフ問題について解説。Adobe-Japan1文字集合選択時に発生する空グリフによりフォールバック機能が正常に動作しな い問題と、Pythonスクリプトを使った修正方法を紹介。

By Toshiyuki Yoshida

OTEditとは

OTEditとは、武蔵システムが販売する OpenType フォントを作成・編集を行なうアプリケーションです。 Windows 版と Mac 版が販売されています。

この手のアプリケーションとしては比較的安価で使っているフォント作家の方も多いようです。

OTEditの問題

(以下の考察は、OTEditの試用版による短時間の検証に基づいています。全ての機能を検証できている訳ではないので誤りを含む可能性があります。)

OTEdit では Adobe-Japan1-3、Adobe-Japan1-4、 Adobe-Japan1-7 の 3 種の文字集合のフォントを作成できます。 新規作成時に選択した文字集合について全てのグリフを設定することを前提としているようです。 そのためOTEdit ではフォントの新規作成時に文字集合を選択すると文字の削除ができず、グリフが作成されなくとも「空グリフ」と 各文字コードとグリフの cmap を作成してしまいます。

すべてのフォントが Adobe 社の策定した日本語文字集合規格で作成されればよいのですが、 実際にはいずれかの文字集合を選択してもすべてのグリフをサポートしないフォントも多いようです。 例えば、IPA が配布する IPA フォントです。このフォントがどんなツールを使用して作成されたか不明ですが、 JIS X 0213:2012 の文字集合をサポートしており Adobe 社の文字集合規格には従っていません。 Adobe の規格と並べると下表のような食い違いがあります。

文字セット総文字数JIS第1~2水準(6,355字)JIS第3~4水準(3,695字)JIS規格外文字(異体字・独自文字)主な特徴
JIS X 0213:201211,233文字完全対応完全対応含まない公的規格・政府標準
Adobe-Japan1-39,354文字完全対応非対応機種依存文字等含む一般用途標準
Adobe-Japan1-415,444文字完全対応一部対応多数の独自文字・異体字含む商業印刷用
Adobe-Japan1-723,060文字完全対応完全対応大量の独自文字・異体字含む最上位規格

このようなケースでは、OTEdit は cmap は生成しますが作者がグリフを用意していない文字には「空グリフ」をマッピングしてしまいます。

通常の使用ではあまり問題にならないかもしれませんが、フォント表示の際にフォールバックを前提にしている ターミナルやブラウザなどで使用するとマズいことになります。 本来であれば指定したフォントがグリフを持たない場合は、別のフォントのグリフが表示されます。 しかし OTEdit で作成したフォントでは、「空グリフ」が設定されている可能性が高いので「空白」が表示されてしまいます。

フォントの修正

最近購入した中村書体室の「カドマ-R」でこの不具合の遭遇したので、 作者の方に相談させていただき手元のフォントに修正を掛けました。

対応は、空グリフと思われる文字を調査し該当文字について cmap からエントリーを削除するという乱暴なものです。 以下がそのスクリプトです。

分析スクリプト

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys
import os

# HomebrewのPython環境でFontToolsを使用
sys.path.insert(0, '/opt/homebrew/Cellar/fonttools/4.59.2/libexec/lib/python3.13/site-packages')

try:
    from fontTools.ttLib import TTFont
    from fontTools.pens.recordingPen import RecordingPen
    import unicodedata
except ImportError as e:
    print(f"Import error: {e}")
    sys.exit(1)

def analyze_font_glyphs(font_path):
    """フォントファイルを分析して空のグリフとスペースグリフを区別する"""

    font = TTFont(font_path)
    cmap = font.getBestCmap()

    if 'CFF ' in font:
        cff = font['CFF ']
        charstrings = cff.cff[0].CharStrings
    else:
        print("This is not a CFF font")
        return

    hmtx = font['hmtx']
    results = []

    space_codes = [0x0020, 0x3000]  # SPACE, IDEOGRAPHIC SPACE

    for unicode_code, glyph_name in sorted(cmap.items()):
        if glyph_name in hmtx.metrics:
            width, lsb = hmtx.metrics[glyph_name]
        else:
            width, lsb = 0, 0

        if glyph_name in charstrings:
            charstring = charstrings[glyph_name]

            pen = RecordingPen()
            try:
                charstring.draw(pen)
                commands = pen.value
                has_drawing = len(commands) > 0
            except Exception:
                has_drawing = False

            # グリフの分類
            is_space_char = unicode_code in space_codes

            if not has_drawing:
                if width > 0:
                    if is_space_char:
                        glyph_type = "SPACE"
                    else:
                        glyph_type = "BLANK_WITH_WIDTH"
                else:
                    glyph_type = "EMPTY"
            else:
                glyph_type = "NORMAL"

            results.append({
                'unicode': f"U+{unicode_code:04X}",
                'code': unicode_code,
                'char': chr(unicode_code),
                'glyph_name': glyph_name,
                'type': glyph_type,
                'width': width,
                'commands_count': len(commands) if has_drawing else 0,
            })

    return results

判定基準は、次の通りです。

  • NORMAL: 描画データがあるグリフ
  • SPACE: スペース文字(U+0020, U+3000)で描画データなし
  • BLANK_WITH_WIDTH: スペース以外で幅はあるが描画データなし → 削除対象
  • EMPTY: 幅や描画データがない → 削除対象

自動修正スクリプト


def fix_font_cmap_only(input_path, output_path):
    """OTEで作成されたフォントのcmapテーブルから空グリフエントリを削除"""

    font = TTFont(input_path)
    original_cmap = font.getBestCmap()

    if 'CFF ' in font:
        cff = font['CFF ']
        charstrings = cff.cff[0].CharStrings
    else:
        return False

    hmtx = font['hmtx']
    codes_to_remove = []

    space_code = 0x0020
    ideographic_space_code = 0x3000

    for unicode_code, glyph_name in original_cmap.items():
        if glyph_name in hmtx.metrics:
            width, lsb = hmtx.metrics[glyph_name]
        else:
            width, lsb = 0, 0

        if glyph_name in charstrings:
            charstring = charstrings[glyph_name]

            pen = RecordingPen()
            try:
                charstring.draw(pen)
                commands = pen.value
                has_drawing = len(commands) > 0
            except Exception:
                has_drawing = False

            # スペース文字以外で描画データがない場合は削除対象
            if unicode_code not in [space_code, ideographic_space_code]:
                if not has_drawing and width > 0:
                    codes_to_remove.append(unicode_code)

    # cmapテーブルから削除
    for table in font['cmap'].tables:
        if hasattr(table, 'cmap'):
            for code in codes_to_remove:
                if code in table.cmap:
                    del table.cmap[code]

    font.save(output_path)
    return True, len(codes_to_remove)

修正結果

修正結果は次の通りです。

  • 総 cmap エントリ数: 10,161 → 5,762 個(削減率: 43.3%)
  • 削除されたエントリ: 4,399 個
  • Adobe Japan 1-4 拡張範囲: 408 個
  • その他の範囲: 3,991 個
  • ファイルサイズ: 2.6% 削減

修正の結果、 今のところターミナルでも問題なく使用できています。

上記のスクリプトは精緻にテストを行なっていませんので、 利用については自己責任でどうぞ。