縦書きを実装してみた

はじめに

この記事は東京高専プロコンゼミアドベントカレンダー2021の記事です.
今は山梨大学編入した身ですが,枠が空いているからとせがまれ書くことになりました.

この記事について

以前,電子ペーパーでなろう小説を読めるようにするを書きました. 電子ペーパーを搭載したM5Stack製のガジェットであるM5Paperでなろう小説を読めるようにしようと頑張った記事です.

記事を読んでいただけたら分かる通り,HTMLをレンダリングしてM5Paperに転送しています.

出力する画面はキレイではあるものの,ページ遷移やデータ転送が異様に遅いものとなっています.また,スマホorPCありきでスタンドアロンではありません.生成される画面がスマホorPC依存であり,崩れた際にCSSを調整するのも一苦労でした. 同時にネットワーク固有の問題も少々出てきました.

なので「M5Paperでなろう小説を読めるようにする」を目標にM5Paper上での縦書きレンダリングを試みました.M5Paper上で縦書きレンダリングが実現できれば上記の問題のほとんどは消えるはずです.

結果としてはあとは移植するだけのところまでできました. 全体的な方針と実装についてを書き記そうと思います.

方針

縦書きを愚直に実装したときに起こる問題

縦書きと言われてどのような実装を思い浮かべますか?まず一番最初に, 一文字一文字のグリフをポンポン縦に並べる実装が思いつきますが,これではダメです.

f:id:ynakano1127:20211225205751p:plain

画像のように小文字や記号の表示に違和感がでてしまうことが容易に想像できるかと思います. 画像では「ごちゅうもん」の「ゅ」だったり「データベース」の「ー」で表記ミス(と捉えられるもの)が起きています.「\「ごじゃいまーす\」」のように鉤括弧が入ったりすると最悪です.なろう小説では特にダッシュ(―)が多用される傾向にあるので,対応が必要です.

一般のソフトウェアでの実装

一般のソフトウェアはHarfbuzzというライブラリを使用しています.縦書きだけではなく,合字変換やら方向の指定やらができる超高レイヤのライブラリです.後述のFreeTypeに依存しています.

The current HarfBuzz codebase is versioned 2.x.x and is stable and under active maintenance. This is what is used in latest versions of Firefox, GNOME, ChromeOS, Chrome, LibreOffice, XeTeX, Android, and KDE, among other places.

FirefoxGNOME,ChromeOS,ChromeLibreOffice,XeTeX,KDEはテキストレンダリングでHarfbuzzを使っているらしいです.凄いですね.関係ないですが,GTKはHarfbuzzに依存したPangoをテキストレンダリングに使っている?っぽいです.

M5Paper上でどのように実現するか

上述のHarfbuzzですが,内部でシステムコールを使ってそう(あんまり詳しく調べてない)かつ使うリソースが多そうという理由で諦めました.

Harfbuzzを使わずに文字レンダリングをするには一つレイヤーを下げてFreeTypeを使うしかありません.(フルスクラッチはキツいので) そもそも,M5Paperを製品として発売するタイミングでFreeTypeを移植してくれていたので,これを使うのが妥当です.

ざっくり言うと,FreeTypeはフォントファイルと文字コード(例えばUnicodeの「あ」は「U+3042」)からビットマップを計算するライブラリです.アウトライン形式のフォントファイルを扱うときとかは特に楽ですよね.

基本的に以下のような使い方をします.チュートリアルはこちら

  1. 文字コードからグリフIDを取得する(FT_Get_Char_Index()
  2. グリフを読み出す(FT_Load_Glyph()
  3. ビットマップを計算(FT_Render_Glyph()

フォントファイルの中身では文字コードとは独立したファイル固有のIDを使ってグリフを管理しています.ここではそれをグリフIDと読んでいます.文字コードとグリフIDの対応付けはフォント内のmapテーブルにて行われます.FreeTypeでは文字コードからグリフIDへの変換をする関数(FT_Get_Char_Index())を用意してくれているので,1番で使用しています.

さて,FreeTypeの使用方法が分かりましたが,このまま使ってしまっては横書き用のグリフが読み出されるだけです.やるべきことは1番と2番の間で縦書き用グリフIDにすり替えることです.しかし,悲しいことにFreeTypeではそのような関数はありません.2番のLOAD時に利用可能なフラグFT_LOAD_VERTICAL_LAYOUTなるものがあり,一瞬心踊ったのですが,

Load the glyph for vertical text layout. In particular, the advance value in the FT_GlyphSlotRec structure is set to the vertAdvance value of the metrics field.

とのことです.縦書きにしたときの対象の文字が専有する高さが取得できるそうですが,肝心の縦書きグリフは取得できません.

横書きグリフIDから縦書きグリフIDへ変換する魔法の箱を作る必要がありそうです.

縦書きグリフを読み出す

フォントファイルの形式

今まで書き連ねてきた「フォントファイル」には様々な種類があります.アウトラインフォントとビットマップフォントで分けられるのは有名な話です.アウトラインには更にPostScriptフォントとTrueTypeフォントで分けられます.OpenTypeはTrueTypeの発展であり,PostScriptを変換したCFFも組み込むことができます.こちらのPDFが非常に参考になります.

OpenTypeの仕様書はこちら

魔法の箱はOpenTypeで定義されるGSUBを読み出して組み立てていきます.

以下余談

自分はこのTrueType/PostScript/OpenTypeの違いが曖昧でした.M5PaperはTTF形式のものを読み込むと至る所に掲載されており,OTFであるNotoを読み込ませた所,エラーを吐いて描画されませんでした.そのため,縦書き不能なTTFのみ描画可能で,縦書き可能なOTFは受け付けないものだと勘違いしてしまいました.

実際は,TrueTypeが入ったOpenTypeファイルとTrueTypeファイルは描画可能,CFF(PostScriptを変換したもの)が入ったOpenTypeは描画不可能なので,縦書きをしつつM5Paperで描画するというのは可能っぽいというのに気づきました.いざNotoについて調べてみるとPostScript形式であると掲載されていました.

フォント形式もPostScriptアウトラインによるOpenTypeとなっている。
Noto-Wikipedia
https://ja.wikipedia.org/wiki/Noto

この記事を書いているときに気づいたので,ちょうど今,記事の書き換えに苦しんでいます.

OpenTypeのテーブル

※これより,こちらの記事が死ぬほど参考になります.いわゆるバイブルってやつです.

GSUBはOpenTypeに定義されるテーブルの一つです.テーブルで有名なのは先程も話に上がった文字コードとグリフIDを変換するcmapでしょうか.簡単なものとしてheadを例に挙げます.

Type Name
uint16 majorVersion
Fixed fontRevision
uint32 checksumAdjustment
uint32 magicNumber
以下略 以下略

定義書を見れば分かりますがこんな感じでデータが型と一緒に定義されます.ビッグエンディアンで配置されているので気をつけましょう.GSUBも同様にデータを順々に読み出していけばグリフIDの変換規則を手に入れることができます.

そもそもheadテーブルやらGSUBテーブルはどのように到達するか.これはファイルの先頭を見れば分かります.テーブル名とオフセットが対応付けられるtableRecordをみると解決です.

TableDirectory:

Type Name Description
uint32 sfntVersion 0x00010000 or 0x4F54544F ('OTTO') — see below.
uint16 numTables Number of tables.
uint16 searchRange Maximum power of 2 less than or equal to numTables, times 16 ((2**floor(log2(numTables))) * 16, where “**” is an exponentiation operator).
uint16 entrySelector Log2 of the maximum power of 2 less than or equal to numTables (log2(searchRange/16), which is equal to floor(log2(numTables))).
uint16 rangeShift numTables times 16, minus searchRange ((numTables * 16) - searchRange).
tableRecord tableRecords[numTables] Table records array—one for each top-level table in the font

TableRecord

Type Name Description
Tag tableTag Table identifier.
uint32 checksum Checksum for this table.
Offset32 offset Offset from beginning of font file.
uint32 length Length of this table.

NotoSansCJKjp-Thin.otf では以下のようなテーブルとそれに対応するオフセットが用意されています.

テーブル名 オフセット(16進数)
BASE e73b84
CFF 45238
DSIG e08920
GPOS e08928
GSUB e1326c
OS/2 170
VORG e337b8
cmap fc0
head 10c
hhea 144
hmtx e33b9c
maxp 168
name 1d0
post 45218
vhea e73b60
vmtx dc8c04

GSUBテーブル

さて,ここからが本題です.GSUBは先程のheadに比べて複雑なのでちゃんとした理解が必要です.なお,ここでは縦書きを実現する機能としてGSUBを紹介していますが,GSUB自体はグリフ置換全般で用いられる仕様です.

GSUBヘッダーについて仕様書にこのようにかかれています.

Type Name Description
uint16 majorVersion Major version of the GSUB table, = 1
uint16 minorVersion Minor version of the GSUB table, = 0
Offset16 scriptListOffset Offset to ScriptList table, from beginning of GSUB table
Offset16 featureListOffset Offset to FeatureList table, from beginning of GSUB table
Offset16 lookupListOffset Offset to LookupList table, from beginning of GSUB table

GSUBですべきことは大きく3工程に分けることができます.

  1. 文字体系と言語を選択
  2. 機能を選択
  3. 文字変換

それぞれ,ScriptListテーブル,FeatureListテーブル,LookupListテーブルを読み込んで行います.GSUBヘッダーにあるscriptListOffsetfeatureListOffsetlookupListOffsetはこれらのテーブルのオフセットです.それぞれGSUBテーブルの先頭からのオフセットであることも仕様書から分かります.

文字体系と言語を選択

1. 文字体系と言語を選択 に関して,ScriptListテーブルに加えて以下のようなテーブルorレコードが定義されています.レコードはテーブルと本質的にあまり変わりません.経験から分かったことですが,テーブルの中に配列として持っているものをRecordと呼ぶそうです.実際,ScriptListの最後にScriptRecordが配列としてありますし,ScriptのなかにLangSysRecordが配列としてあります.

  1. ScriptList table
  2. ScriptRecord
  3. Script table
  4. LangSysRecord
  5. LangSys table

以下テーブルの定義です.

ScriptList table

Type Name Description
uint16 scriptCount Number of ScriptRecords
ScriptRecord scriptRecords[scriptCount] Array of ScriptRecords, listed alphabetically by script tag

ScriptRecord

Type Name Description
Tag scriptTag 4-byte script tag identifier
Offset16 scriptOffset Offset to Script table, from beginning of ScriptList

Script table

Type Name Description
Offset16 defaultLangSysOffset Offset to default LangSys table, from beginning of Script table — may be NULL
uint16 langSysCount Number of LangSysRecords for this script — excluding the default LangSys
LangSysRecord langSysRecords[langSysCount] Array of LangSysRecords, listed alphabetically by LangSys tag

LangSysRecord

Type Name Description
Tag langSysTag 4-byte LangSysTag identifier
Offset16 langSysOffset Offset to LangSys table, from beginning of Script table

LangSys table

Type Name Description
Offset16 lookupOrderOffset = NULL (reserved for an offset to a reordering table)
uint16 requiredFeatureIndex Index of a feature required for this language system; if no required features = 0xFFFF
uint16 featureIndexCount Number of feature index values for this language system — excludes the required feature
uint16 featureIndices[featureIndexCount] Array of indices into the FeatureList, in arbitrary order

機能を選択

2. 機能を選択 に関してはFeatureListテーブルに加えて以下のテーブルorレコードが定義されています.

  1. FeatureList table
  2. FeatureRecord
  3. Feature table

仕様書は以下の通り.

FeatureList table

Type Name Description
uint16 featureCount Number of FeatureRecords in this table
FeatureRecord featureRecords[featureCount] Array of FeatureRecords — zero-based (first feature has FeatureIndex = 0), listed alphabetically by feature tag

FeatureRecord

Type Name Description
Tag featureTag 4-byte feature identification tag
Offset16 featureOffset Offset to Feature table, from beginning of FeatureList

Feature table

Type Name Description
Offset16 featureParamsOffset Offset from start of Feature table to FeatureParams table, if defined for the feature and present, else NULL
uint16 lookupIndexCount Number of LookupList indices for this feature
uint16 lookupListIndices[lookupIndexCount] Array of indices into the LookupList — zero-based (first lookup is LookupListIndex = 0)

文字変換

3. 文字変換 に関してはLookupListテーブルに加えて以下のテーブルorレコードが定義されています.

  1. LookupList table
  2. Lookup table
  3. LookupType 1: Single Substitution Subtable
  4. LookupType 2: Multiple Substitution Subtable
  5. LookupType 3: Alternate Substitution Subtable
  6. LookupType 4: Ligature Substitution Subtable
  7. LookupType 5: Contextual Substitution Subtable
  8. LookupType 6: Chained Contexts Substitution Subtable
  9. LookupType 7: Extension Substitution
  10. LookupType 8: Reverse Chaining Contextual Single Substitution Subtable

LookupList table

Type Name Description
uint16 lookupCount Number of lookups in this table
Offset16 lookupOffsets[lookupCount] Array of offsets to Lookup tables, from beginning of LookupList — zero based (first lookup is Lookup index = 0)

Lookup table

Type Name Description
uint16 lookupType Different enumerations for GSUB and GPOS
uint16 lookupFlag Lookup qualifiers
uint16 subTableCount Number of subtables for this lookup
Offset16 subtableOffsets[subTableCount] Array of offsets to lookup subtables, from beginning of Lookup table
uint16 markFilteringSet Index (base 0) into GDEF mark glyph sets structure. This field is only present if the USE_MARK_FILTERING_SET lookup flag is set.

Lookup tableのlookupTypeによって,そのLookup tableが指定するsubtableの構造が変わります.今回は1文字から1文字の変換さえできれば良いので,LookupType 1: Single Substitution Subtableのみ紹介します.これにはフォーマットが2つあります.

LookupType 1: Single Substitution Subtable (format=1)

Type Name Description
uint16 substFormat Format identifier: format = 1
Offset16 coverageOffset Offset to Coverage table, from beginning of substitution subtable
int16 deltaGlyphID Add to original glyph ID to get substitute glyph ID

LookupType 1: Single Substitution Subtable (format=2)

Type Name Description
uint16 substFormat Format identifier: format = 2
Offset16 coverageOffset Offset to Coverage table, from beginning of substitution subtable
uint16 glyphCount Number of glyph IDs in the substituteGlyphIDs array
uint16 substituteGlyphIDs[glyphCount] Array of substitute glyph IDs — ordered by Coverage index

変換の際に必要になるテーブルとしてCoverageテーブルがあるので,こちらも乗っけます.これもフォーマットが2つあります.

Coverage Format 1

Type Name Description
uint16 coverageFormat Format identifier — format = 1
uint16 glyphCount Number of glyphs in the glyph array
uint16 glyphArray[glyphCount] Array of glyph IDs — in numerical order

Coverage Format 2

Type Name Description
uint16 coverageFormat Format identifier — format = 2
uint16 rangeCount Number of RangeRecords
RangeRecord rangeRecords[rangeCount] Array of glyph ranges — ordered by startGlyphID

CoverageテーブルにRangeRecordなるデータもありますね.これも別途定義されているので,乗っけます.

RangeRecord

Type Name Description
uint16 startGlyphID First glyph ID in the range
uint16 endGlyphID Last glyph ID in the range
uint16 startCoverageIndex Coverage Index of first glyph ID in range

GSUBテーブル2

とまぁペタペタと仕様書をコピペしたわけですが,これでは仕様書の2番煎じですよね(二番煎じを否定する気は全くありませんが).

実装する上で役立ちそうな「お気持ち」を書き記します.図のデータはNotoのNotoSansCJKjp-Thin.otfです.具体的な値は手打ちによるミスがあるかもしれませんのであしからず.

f:id:ynakano1127:20211225195954p:plain

図のように木構造になっていて必要なデータを求めてぴょんぴょん飛ぶお気持ちです.

  1. GSUBヘッダーからscriptListOffsetを取得して飛ぶ
  2. ScriptList中のScriptRecordを一つづつみる.タグがkana(ひらがな/カタカナ)のものを選ぶ.(無かったらhani(中国語/日本語/韓国語),DFLT(デフォルト)を選ぶといいと思います.)
  3. scriptOffsetを見てScriptへ飛ぶ

ここまでで1. 文字体系と言語を選択のうち文字体系を選択できたことになります.

f:id:ynakano1127:20211225200125p:plain 1. 選んだScriptに格納されているデフォルトのLangSysを取得

もしくは

  1. 選んだScript中のLangSysRecordを見ていき,タグがJAN(日本語)であるものを選択
  2. LangSysを取得

ここまでで1. 文字体系と言語を選択が出来たことになります.

f:id:ynakano1127:20211225200422p:plain

同様にして2. 機能を選択を進めます.

  1. ScriptListと同様にGSUBヘッダからFeatureListのトップに行きます.選んだ言語(LangSys)が持つ置き換え機能(FeatureRecord)を得るためにLangSysのfeatureIndicesでフィルターします.
  2. aalt(複数あるバリエーションからの選択)やらccmp(2つ以上のグリフの合字/分解)やらありますが,ここからvertもしくはvrt2を選択します.vertvrt2の違いは欧文の対応がなされているかなされていないかの違いらしいです.(大参考
  3. featureOffsetをみて飛びます.

ここまでで2. 機能を選択が出来たことになります.

f:id:ynakano1127:20211225200804p:plain

同様にして3. 文字変換 を進めます.やりたいことの本質部分にやっと来れました.Notoにない組み合わせが出てくるのでここからは具体的なデータを図に書きません.

  1. 機能選択と同様にFeatureで取得したlookupListIndicesでlookupListをフィルターします.
  2. Lookupに飛びます.このLookupが置き換えのテーブルです.

f:id:ynakano1127:20211225184643p:plain

Coverageテーブルは書き換え元を表し,Subtableは変換規則を表します.(重要)

具体的に以下の手順でLookupテーブルを使用します.

  1. サブテーブルへ移動
  2. CoverageFormatへ移動
  3. 書き換えたいglyphIDをCoverageテーブルのglyphArray中から探索する.(見つからなければ変換の必要がないとする)
  4. SubtableのdeltaGlyphIDを足し合わせてグリフIDのの変換終了.

上述の通りLookup Type1 SubtableとCoverageにはそれぞれ2種類のフォーマットが仕様として存在します.上ではFormat1,Format1の組み合わせを書きましたが,それらの他に以下のような組み合わせも考えられるわけです.

Lookup Type1 Subtable Coverage
Format 1 Format 2
Format 2 Format 1
Format 2 Format 2

最後に一番下のFormat2×Format2という組み合わせを考えた図を乗っけます.

f:id:ynakano1127:20211225184735p:plain

手順は以下の通り

  1. サブテーブルへ移動
  2. CoverageFormatへ移動
  3. rangeRecordのstartGlyphIDからendGlyphIDまでに対象のグリフIDがあるかを確認.もし範囲内ならindex = glyphID - startGlyphID + startCoverageIndexとしてindexを定義.範囲外ならば置き換え対象ではない.
  4. 3番で求めたindexを使ってsubstituteGlyphID[index]を求める.その値こそが置き換えるグリフIDである.

実装

これを実装します.大参考のページでソースコードを公開してくれているので,これをベースにシークすれば実装可能です.

が,キツイっす.400行に達したあたりで完全に手が止まりました.手続き型の限界を感じて一人ソフトウェア危機を迎えました.

今は令和時代です.先人が頑張った結果として我々には強い味方「オブジェクト指向」があります.使わない手はありませんね.

というわけで,こちらが実装したクラス図です.ソースコードこちら

f:id:ynakano1127:20211225191154p:plain

左上から軽く紹介したいと思います.

  • Tagクラスを実装しました.本来符号なし32bit整数でいいのですが,テストやら動作確認やらするときに文字列として描画してくれたほうがやりやすいので,このようにしました.
  • FontFileというクラスがあります.ただのファイルストリームです.M5Paperに移植したときにファイルストリームが変わってしまうのでアダプターとして用意しました.
  • 左上のFontSeekerというFontFileをラップした便利なクラスを用意してGSUBクラス等に渡しています.ビッグエンディアンの処理とか2バイト分取得,といった処理をかなりの回数するので,このようにしました.このアイデア大参考を真似させていただきました.

まずいちばん最初に,GSUBはScriptList,FeatureList,LookupListクラスのコンストラクタを呼び出し,データとして所有します.それぞれのクラスは同様にして適切なクラスを生成し所有します.各クラスの依存関係はクラス図の通りキレイな木構造になってるのが分かるかと思います.

クラスを使った例が以下のソースコードです.言語体系を選択,言語を選択,機能を選択,そして変換規則の関数が得られます.魔法の箱が完成しました!

FontSeeker fs("NotoSansCJKjp-Thin.otf");
Gsub gsub(fs);

Script script;
for(auto script_record : gsub.getScriptList().getScriptList()){
    cout << "ScriptRecord Tag : " << script_record.getScriptTag().toString() << endl;
    if(script_record.getScriptTag().toString() == "kana")
        script = script_record.getScript();
}

LangSys langsys = script.getDefaultLangSys();
for(auto lang_sys_record : script.getLangSysList()) {
    cout << "LangSysRecord Tag : " << lang_sys_record.getLangSysTag().toString() << endl;
    if(lang_sys_record.getLangSysTag().toString() == "JAN ")
        langsys = lang_sys_record.getLangSys();
}

Feature feature;
vector<FeatureRecord> feature_records = gsub.getFeatureList().getFeatureList();
for(uint16_t i : langsys.getFeatureIndices()){
    FeatureRecord r = feature_records[i];
    cout << "FeatureRecord Tag : " << r.getFeatureTag().toString() << endl;
    if( r.getFeatureTag().toString() == "vrt2")
        feature = r.getFeature();
}

vector<LookupSubtableType1> lst1_list;
vector<Lookup> lookups = gsub.getLookupList().getLookupList();
for(uint16_t i : feature.getLookupListIndices()){
    Lookup l = lookups[i];
    cout << "Lookup Type : " << l.getLookupType() << endl;
    if(l.getLookupType() == 1)
        for(uint16_t offset : l.getSubtableOffsets())
            lst1_list.push_back(LookupSubtableType1(fs, l.getLookupOffset() + offset));
}

lst1_list[0].convertGlyph(0x3042); // 変換の関数

この関数を使わない描画がこちらで, f:id:ynakano1127:20211225194331j:plain アフターがこちら, f:id:ynakano1127:20211225194308j:plain なんということでしょう.あのみすぼらしい描画がこんなに美しく.

GSUBを取得する手段としてFT_OpenType_Validate()なるものがあるそうですが,以下を見る通りあまり評判がよくありません.今回は見送りました.

http://d.hatena.ne.jp/keyword/freetypehttps://project-the-tower2.hatenadiary.org/entry/20100509/1273370298

https://aznote.jakou.com/prog/opentype/21_gsub4.html

おわりに

一つの言語のLookupType1しか実装していないので,ときどき横にならないグリフが現れます.他のテーブルを見て置き換えとかしなきゃいけないのかなぁって思ったり思わなかったり.

最終的にM5Paperの移植までやろうと思ったんだけど時間が足りなかったので暇になったらやろうと思う.あとは移植する「だけ」なので.

M5Paperでなろう小説を読めるようにするっていう目標もあとは,改行処理をしてルビ整えて,禁則処理して,HTTPのインターフェイスを設計してUI作って実装してHTMLパースする「だけ」なのでまぁすぐできるんじゃないでしょうか.「暇になったら」やります.いやーつかれたつかれた.

参考

https://www.iwatafont.co.jp/news/img/about_font.pdf

https://aznote.jakou.com/prog/opentype/index.html

https://docs.microsoft.com/en-us/typography/opentype/spec/