縦書きを実装してみた
はじめに
この記事は東京高専プロコンゼミアドベントカレンダー2021の記事です.
今は山梨大学へ編入した身ですが,枠が空いているからとせがまれ書くことになりました.
この記事について
以前,電子ペーパーでなろう小説を読めるようにするを書きました. 電子ペーパーを搭載したM5Stack製のガジェットであるM5Paperでなろう小説を読めるようにしようと頑張った記事です.
記事を読んでいただけたら分かる通り,HTMLをレンダリングしてM5Paperに転送しています.
出力する画面はキレイではあるものの,ページ遷移やデータ転送が異様に遅いものとなっています.また,スマホorPCありきでスタンドアロンではありません.生成される画面がスマホorPC依存であり,崩れた際にCSSを調整するのも一苦労でした. 同時にネットワーク固有の問題も少々出てきました.
なので「M5Paperでなろう小説を読めるようにする」を目標にM5Paper上での縦書きレンダリングを試みました.M5Paper上で縦書きレンダリングが実現できれば上記の問題のほとんどは消えるはずです.
結果としてはあとは移植するだけのところまでできました. 全体的な方針と実装についてを書き記そうと思います.
方針
縦書きを愚直に実装したときに起こる問題
縦書きと言われてどのような実装を思い浮かべますか?まず一番最初に, 一文字一文字のグリフをポンポン縦に並べる実装が思いつきますが,これではダメです.
画像のように小文字や記号の表示に違和感がでてしまうことが容易に想像できるかと思います. 画像では「ごちゅうもん」の「ゅ」だったり「データベース」の「ー」で表記ミス(と捉えられるもの)が起きています.「\「ごじゃいまーす\」」のように鉤括弧が入ったりすると最悪です.なろう小説では特にダッシュ(―)が多用される傾向にあるので,対応が必要です.
一般のソフトウェアでの実装
一般のソフトウェアは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.
Firefox,GNOME,ChromeOS,Chrome,LibreOffice,XeTeX,KDEはテキストレンダリングでHarfbuzzを使っているらしいです.凄いですね.関係ないですが,GTKはHarfbuzzに依存したPangoをテキストレンダリングに使っている?っぽいです.
M5Paper上でどのように実現するか
上述のHarfbuzzですが,内部でシステムコールを使ってそう(あんまり詳しく調べてない)かつ使うリソースが多そうという理由で諦めました.
Harfbuzzを使わずに文字レンダリングをするには一つレイヤーを下げてFreeTypeを使うしかありません.(フルスクラッチはキツいので) そもそも,M5Paperを製品として発売するタイミングでFreeTypeを移植してくれていたので,これを使うのが妥当です.
ざっくり言うと,FreeTypeはフォントファイルと文字コード(例えばUnicodeの「あ」は「U+3042」)からビットマップを計算するライブラリです.アウトライン形式のフォントファイルを扱うときとかは特に楽ですよね.
基本的に以下のような使い方をします.チュートリアルはこちら
- 文字コードからグリフIDを取得する(FT_Get_Char_Index())
- グリフを読み出す(FT_Load_Glyph())
- ビットマップを計算(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工程に分けることができます.
- 文字体系と言語を選択
- 機能を選択
- 文字変換
それぞれ,ScriptListテーブル,FeatureListテーブル,LookupListテーブルを読み込んで行います.GSUBヘッダーにあるscriptListOffset
,featureListOffset
,lookupListOffset
はこれらのテーブルのオフセットです.それぞれGSUBテーブルの先頭からのオフセットであることも仕様書から分かります.
文字体系と言語を選択
1. 文字体系と言語を選択
に関して,ScriptListテーブルに加えて以下のようなテーブルorレコードが定義されています.レコードはテーブルと本質的にあまり変わりません.経験から分かったことですが,テーブルの中に配列として持っているものをRecordと呼ぶそうです.実際,ScriptListの最後にScriptRecordが配列としてありますし,ScriptのなかにLangSysRecordが配列としてあります.
- ScriptList table
- ScriptRecord
- Script table
- LangSysRecord
- 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レコードが定義されています.
- FeatureList table
- FeatureRecord
- 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レコードが定義されています.
- LookupList table
- Lookup table
- LookupType 1: Single Substitution Subtable
- LookupType 2: Multiple Substitution Subtable
- LookupType 3: Alternate Substitution Subtable
- LookupType 4: Ligature Substitution Subtable
- LookupType 5: Contextual Substitution Subtable
- LookupType 6: Chained Contexts Substitution Subtable
- LookupType 7: Extension Substitution
- 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
です.具体的な値は手打ちによるミスがあるかもしれませんのであしからず.
図のように木構造になっていて必要なデータを求めてぴょんぴょん飛ぶお気持ちです.
- GSUBヘッダーからscriptListOffsetを取得して飛ぶ
- ScriptList中のScriptRecordを一つづつみる.タグが
kana
(ひらがな/カタカナ)のものを選ぶ.(無かったらhani
(中国語/日本語/韓国語),DFLT
(デフォルト)を選ぶといいと思います.) - scriptOffsetを見てScriptへ飛ぶ
ここまでで1. 文字体系と言語を選択
のうち文字体系を選択できたことになります.
1. 選んだScriptに格納されているデフォルトのLangSysを取得
もしくは
- 選んだScript中のLangSysRecordを見ていき,タグが
JAN
(日本語)であるものを選択 - LangSysを取得
ここまでで1. 文字体系と言語を選択
が出来たことになります.
同様にして2. 機能を選択
を進めます.
- ScriptListと同様にGSUBヘッダからFeatureListのトップに行きます.選んだ言語(LangSys)が持つ置き換え機能(FeatureRecord)を得るためにLangSysのfeatureIndicesでフィルターします.
aalt
(複数あるバリエーションからの選択)やらccmp
(2つ以上のグリフの合字/分解)やらありますが,ここからvert
もしくはvrt2
を選択します.vert
とvrt2
の違いは欧文の対応がなされているかなされていないかの違いらしいです.(大参考)- featureOffsetをみて飛びます.
ここまでで2. 機能を選択
が出来たことになります.
同様にして3. 文字変換
を進めます.やりたいことの本質部分にやっと来れました.Notoにない組み合わせが出てくるのでここからは具体的なデータを図に書きません.
- 機能選択と同様にFeatureで取得したlookupListIndicesでlookupListをフィルターします.
- Lookupに飛びます.このLookupが置き換えのテーブルです.
Coverageテーブルは書き換え元を表し,Subtableは変換規則を表します.(重要)
具体的に以下の手順でLookupテーブルを使用します.
- サブテーブルへ移動
- CoverageFormatへ移動
- 書き換えたいglyphIDをCoverageテーブルのglyphArray中から探索する.(見つからなければ変換の必要がないとする)
- 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という組み合わせを考えた図を乗っけます.
手順は以下の通り
- サブテーブルへ移動
- CoverageFormatへ移動
- rangeRecordのstartGlyphIDからendGlyphIDまでに対象のグリフIDがあるかを確認.もし範囲内なら
index = glyphID - startGlyphID + startCoverageIndex
としてindexを定義.範囲外ならば置き換え対象ではない. - 3番で求めたindexを使ってsubstituteGlyphID[index]を求める.その値こそが置き換えるグリフIDである.
実装
これを実装します.大参考のページでソースコードを公開してくれているので,これをベースにシークすれば実装可能です.
が,キツイっす.400行に達したあたりで完全に手が止まりました.手続き型の限界を感じて一人ソフトウェア危機を迎えました.
今は令和時代です.先人が頑張った結果として我々には強い味方「オブジェクト指向」があります.使わない手はありませんね.
というわけで,こちらが実装したクラス図です.ソースコードはこちら
左上から軽く紹介したいと思います.
- 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); // 変換の関数
この関数を使わない描画がこちらで, アフターがこちら, なんということでしょう.あのみすぼらしい描画がこんなに美しく.
GSUBを取得する手段としてFT_OpenType_Validate()
なるものがあるそうですが,以下を見る通りあまり評判がよくありません.今回は見送りました.
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