⚔️

FontForge のスクリプト機能と TTX を利用してフォントに文脈依存の置換機能を追加する

2025/01/12に公開

はじめに

前回の続きです。今回は文脈に依存した文字置換として calt タグを取り扱います。
文脈依存の文字置換が使えると自作フォントにリガチャ等が実装できるため、いろいろと夢が拡がります。
最初は FontForge だけで実装できないかと試行錯誤しましたが、私には無理!!という結論に達したため、無理!!の部分を TTX/FontTools と Shell スクリプトを使って実装させています。
今回も私が理解した (つもりの) 範囲で記事にしました。

0. 準備

引き続き DejaVuSansMono.ttf に実験台となっていただきます。サンプルスクリプトと同じディレクトリに置きます。

1. 作戦内容

  1. FontForge でダミーとして zero タグを追加 (使用しないタグであれば zero で無くても可)
  2. FontTools の ttx コマンドで GSUB フィーチャの TTX ファイルを出力
  3. 出力した TTX ファイルを Shell スクリプトで加工 (zero タグの部分をそっくり入れ換える)
  4. 加工後の TTX ファイルを使ってフォントの設定テーブルを書き換える

2. 生成フォントにさせたい挙動

  1. 「A」と「A」に挟まれた「A」は斜体に置換
  2. 「斜体 A」と「斜体 A」に挟まれた「斜体 A」は太字に置換
  3. ただし左が「B」「A」と並んだ場合、右に「A」があっても「A」は斜体にしない

3. FontForge スクリプトと解説

sample1.pe
inputName="DejaVuSansMono.ttf"
outputName="DSUBtestMono1.ttf"

Open(inputName)

# GSUB フィーチャを全て削除
lookups = GetLookups("GSUB")
i = 0
while (i < SizeOf(lookups))
  Print("Remove GSUB_" + lookups[i])
  RemoveLookup(lookups[i])
  i += 1
endloop

# GPOS フィーチャを全て削除
lookups = GetLookups("GPOS")
i = 0
while (i < SizeOf(lookups))
  Print("Remove GPOS_" + lookups[i])
  RemoveLookup(lookups[i])
  i += 1
endloop

origin  = 0u0041 # A
replace = 1114164 # GSUB フィーチャを削除したことで使われることのなくなったグリフの ID を指定

# calt 用の外字グリフを作成
Print("Make calt glyphs")
Select(origin); Copy() # A を斜体にしたグリフをサンプルとして作成
Select(replace); Clear(); DetachAndRemoveGlyphs()
Paste(); Transform(100, 0, 25, 100, 0, 0)
SetWidth(1233)

Select(origin); Copy() # 同じく A を太字にしたグリフをサンプルとして作成
Select(replace + 1); Clear(); DetachAndRemoveGlyphs()
Paste(); Move(-40, 0)
PasteWithOffset(40, 0)
RemoveOverlap()
SetWidth(1233)

# zero (ダミー) タグを追加 (サンプルでは最大2段階ほど置換するため2つ作成)
Print("Add dummy lookups")
i = 0
while (i < 2)
  lookups = GetLookups("GSUB"); numlookups = SizeOf(lookups)
  lookupName = "'zero' 後で文脈依存の設定に入れ換える " + ToString(i)
  AddLookup(lookupName, "gsub_single", 0, [["zero",[["DFLT",["dflt"]]]]])
  lookupSub = lookupName + "サブテーブル"
  AddLookupSubtable(lookupName, lookupSub)

  ## タグを追加しても使わないとフォント生成時に削除されるため、ダミーの設定を作る
  Select(0u00a0)
  glyphName = GlyphInfo("Name")
  Select(0u0020)
  AddPosSub(lookupSub, glyphName)
  i += 1
endloop

# 単純置換テーブル (タグ無し) を追加
# (calt テーブルに記述された条件と一致した場合に呼び出されるテーブル。
# 同じグリフを複数のグリフに置換したい場合はその数だけテーブルが必要)
Print("Add replaceacement lookups")
i = 0
while (i < 2)
  lookups = GetLookups("GSUB"); numlookups = SizeOf(lookups)
  lookupName = "単純置換" + ToString(i)
  AddLookup(lookupName, "gsub_single", 0, [], lookups[numlookups - 1])
  lookupSub = lookupName + "サブテーブル"
  AddLookupSubtable(lookupName, lookupSub)

  ## 「A」 を「斜体A」に置換する設定と「A」または「斜体A」を「太字A」に置換する設定を追加
  Select(replace + i)
  glyphName = GlyphInfo("Name")
  Select(origin)
  AddPosSub(lookupSub, glyphName)
  if (i == 1)
    Select(replace)
    AddPosSub(lookupSub, glyphName)
  endif
  i += 1
endloop

Generate(outputName)

Close()

4. 中間結果

スクリプト実行。

fontforge -script sample1.pe

生成された中間フォント (DSUBtestMono1.ttf)。
TTX ファイルを加工する際はルックアップ番号とグリフ名を間違えないようにしなければいけません。

TTX ファイルを出力して内容を確認してみます。

ttx -t GSUB DSUBtestMono1.ttf

赤く囲んだ部分を入れ換えます。

5. Shell スクリプトと解説

sample2.sh
#!/bin/bash

inputName="DSUBtestMono1"
outputName="DSUBtestMono2"
caltSet="caltSetting" # calt の設定用一時ファイル名
lookupIndex_calt=0 # calt テーブルの先頭ルックアップ番号
lookupIndex_replace=2 # 単純置換テーブルの先頭ルックアップ番号

# 前回実行時に作成したファイルを削除
rm -f ${inputName}.ttx
rm -f ${inputName}.ttx.bak

# GSUB の内容を XML 形式にして取り出す
ttx -t GSUB "${inputName}.ttf"

# TTX ファイルの編集開始

# ダミーの zero タグを calt タグに 置換
sed -i.bak -e 's,FeatureTag value="zero",FeatureTag value="calt",' "${inputName}.ttx"

# 1番目の calt テーブルを編集
substIndex=0 # レコードのインデックス番号
echo "Edit Lookup index ${lookupIndex_calt}"

# zero タグのダミー設定を削除
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"

# 新しい設定を作成し、一時ファイルに出力する
## 最初のおまじない
cat > ./${caltSet}.txt << _EOT_
        <LookupType value="6"/>
        <LookupFlag value="0"/>
_EOT_

## レコード 0
cat >> ./${caltSet}.txt << _EOT_
        <!-- 設定 0-0 左が「A」で その左が「B」の場合、「A」はそのまま -->
        <ChainContextSubst index="${substIndex}" Format="3">
          <!-- ↓後ろ (左側) の文字 -->
          <BacktrackCoverage index="0">
            <Glyph value="A"/>
          </BacktrackCoverage>
          <!-- ↓2つ左の文字、3つ以上先も指定できる (AND になる、右側も同様) -->
          <BacktrackCoverage index="0">
            <Glyph value="B"/>
          </BacktrackCoverage>
          <!-- ↓置換対象の文字 -->
          <InputCoverage index="0">
            <Glyph value="A"/>
          </InputCoverage>
          <!-- 置換対象の左右の文字を指定しない場合、ワイルドカード (文字無し含む) として働く -->
          <!-- 呼び出す単純置換テーブルを指定しない場合、置換せずに次のテーブルへ進む -->
        </ChainContextSubst>
_EOT_

## レコード 1
substIndex=$((substIndex + 1))
cat >> ./${caltSet}.txt << _EOT_
        <!-- 設定 0-1 左が「A」か「斜体 A」で右が「A」の場合、「A」を斜体にする -->
        <ChainContextSubst index="${substIndex}" Format="3">
          <!-- ↓置換対象の左右の文字は複数指定できる (OR になる) -->
          <!-- ↓ただしグリフの ID 順に記述しないと TTX にしかられる -->
          <BacktrackCoverage index="0">
            <Glyph value="A"/>
            <!-- ↓作成した外字のグリフ名 -->
            <Glyph value="NameMe.1114164"/>
          </BacktrackCoverage>
          <InputCoverage index="0">
            <Glyph value="A"/>
          </InputCoverage>
          <!-- ↓前 (右側) の文字 -->
          <LookAheadCoverage index="0">
            <Glyph value="A"/>
          </LookAheadCoverage>
          <!-- ↓条件が成立した場合に呼び出す単純置換テーブルのルックアップ番号を指定する -->
          <SubstLookupRecord index="0">
            <SequenceIndex value="0"/>
            <LookupListIndex value="${lookupIndex_replace}"/>
          </SubstLookupRecord>
        </ChainContextSubst>
_EOT_

# 一時ファイルの内容を TTX ファイルに挿入
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/r ${caltSet}.txt" "${inputName}.ttx"

# 2番目の calt テーブルを編集
lookupIndex_calt=$((lookupIndex_calt + 1))
substIndex=0
echo "Edit Lookup index ${lookupIndex_calt}"

sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"
sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/{n;d;}" "${inputName}.ttx"

cat > ./${caltSet}.txt << _EOT_
        <LookupType value="6"/>
        <LookupFlag value="0"/>
_EOT_

## レコード 0
cat >> ./${caltSet}.txt << _EOT_
        <!-- 設定 1-0 左が「斜体 A」か「太字 A」で右が「斜体 A」の場合「斜体 A」を太字にする -->
        <ChainContextSubst index="${substIndex}" Format="3">
          <!-- ↓Backtrack には、前までと現在のテーブルで置換されるグリフが指定できる -->
          <BacktrackCoverage index="0">
            <Glyph value="NameMe.1114164"/>
            <Glyph value="NameMe.1114165"/>
          </BacktrackCoverage>
          <!-- ↓Input には、前のテーブルまでに置換されたグリフが指定できる -->
          <InputCoverage index="0">
            <Glyph value="NameMe.1114164"/>
          </InputCoverage>
          <!-- ↓LookAhead に指定できるグリフは Input と同じ -->
          <LookAheadCoverage index="0">
            <Glyph value="NameMe.1114164"/>
          </LookAheadCoverage>
          <SubstLookupRecord index="0">
            <SequenceIndex value="0"/>
            <LookupListIndex value="$((lookupIndex_replace + 1))"/>
          </SubstLookupRecord>
        </ChainContextSubst>
_EOT_

sed -i.bak -e "/Lookup index=\"${lookupIndex_calt}\"/r ${caltSet}.txt" "${inputName}.ttx"

# フォント生成

# 更新前のフォントファイルを残すため一旦名前を変更
mv "${inputName}.ttf" "${inputName%%.ttf}.orig.ttf"
# TTX ファイルの内容で書き換えられたフォントを生成し、名前を変更
ttx -m "${inputName}.orig.ttf" "${inputName}.ttx"
mv "${inputName}.ttf" "${outputName}.ttf"
# 更新前のフォントファイルの名前を元に戻す
mv "${inputName%%.ttf}.orig.ttf" "${inputName}.ttf"

exit

6. 最終結果

スクリプト実行。

./sample2.sh

生成された最終フォント (DSUBtestMono2.ttf)。
zero タグが calt タグに置き換わり、サブテーブルが増えています。

7. フォントの動作確認

通常、calt はデフォルトで ON です。
「A」と「A」に挟まれた「A」が斜体になっていますが、左が「B」「A」の場合は立体のままです。
また「斜体 A」と「斜体 A」に挟まれた「A」は太字になっています。


希望通りの動作ができているようです。

8. もう少し詳しく解説

前回扱った1対1の文字置換は単純で分かりやすいのですが、文脈依存の文字置換をうまく利用するにはテキストデータが表示されるまでの過程を理解しておく必要があるため、少しだけ補足します。項目 7. 以外は1対1の文字置換の場合も同様です。

  1. 文字を入力するなど、状態に変化があるたびに置換された全てのグリフは一旦リセットされ、1文字ずつ順番に処理し直される。
  2. 各文字はルックアップ番号0のテーブルから順番に (フィーチャが ON の場合) 評価される。
  3. ただし単純置換テーブルはサブルーチンであり、グリフ置換後、呼び出した評価テーブルに処理が戻される。
  4. 各テーブルではレコードのインデックス番号0から評価され、条件が成立した時点で所定の単純置換テーブルが呼び出されて (あるいは呼び出されずに) 次の評価テーブルへ処理が移される (残りのレコードはキャンセルされる)。
  5. 成立する条件が無かった場合、そのまま次の評価テーブルに処理が移される。
  6. ルックアップ番号1以降のテーブルではその前までの処理結果のグリフが再度評価されるため、1度置換されたグリフでも後のテーブルで条件が成立すればさらに置換される。
  7. Backtrack 側から LookAhead 側に向かって1文字ずつ処理されるため、Backtrack には置換後のグリフを条件に指定できるが、LookAhead 側は置換前のグリフしか条件に指定できない (置換後のグリフを指定しても条件が成立しない、後のテーブルで処理させる必要がある)。
  8. 置換されるのはグリフ (見た目) だけであり、文字コードが変わるわけではない。

分かってしまえば複雑なことは何も無いのですが、知らないと思い通りにいかず悩むことになります。私は最初 6. の仕組みに気付かずに1つのテーブルで全てを済まそうとしていたため、「calt って大して使えんな」と思っていました。

おわりに

今回の方法はスマートとは言えない気がしますが、無事に目的を果たすことが出来ました。共通部分を関数化すればより扱いやすくなると思います。

Discussion