TrueType字体文件解析和字体光栅化
本文主要記錄一下這幾天做的一個小Demo,它能夠讀取.ttf格式的字體文件,獲取其中的相關數據,將得到的字體信息光柵化處理后輸出到一張PNG文件中,最終輸出的結果如下:
有興趣的可以參考一下源碼:
https://github.com/syddf/TTFFontRender
TTF文件解析
首先要注意ttf采用的是大端編址,即最低位的字節在最后面,而最高位的字節在最前面,如果所在的環境用的是小端編址就需要在讀取時候逆轉一下字節序,在C++中可以像這樣讀取數據:
static void InverseEudianRead(const char * source, char * target, const int per_data_size, int & offset, const int data_num = 1) {assert(target != NULL && source != NULL);char * ptr = (char*)target;for (int i = 0; i < data_num; i++){for (int j = per_data_size - 1; j >= 0; j--){ptr[j + i * per_data_size] = source[offset + (per_data_size - 1 - j)];}offset += per_data_size;} }template<typename T> void TRead(char * source, T * buffer, int & offset) {InverseEudianRead(source, (char*)buffer, sizeof(T), offset); }ttf格式的字體文件包含的數據非常多,然而如果只是想要把某個漢字提取出來,其實只會用到其中的一小部分數據,下面逐一介紹一下需要解析的內容。
這里每個Table只會說一下其中會用到的幾個相關數據的意義和作用,對于其他的數據可以參考MSDN的文檔:https://docs.microsoft.com/zh-cn/typography/opentype/spec/avar
OffsetTable
TTF文件中的數據分成了許多塊,每一塊都記錄了不同類型的信息,對于每一塊數據,如果想要讀取它,那么肯定需要知道它相對于文件起始位置的一個偏移量,然后從相應的位置開始讀取,OffsetTable就記錄了這樣的一些信息。OffsetTable位于TTF文件的開頭位置,因此可以直接讀取,它的結構如下:
其中的TableRecordEntry是這樣一個結構:
Tag m_Tag;ULONG m_Checksum;ULONG m_Offset;ULONG m_Length;可能有一些類型名比較陌生,它們大多都是1、2、4字節的unsigned int類型,具體的類型定義可以看我的代碼中的TTF_Type.h文件。
m_numTables表示整個ttf文件中一共有多少個數據塊,在m_rangeShift被讀取完之后,需要依次讀取m_numTables個TableRecordEntry結構,TableRecordEntry記錄了每個數據塊的 名稱、數據塊的校驗和、相對于文件起始的偏移量、數據塊的長度,有了這些信息,在之后需要讀取某個數據塊時,只需要根據數據塊的名稱,在OffsetTable中找到相應的TableRecordEntry,然后獲得偏移量,偏移到相應位置后開始讀取即可。
以我所使用的等線字體為例,它的OffsetTable讀取完之后的結果如下:
可以看到數據塊的個數是一個不小的數字,但是并不需要將所有的數據塊都讀取出來,如果只想要繪制出字體來,只需要用到:head、maxp、cmap、loca 、glyf 這幾個數據塊就可以了。
HeadTable
head塊的數據非常直觀,數量也比較多
m_UnitsPerEm表示每個參考網格(Em-Square)中以FUnit為單位的邊長,FUnit和Em與TTF字體設計有關,這里可以將它簡化處理,就把它看做是我們最終輸出的這個結果圖像的邊長,比如我讀取的值是2048,那么我最終輸出的PNG文件的大小就是2048*2048的。
m_indexToLocFormat會在讀取LocaTable時用到,它的值如果為0表示LocaTable中讀取的offset都是16位長度,如果為1表示LocaTable中讀取的offset都是32位長度。
我最終采取的輸出方式是讓所有的字體都居中顯示,即把每個字都放在PNG文件的中央,這樣的方式不會用到head塊中的其他任何數據,所以這里也就不再介紹了,至于每個成員的意義到底是什么,可以參考MSDN。
MaxpTable
maxp塊主要記錄的是一些與最大數目有關的信息:
這么多的數據中我只用到了m_NumGlyphs,它表示一共有多少個Glyph,在ttf文件中每個字都對應一個glyph,glyph中存儲了構成字的輪廓信息,是最重要的一個結構,但是要注意到有可能會有多個字對應同一個glyph,比如對于那些沒有被該字體文件設計的字都會對應一個表示未知的glyph。
CMapTable、LocaTable
如果現在我們想要顯示"啊",那么我們需要找到"啊"所對應的glyph結構,這可以通過CMapTable完成,CMapTable將采取一定的映射方式,將輸入的文字的某一種格式的編碼映射到一個標號,代表其對應的glyph的下標,得到glyph的編號后我們需要找到該glyph的相關數據存儲在整個字體文件的哪個位置,這就是LocaTable的作用。因此這兩個結構就是兩張映射表,根據輸入的文字找到對應的輪廓信息在文件中的具體位置。
CMapTable:
uint16 version; uint16 numTables; EncodingRecord encodingRecords[numTables];開頭是版本以及映射表的數目,需要按照順序逐一讀取每一個映射表,將它們的所有信息匯總到一個映射表上。
EncodingRecord的結構為:
uint16_t platform_id, encoding_id;uint32_t offset;查閱MSDN可以看到,platform_id和encoding_id決定了將會使用輸入文字的哪一種編碼方式,offset給出了映射表的偏移量,這里我們希望使用Unicode編碼,因此我們讀取platform_id=3,encoding_id=1 或者 platform_id=0,encoding_id=3的那些映射表,其他的表直接忽略掉。根據Offset跳轉到相應的位置,讀取相應的信息(注意每種平臺和編碼方式所需要讀取的數據信息都是不一樣的,此處細節比較多,請參看MSDN)。
LocaTable:
LocaTable就沒有CMapTable那么復雜了,根據HeadTable中的m_indexToLocFormat的值來確定讀取的offset的大小,根據MaxpTable中的m_NumGlyphs來確定一共要讀取多少個Offset,然后按照順序一個一個讀取就可以了。
注意:LocaTable讀取的Offset是每個Glyph相對于GlyphTable起始位置的偏移量,而不是相對于字體文件開頭位置的偏移量。
GlyphTable
GlyphTable應該是最為重要的一個數據塊了,它記錄了所有的字的輪廓信息,一個Glyph是由多條輪廓線(Contour)構成的,而每個Contour則由一些二階Bezier曲線和直線構成。
TRead(data, &m_Glyph.contour_num, offset);TRead(data, &m_Glyph.bounding_box[0], offset);TRead(data, &m_Glyph.bounding_box[1], offset);TRead(data, &m_Glyph.bounding_box[2], offset);TRead(data, &m_Glyph.bounding_box[3], offset);一開始我們需要先讀取每個Glyph所包含的contour的數目,以及Glyph中所包含的點的x、y坐標的最小值和最大值,這個極值在最終要居中顯示字體的時候會用到。
contour_num如果大于0,說明這是一個簡單的Glyph,所有的一切正常讀取就行了,而如果它的值小于0,則說明這是一個composite glyph,它是由多個glyph復合而成,這里不介紹composite glyph的讀取并且也沒有在代碼中實現。就我讀取的等線字體來看,沒有一個字用到了composite glyph,所以如果對此感興趣就自行查閱MSDN吧。
隨后需要依次讀取glyph中用到的點的flag、x坐標和y坐標,
//flag uint8_t repeat = 0;for (int i = 0 , p_ind = 0 , c_ind = 0; i < point_nums; i++ , p_ind++){if (repeat == 0){uint8_t flag;TRead(data, &flag, offset);if (flag & 0x8) {TRead(data, &repeat, offset);}glyph_flags[i].off_curve = (!(flag & 0b00000001)) != 0;glyph_flags[i].xShort = ((flag & 0b00000010)) != 0;glyph_flags[i].yShort = ((flag & 0b00000100)) != 0;glyph_flags[i].repeat = ((flag & 0b00001000)) != 0;glyph_flags[i].xDual = ((flag & 0b00010000)) != 0;glyph_flags[i].yDual = ((flag & 0b00100000)) != 0;if (p_ind >= (c_ind ? countour_end[c_ind] - countour_end[c_ind - 1] : countour_end[c_ind] + 1) ){p_ind = 0;c_ind++;}countour_index_for_point[i] = c_ind;point_index_in_countour[c_ind][p_ind] = i ;}else {glyph_flags[i] = glyph_flags[i - 1];repeat--;}}std::vector<Point_i> point_coordinates;point_coordinates.resize(point_nums);// x coordinatefor (int i = 0; i < point_nums; i++){if (glyph_flags[i].xDual && !glyph_flags[i].xShort){point_coordinates[i].x = i > 0 ? point_coordinates[i - 1].x : 0 ;}else {if (glyph_flags[i].xShort){uint8_t x;TRead(data, &x, offset);point_coordinates[i].x = x;if (!glyph_flags[i].xDual) {point_coordinates[i].x *= -1;}}else {int16_t x;TRead(data, &x, offset);point_coordinates[i].x = x;}if (i) point_coordinates[i].x += point_coordinates[i - 1].x;}}// y coordinatefor (int i = 0; i < point_nums; i++){if (glyph_flags[i].yDual && !glyph_flags[i].yShort){point_coordinates[i].y = i > 0 ? point_coordinates[i - 1].y : 0;}else {if (glyph_flags[i].yShort){uint8_t y;TRead(data, &y, offset);point_coordinates[i].y = y;if (!glyph_flags[i].yDual) {point_coordinates[i].y *= -1;}}else {int16_t y;TRead(data, &y, offset);point_coordinates[i].y = y;}if (i) point_coordinates[i].y += point_coordinates[i - 1].y;}}最后需要依次讀取每一個二階bezier曲線和直線,它們可能用到的點的坐標已經被讀取在了point_coordinates中,一個二階bezier曲線會用到3個點,1個在曲線上的點+1個不在曲線上的控制點+1個在曲線上的點,點是否在曲線上可以通過flag獲取,我們每次考慮兩個下標連續的點p0,p1,并且維護一個pre_point來記錄此前最后一個在曲線上的點,根據flag,看它們是否在曲線上,如果:
1.p0在曲線上,p1也在曲線上,這說明當前的這條曲線實際上是一條線段p0p1
2.p0在曲線上,p1不在曲線上,這個時候需要往后考慮第三個點p2,如果p2在曲線上那么就可以直接得到一條bezier曲線,而如果p2不在曲線上,這個時候我們就要想辦法得到一個在曲線上的點,方法是取p1和p2的中點,認為這個中點是在二階bezier曲線上
3.p0不在曲線上,p1在曲線上,這個時候就需要讓pre_point作為二階bezier曲線的第一個點,而p0作為bezier曲線的第二個點,p1作為bezier曲線的第三個點
4.p0不在曲線上,p1不在曲線上,這個時候就需要讓pre_point作為二階bezier曲線的第一個點,而p0作為bezier曲線的第二個點,p1不在曲線上,因此要讓取p0和p1的中點來代表在曲線上的第三個點。
至此已經讀取了所有需要的信息,如果需要實現更加精細和復雜的繪制效果,肯定需要理解并讀取其他相關數據,有興趣的讀者可以自行深入研究。
字體的光柵化
現在我們可以通過GlyphTable獲取某個文字的一系列二階bezier曲線和直線段,bezier曲線的光柵化非常容易,直接根據其定義計算每個插值點的坐標即可,而對于直線段,我們可以采用Bresenham算法來做光柵化處理。這兩步完成后,我們可以得到字體的輪廓線:
比較困難的一步是填充,關于填充有用于填充多邊形的掃描線算法以及可以填充任意封閉區域的種子填充算法。掃描線算法在這里不是很適合,因為我們的輪廓不僅有直線還有bezier曲線,不是很好確定掃描線與bezier曲線的交點(當然似乎有用直線逼近bezier曲線這樣的一種做法)
我采用的是種子填充算法,即選擇在輪廓線內部的一個點然后向四周逐漸擴散填充,但是如何找到這樣一個在輪廓線內部的點?很容易想到,如果由某點朝某個方向發出一條射線,如果射線與輪廓線的交點的個數是奇數的話,說明該點在輪廓線的內部,否則在外部。但是要注意到輪廓線會存在連續的點的問題
比如像這種,黑色的點代表輪廓點,圖中的紅色點按照上述判定準則會被認為不在輪廓內部,因為它左側的輪廓點是偶數個,但是事實上它應該是在輪廓內部的,因此在判定的時候需要注意,所有連續的點應該只被當作一個點來統計。
這一部分的代碼都比較直觀,可以參考代碼中的TTFRaster類。
總的來說,對于TrueType文件的解析還是遠遠不夠的,目前所做到的也只是將輪廓提取出來并顯示而已,但如果要更近一步,例如修改某個字并寫回到文件中,那么肯定需要對TTF文件有更高層次的認識。
\希望這篇文章對于想要提取并繪制字體的朋友有所幫助。
總結
以上是生活随笔為你收集整理的TrueType字体文件解析和字体光栅化的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: maven中resource配置详解
- 下一篇: 文献管理工具之Zotero:如何在Zot