woff字体图元结构剖析,自定义字体的制作与匹配和识别
前面我在《2萬字硬核剖析網頁自定義字體解析》一文中,講解了通過圖像識別來解析自定義字體,但是圖像識別的缺點在于準確率并不能達到100%,還需要二次修改。
前面將字體的稱為點陣圖,其實根據TrueType字體實際采用的技術,稱為輪廓圖更為合適,所以本文所說的輪廓圖就是上篇的點陣圖。
由于目前幾個大廠的網站的自定義字體的輪廓圖都是那個固定的順序,所以上文只處理了所有字體文件輪廓圖順序都一致的情況,并沒有繼續深挖去處理輪廓圖順序出現隨機的情況。
本文就將針對未來自定義字體的輪廓圖順序出現隨機的情況進行處理。
具體處理思路就是,提取字體的圖元數據,包括控制點位置和標志位,轉成二進制字節進行唯一標識,與現有的已知的字符集進行映射。后續任何Unicode代碼點順序隨機和輪廓圖順序隨機的字體文件,都可以提取圖元數據轉換后進行唯一匹配從而解碼出唯一正確的字符。
不過上述思路還只是處理了輪廓圖順序隨機,其實還可以再變態點以多個基礎字形制作自定義字體取隨機,意味著每個字符的圖元數據都會發生較大變化,上面的匹配方法就會直接失效。此時便只能通過機器學習計算字符間的相似度,從而識別出圖元對應的真實字符。
文章目錄
- 字體格式類型介紹
- 如何生成自定義字體
- woff字體的解析
- 字體頭表(head表)
- 字符到圖元索引的映射表(cmap表)
- 圖元數據(glyf表)
- 位置索引(loca表)
- 最大需求表(maxp表)
- 命名表(name)
- 水平布局(hmtx)
- 二進制匹配解析輪廓圖順序隨機的woff字體
- 圖像識別解析字形隨機的woff字體
- 總結
字體格式類型介紹
字體格式類型主要有幾個大分類:TrueType、Embedded Open Type 、OpenType、WOFF 、SVG。
TrueType:
Windows和Mac系統最常用的字體格式,基于輪廓技術的數學模式來進行定義,比基于矢量的字體更容易處理,保證了屏幕與打印輸出的一致性。同時,這類字體和矢量字體一樣可以隨意縮放、旋轉而不必擔心會出現鋸齒。
EOT – Embedded Open Type (.eot):
微軟開發的嵌入式字體,允許OpenType字體用@font-face嵌入到網頁并下載至瀏覽器渲染,存儲在臨時安裝文件夾下。
OpenType (.otf):
微軟和Adobe共同開發的字體,微軟的IE瀏覽器全部采用這種字體,致力于替代TrueType字體。
WOFF – Web Open Font Format (.woff):
專門為了Web而設計的字體格式標準,實際上是對TrueType/OpenType等字體格式的封裝,每個字體文件中含有字體以及針對字體的元數據(Metadata),字體文件被壓縮,以便于網絡傳輸。
SVG (Scalable Vector Graphics) Fonts (.svg):
使用SVG技術來呈現字體,支持gzip壓縮格式。
在上次從css的@font-face提取出字體URL鏈接時,就包含了eot和woff兩種格式。鑒于woff字體更容易被分析,所以我們上次選擇了只下載woff字體格式,今天這篇文章也一樣。
字體格式轉換工具:
- https://www.fontsquirrel.com/tools/webfont-generator
- https://everythingfonts.com/
可以生成自定義字體的網站:
- https://icomoon.io/app/#/select
- http://fontello.com
如何生成自定義字體
先生成svg字體,再導入到自定義字體生成網站,再定義字體映射關系,最后導入字體即可。
由于https://everythingfonts.com/對文件較大的字體轉換需要收費,這里我使用https://www.fontsquirrel.com/tools/webfont-generator將系統自帶的arial.ttf字體文件轉換為svg字體:
下載并解壓得到一個arial-webfont.svg文件。
接下來打開https://icomoon.io/app/#/select,選擇需要被自定義的字符:
本例選擇了0-9作為被自定義的字符,然后點擊右下角 Generate Font 按鈕準備設置字符映射:
設置好映射關系后,點擊下載字體。
下載的壓縮包包含多種字體,解壓出其中的icomoon.woff字體文件。
用FontCreator字體設計工具打開后可以看到如下結果:
可以看到與我們前面在網站中自定義的映射一致。
woff字體的解析
首先,我們用python的fontTools庫讀取上次下載的字體文件:
from fontTools.ttLib import TTFontfont = TTFont("tagName.woff")可以一次性將相關數據保存到本地:
font.saveXML("tagName.xml")字體文件都包含了一個TableDirectory結構,保存了多張表,每個表保存了不同的信息。
TrueType字體中常見的表有:
| head | 字體頭 | 字體的全局信息 |
| cmap | 字符代碼到圖元的映射 | 把字符代碼映射為圖元索引 |
| glyf | 圖元數據 | 圖元輪廓定義以及網格調整指令 |
| loca | 位置表索引 | 把元索引轉換為圖元的位置 |
| maxp | 最大需求表 | 字體中所需內存分配情況的匯總數據 |
| name | 命名表 | 版權說明、字體名、字體族名、風格名等等 |
| hmtx | 水平布局 | 字體水平布局:上高、下高、行間距、最大前進寬度、最小左支撐、最小右支撐 |
字體頭表(head表)
字體頭表(head表)中包含了TrueType字體的全局信息,在c語言中的結構定義如下:
typedef sturct {Fixed Table;//x00010000 ro version 1.0Fixed fontRevision;//Set by font manufacturer.ULONG checkSumAdjustment;ULONG magicNumer; //Set to 0x5f0f3cf5USHORT flags;USHORT unitsPerEm; //Valid range is from 16 to 16384longDT created; //International date (8-byte field).longDT modified; //International date (8-byte field).FWord xMin; //For all glyph bounding boxes.FWord yMin; //For all glyph bounding boxes.FWord xMax; //For all glyph bounding boxes.FWord xMax; //For all glyph bounding boxes.USHORT macStyle;USHORT lowestRecPPEM; //Smallest readable size in pixels.SHORT fontDirctionHint;SHORT indexToLocFormat; //0 for short offsets ,1 for long.SHORT glyphDataFormat; //0 for current format. }Table_head;上面各個字段定義基本都能直接在python中讀取,其中日期字段有created和modified,分別表示字體創建時間和字體最后修改時間,使用8個字節記錄從1904年1月1日午夜12:00開始的秒數。
獲取字體的創建時間和字體最后修改時間:
import datetime head = font['head'] base = datetime.datetime(1904, 1, 1, 0, 0, 0) create_time = base+datetime.timedelta(seconds=head.created) modifie_time = base+datetime.timedelta(seconds=head.modified) print(f"創建時間:{create_time},最后修改時間:{modifie_time}") 創建時間:2021-08-02 15:00:30,最后修改時間:2021-08-02 15:00:30字體是針對一個被稱為em-square的參考網格設計的,字體中的圖元用網格中的坐標表示。em-squrare的大小決定字體的圖元被縮放的方式和質量。字體頭中保存了每個em-square的格數和能 包含所有圖元的邊界框。Em-square的有效值是從16到16384。
讀取每個em-square的格數和圖元邊界框范圍:
print(f"每個em-square的格數:{head.unitsPerEm},邊界框范圍x: {head.xMin} - {head.xMax},y: {head.yMin} - {head.yMax}") 每個em-square的格數:1000,邊界框范圍x: 0 - 1136,y: -112 - 833字體頭表中的其他信息包括最小可讀像素大小、字體方向、在位置表中圖元索引的格式和圖元數據格式等:
head.lowestRecPPEM, head.fontDirectionHint, head.indexToLocFormat, head.glyphDataFormat (8, 2, 0, 0)字符到圖元索引的映射表(cmap表)
字符到圖元索引的映射表(cmap表)定義了從不同代碼頁中的字符代碼到圖元索引的映射關系。cmap表包含幾個子表以支持不同的平臺和不同的字符編碼方案。cmap表在c語言中的定義較為復雜,不作展示。
在python中我們可以通過cmap表獲取字符代碼到圖元索引的映射關系:
cmap = font['cmap'] cmap.getBestCmap() {120: 'x',57360: 'unie010',57369: 'unie019',57370: 'unie01a',...63699: 'unif8d3',63718: 'unif8e6',63724: 'unif8ec'}不過獲取這個關系也并沒有太大的意義,因為我們可以很輕松的進行相互轉換:
"uni"+chr(57360).encode("unicode_escape").decode()[2:]就可以得到對應的unie010,反過來也可以:
char = 'unie010' ord(("\\u"+char[3:]).encode().decode("unicode_escape"))即可得到57360。
當然fontTools本身也提供了反向獲取的API:
cmap.buildReversed() {'x': {120},'unie010': {57360},'unie019': {57369},'unie01a': {57370},...'unif8d3': {63699},'unif8e6': {63718},'unif8ec': {63724}}圖元數據(glyf表)
圖元數據(glyf表)是我們所需要的字體核心信息,以序列形式保存了圖元數據,每個圖元以圖元頭(GlyphHeader)結構開始,在c語言中的定義為:
typedef struct { WORD numberOfContours; //contor number,negative if composite FWord xMin; //Minimum x for coordinate data. FWord yMin; //Minimum y for coordinate data. FWord xMax; //Maximum x for coordinate data. FWord yMax; //Maximum y for coordinate data. }GlyphHeader;合成圖元由多個簡單圖元或合成圖元組成,簡單圖元的numberOfContours字段保存了當前圖元的輪廓線的數目。而合成圖元的numberOfContours字段為負值,表示需要基于組成該合成圖元的所有簡單圖元的輪廓線的數目計算得到。后四個字段記錄了圖元的邊界框。
簡單圖元的圖元描述信息緊跟在其GlyphHeader結構之后,c語言定義為:
USHORT endPtsOfContours[n]; //n=number of contours USHORT instructionlength; BYTE instruction[i]; //i = instructionlength BYTE flags[]; //variable size BYTE xCoordinates[]; //variable size BYTE yCoordinates[]; //variable size包括所有輪廓線結束點的索引、圖元指令和一系列的控制點,每個控制點包括包括一個標志和xy軸坐標。
endPtsOfContours數組保存了每一條輪廓線終點的索引,通過該索引可以計算出每條輪廓線中點的數量。比如,endPtsOfContours[0]+1是第一條輪廓線上點的數量,endPtsOfContours[1]-endPtsOfContours[0]是第二條輪廓線上點的數量。
圖元的控制點保存在三個數組中:標志獲得組、x坐標數組和y坐標數組。為了節省存儲空間,圖元中保存的是相對坐標。第一個點的坐標是相對原點(0, 0)記錄的,隨后的點記錄和上一個點的坐標差值。標志數組保存了每個坐標的編碼信息以及其他一些信息。下面是標志中各個位的含義(c語言定義):
typedef enum {G_ONCURVE=0x01, // on curve ,off curveG_REPEAT=0x08, // next byte is flag repeat count G_XMASK=0x12, G_XADDBYTE=0x12, //X is positive byteG_XSUBBYTE=0x12, //X is negative byte G_XSAME=0x10, //X is sameG_XADDINT=0x00, //X is signed word G_YMASK=0x24,G_YADDBYTE=0x24, //Y is positive byte G_YSUBBYTE=0x04, //Y is negative byteG_YSAME=0x20, //Y is sameG_YADDINT=0x00, //Y is signed word };在輪廓技術的數學模式中,一段三階的Bezier曲線由四個控制點定義:位于曲線上的起始點、兩個不在曲線上(off-curve)的控制點和一個曲線上的結束點。
字體中的圖元輪廓用二階Bezier曲線定義,有三個點:一個曲線上的點,一個曲線外的點和另一個曲線上的點。對于多個連續不在曲線上的點,會隱式加入一些點使其符合二階Bezier曲線曲線的定義。例如,on-off-off-on模式的四個點,會隱式加入一個點使之成為on-off-on-off-on的五個點。
G_ONCURVE位表示控制點是否在曲線上,設置G_REPEAT位表示標志數組的下一字節表示重復次數,當前標志被重復指定的次數。解碼圖元的描述需要兩次掃描起始點,然后再遍歷圖元定義中的每一個點進行轉換。
圖元指令具體細節比較復雜,主要是為了控制圖元輪廓從em-square到柵格網格的縮放過程,通過網格調整技術使縮放后的渲染不失真,而記錄控制值的一張表。
整體來說渲染圖元是一個非常復雜的算法,咱們不再繼續深究。
下面看看fontTools庫能夠讀取到的圖元數據,首先讀取glyf表:
glyf = font["glyf"]我們以字符0為例進行演示,查看到該字體中數字0對應的代碼點為unif82e。
首先查看圖元頭信息:
glyph = glyf['unif82e'] print(f"輪廓線數目:{glyph.numberOfContours},邊界范圍:({glyph.xMin},{glyph.yMin})-({glyph.xMax},{glyph.yMax})") 輪廓線數目:2,邊界范圍:(0,-14)-(550,729)前面已經提到,每個點記錄的是和上一個點的坐標差值,所以邊界范圍存在負數很好理解。
獲取每條輪廓線終點的索引:
glyph.endPtsOfContours [12, 25]可以計算出兩條輪廓線點的數量:
num1 = glyph.endPtsOfContours[0]+1 num2 = glyph.endPtsOfContours[1]-glyph.endPtsOfContours[0] print(f"第一條輪廓線上點的數量為{num1},第二條輪廓線上點的數量為{num2}") 第一條輪廓線上點的數量為13,第二條輪廓線上點的數量為13對于控制點數據中的標志,python的fontTools庫似乎只能讀取G_ONCURVE標志位,即是否存在于曲線上。
首先查看控制點的坐標coordinates:
glyph.coordinates GlyphCoordinates([(300, 728),(171, 729),(107, 615),(50, 519),(50, 195),(107, 99),(171, -14),(427, -14),(493, 99),(550, 195),(550, 519),(493, 615),(427, 729),(300, 658),(396, 658),(438, 555),(469, 483),(469, 233),(438, 159),(396, 57),(204, 57),(162, 159),(132, 233),(132, 483),(162, 555),(204, 658)])可以借助numpy計算出偏移后的實際坐標:
coordinates = np.array(glyph.coordinates).cumsum(axis=0) print(coordinates.shape, coordinates.tolist()) (26, 2) [[300, 728], [471, 1457], [578, 2072], [628, 2591], [678, 2786], [785, 2885], [956, 2871], [1383, 2857], [1876, 2956], [2426, 3151], [2976, 3670], [3469, 4285], [3896, 5014], [4196, 5672], [4592, 6330], [5030, 6885], [5499, 7368], [5968, 7601], [6406, 7760], [6802, 7817], [7006, 7874], [7168, 8033], [7300, 8266], [7432, 8749], [7594, 9304], [7798, 9962]]控制點是否存在于曲線上:
glyph.flags bytearray(b'\x01\x00\x01\x00\x00\x01\x00\x00\x01\x00\x00\x01\x00\x01\x00\x01\x00\x00\x01\x00\x00\x01\x00\x00\x01\x00')可以用numpy橫向拼接,方便查看:
data = np.c_[coordinates, glyph.flags].astype("int16") print(data) [[ 300 728 1][ 471 1457 0][ 578 2072 1][ 628 2591 0][ 678 2786 0][ 785 2885 1][ 956 2871 0][1383 2857 0][1876 2956 1][2426 3151 0][2976 3670 0][3469 4285 1][3896 5014 0][4196 5672 1][4592 6330 0][5030 6885 1][5499 7368 0][5968 7601 0][6406 7760 1][6802 7817 0][7006 7874 0][7168 8033 1][7300 8266 0][7432 8749 0][7594 9304 1][7798 9962 0]]對于連續不在曲線上的點都會自動添加隱式的點。
如何將這些控制點數據用最簡化的2進制的形式描述呢?
np.array(glyph.coordinates).astype("int16").tobytes()+glyph.flags b',\x01\xd8\x02\xab\x00\xd9\x02k\x00g\x022\x00\x07\x022\x00\xc3\x00k\x00c\x00\xab\x00\xf2\xff\xab\x01\xf2\xff\xed\x01c\x00&\x02\xc3\x00&\x02\x07\x02\xed\x01g\x02\xab\x01\xd9\x02,\x01\x92\x02\x8c\x01\x92\x02\xb6\x01+\x02\xd5\x01\xe3\x01\xd5\x01\xe9\x00\xb6\x01\x9f\x00\x8c\x019\x00\xcc\x009\x00\xa2\x00\x9f\x00\x84\x00\xe9\x00\x84\x00\xe3\x01\xa2\x00+\x02\xcc\x00\x92\x02\x01\x00\x01\x00\x00\x01\x00\x00\x01\x00\x00\x01\x00\x01\x00\x01\x00\x00\x01\x00\x00\x01\x00\x00\x01\x00'位置索引(loca表)
前面在讀取glyf表中的圖元數據時就需要讀取loca表的圖元索引的偏移量。
位置索引表中保存了n+1個圖元數據表的索引,其中的n是保存在最大需求表中的圖元數量。最后一個額外的偏移量指向最后一個圖元的偏移量和當前圖元的偏移量間的差值得到的圖元長度。
python中能夠讀取到:
loca = font["loca"] loca.locations array('I', [0, 0, 24, 68, 168, 304, 364, 480, 612, 652, 824, 948, 1040, 1164, 1252, 1432, 1660, 1856, 1944, 2052, 2140, ...... 97488, 97624, 97776, 98036, 98180, 98320, 98480, 98676, 98832, 99020, 99308])最大需求表(maxp表)
最大需求表的目的是告知字體柵格器(rasterizer)對內存的需求,以便 在出來字體前分配合適大小的內存。下面是maxp表的結構在c語言中的定義:
typedef struct { Fixed Version;//0x00010000 for version 1.0. USHORT numGlypha; //Number of glyphs in the font . USHORT maxPoints; //Max points in noncomposite glyph . RSHORT maxContours; //Max contours in noncomposite glyph. USHORT maxCompositePoints;//Max points in a composite glyph. USHORT maxCompositeContours; //Max contours in a composite glyph. USHORT maxZones;// 1 if not use the twilight zone [Z0],//or 2 if so use Z0;2 in most cases. USHORT max TwilightPoints ;/ Maximum points used in Z0. USHORT maxStorage; //Number of storage area locations. USHORT maxFunctionDefs; //Number of FDEFs. USHORT maxStackElements; //Number of depth. USHORT maxSizeOfInstructions; //Max byte count for glyph inst. USHORT maxComponentElements; //Max number top components refernced. USHORT maxComponentDepth; //Max levels of recursion. }Table_maxp;numGlyphs字段保存了字體中圖元的總數,這決定了到位置表的圖元索引的數量,可以驗證圖元索引的有效性。maxPoints\maxCountors\maxCompositePoints maxCompositeContours這幾個字段說明了圖元定義的復雜度。
python中的讀取一下:
maxp = font["maxp"] maxp.numGlyphs, maxp.maxPoints, maxp.maxContours, maxp.maxCompositePoints, maxp.maxCompositeContours (603, 134, 11, 0, 0)命名表(name)
包含版權說明、字體名、字體族名、風格名等,直接通過python查看:
for n in font["name"].names:print(repr(n), n)print(n.platformID, n.nameID, n.string)print("----------------") 1 0 b'\n Created by font-carrier\n ' ---------------- ...... 1 10 b'Generated by svg2ttf from Fontello project.' ---------------- 1 11 b'http://fontello.com' ---------------- 3 0 b'\x00\n\x00 \x00 \x00C\x00r\x00e\x00a\x00t\x00e\x00d\x00 \x00b\x00y\x00 \x00f\x00o\x00n\x00t\x00-\x00c\x00a\x00r\x00r\x00i\x00e\x00r\x00\n\x00 \x00 ' ......截取了部分結果,可以看到該自定義字體通過fontello.com生成。
水平布局(hmtx)
Python查看字體的水平布局:
for code, width in hmtx.metrics.items():print(code, width) glyph00000 (1136, 0) x (100, 0) uniec3e (600, 0) ... unif82e (600, 0) unie7c5 (1000, 0) ... unif69c (1000, 0)二進制匹配解析輪廓圖順序隨機的woff字體
有了前面的基礎,現在對于亂序了輪廓圖順序的woff字體,已經變得非常簡單。
我們使用上次下載的address.woff文件作為已知訓練集,然后將shopNum.woff字體文件的輪廓圖,進行一定的亂序處理,看看能否正確的提取出需要的文字。
首先使用FontCreator.exe打開shopNum.woff字體文件,然后修改輪廓圖順序。
最終在我一頓操作后,形成下面的順序:
再將字體導出為random.woff。
那么我們能否通過address.woff文件和已知字符列表作為訓練集,正確匹配出random.woff文件每個Unicode代碼點對應的字符呢?
首先讀取address.woff文件的每個圖元數據轉成二進制后和之前已經識別出來的字符列表建立映射關系:
from fontTools.ttLib import TTFont import numpy as npdef get_glyphBytes(glyph):coordinates = np.array(glyph.coordinates).astype("int16")return coordinates.tobytes()+glyph.flagsfont = TTFont("address.woff") glyf = font["glyf"]chars = ' `1234567890店中美家館小車大市公酒行國品發電金心業商司超生裝園場食有新限天面工服海華水房飾城樂汽香部利子老藝花專東肉菜學福飯人百餐茶務通味所山區門藥銀農龍停尚安廣鑫一容動南具源興鮮記時機烤文康信果陽理鍋寶達地兒衣特產西批坊州牛佳化五米修愛北養賣建材三會雞室紅站德王光名麗油院堂燒江社合星貨型村自科快便日民營和活童明器煙育賓精屋經居莊石順林爾縣手廳銷用好客火雅盛體旅之鞋辣作粉包樓校魚平彩上吧保永萬物教吃設醫正造豐健點湯網慶技斯洗料配匯木緣加麻聯衛川泰色世方寓風幼羊燙來高廠蘭阿貝皮全女拉成云維貿道術運都口博河瑞宏京際路祥青鎮廚培力惠連馬鴻鋼訓影甲助窗布富牌頭四多妝吉苑沙恒隆春干餅氏里二管誠制售嘉長軒雜副清計黃訊太鴨號街交與叉附近層旁對巷棟環省橋湖段鄉廈府鋪內側元購前幢濱處向座下澩鳳港開關景泉塘放昌線灣政步寧解白田町溪十八古雙勝本單同九迎第臺玉錦底后七斜期武嶺松角紀朝峰六振珠局崗洲橫邊濟井辦漢代臨弄團外塔楊鐵浦字年島陵原梅進榮友虹央桂沿事津凱蓮丁秀柳集紫旗張谷的是不了很還個也這我就在以可到錯沒去過感次要比覺看得說常真們但最喜哈么別位能較境非為歡然他挺著價那意種想出員兩推做排實分間甜度起滿給熱完格薦喝等其再幾只現朋候樣直而買于般豆量選奶打每評少算又因情找些份置適什蛋師氣你姐棒試總定啊足級整帶蝦如態且嘗主話強當更板知己無酸讓入啦式笑贊片醬差像提隊走嫩才剛午接重串回晚微周值費性桌拍跟塊調糕' glyphBytes2char = {} for code, char in zip(glyf.glyphOrder, chars):glyph = glyf[code]if not hasattr(glyph, 'coordinates'):continueglyphBytes2char[get_glyphBytes(glyph)] = char有了映射關系,我們再開始嘗試匹配random.woff文件每個Unicode代碼點對應的字符:
font = TTFont("random.woff") glyf = font["glyf"]code2char = {} for code in glyf.glyphOrder:glyph = glyf[code]if not hasattr(glyph, 'coordinates'):continueglyphBytes = get_glyphBytes(glyph)if glyphBytes not in glyphBytes2char:print("不在資料庫的代碼點:", code)continuecode2char[code] = glyphBytes2char[glyphBytes] code2char結果:
可以看到每一個代碼點都一一精準的匹配出正確的結果。
可以將上述過程封裝成類,方便以后隨時調用使用:
from fontTools.ttLib import TTFont import numpy as npclass FontMatch:"""用于字體圖元數據匹配的類"""@staticmethoddef get_glyphBytes(glyph):coordinates = np.array(glyph.coordinates).astype("int16")return coordinates.tobytes() + glyph.flagsdef __init__(self, sample_font="sample.woff", chars=None, dest_font=None):"""傳入已知輪廓圖順序的字體文件和真實字符作為訓練集,去匹配目標字體,后面能夠得到該目標字體映射字符對應的真實字符:param sample_font: 已知輪廓圖順序的字體文件:param chars: 該字體文件每個輪廓圖對應的真實字符:param dest_font: 要進行匹配的目標字體,可以后面再調用 load_dest_font 傳入"""sample_font = TTFont(sample_font)glyf = sample_font["glyf"]if chars is None:chars = ' `1234567890店中美家館小車大市公酒行國品發電金心業商司超生裝園場食有新限天面工服海華水房飾城樂汽香部利子老藝花專東肉菜學福飯人百餐茶務通味所山區門藥銀農龍停尚安廣鑫一容動南具源興鮮記時機烤文康信果陽理鍋寶達地兒衣特產西批坊州牛佳化五米修愛北養賣建材三會雞室紅站德王光名麗油院堂燒江社合星貨型村自科快便日民營和活童明器煙育賓精屋經居莊石順林爾縣手廳銷用好客火雅盛體旅之鞋辣作粉包樓校魚平彩上吧保永萬物教吃設醫正造豐健點湯網慶技斯洗料配匯木緣加麻聯衛川泰色世方寓風幼羊燙來高廠蘭阿貝皮全女拉成云維貿道術運都口博河瑞宏京際路祥青鎮廚培力惠連馬鴻鋼訓影甲助窗布富牌頭四多妝吉苑沙恒隆春干餅氏里二管誠制售嘉長軒雜副清計黃訊太鴨號街交與叉附近層旁對巷棟環省橋湖段鄉廈府鋪內側元購前幢濱處向座下澩鳳港開關景泉塘放昌線灣政步寧解白田町溪十八古雙勝本單同九迎第臺玉錦底后七斜期武嶺松角紀朝峰六振珠局崗洲橫邊濟井辦漢代臨弄團外塔楊鐵浦字年島陵原梅進榮友虹央桂沿事津凱蓮丁秀柳集紫旗張谷的是不了很還個也這我就在以可到錯沒去過感次要比覺看得說常真們但最喜哈么別位能較境非為歡然他挺著價那意種想出員兩推做排實分間甜度起滿給熱完格薦喝等其再幾只現朋候樣直而買于般豆量選奶打每評少算又因情找些份置適什蛋師氣你姐棒試總定啊足級整帶蝦如態且嘗主話強當更板知己無酸讓入啦式笑贊片醬差像提隊走嫩才剛午接重串回晚微周值費性桌拍跟塊調糕'glyphBytes2char = {}for code, char in zip(glyf.glyphOrder, chars):glyph = glyf[code]if not hasattr(glyph, 'coordinates'):continueglyphBytes2char[FontMatch.get_glyphBytes(glyph)] = charself.glyphBytes2char = glyphBytes2charsample_font.close()if dest_font is not None:self.load_dest_font(dest_font)def load_dest_font(self, dest_font):"""傳入要進行匹配的目標字體,之前已經傳入的目標字體會被覆蓋"""font = TTFont(dest_font)self.code2name = font.getBestCmap()self.glyf = font["glyf"]def getRealChar(self, char):code = ord(char)if code not in self.code2name:returnname = self.code2name[code]glyphBytes = FontMatch.get_glyphBytes(self.glyf[name])return self.glyphBytes2char.get(glyphBytes)調用方式:下面的代碼將前面已經下載的任意一個字體文件重命名為sample.woff作為訓練集,random.woff是要處理的目標字體。對于任何給點的映射字符都可以匹配出正確結果:
from FontMatch import FontMatchfont = FontMatch(sample_font="sample.woff", dest_font="random.woff") print(font.getRealChar("\uEE9B")) '4'對前面我們自行亂序后的自定義字體前面幾個字符批量匹配測試一下:
real_map = {'\uE0A7': '1', '\uEBF3': '2', '\uEE9B': '4', '\uE7E4': '3', '\uF5F8': '店', '\uE7A1': '中', '\uEF49': '7', '\uEEF7': '8', '\uF7E0': '9', '\uE633': '小', '\uE5DE': '車', '\uE67F': '6', '\uF2C3': '美', '\uF012': '家', '\uE0B8': '館', '\uE438': '5'} for char, real in real_map.items():r = font.getRealChar(char)print("真實結果與匹配結果:", real, "|", r) 真實結果與匹配結果: 1 | 1 真實結果與匹配結果: 2 | 2 真實結果與匹配結果: 4 | 4 真實結果與匹配結果: 3 | 3 真實結果與匹配結果: 店 | 店 真實結果與匹配結果: 中 | 中 真實結果與匹配結果: 7 | 7 真實結果與匹配結果: 8 | 8 真實結果與匹配結果: 9 | 9 真實結果與匹配結果: 小 | 小 真實結果與匹配結果: 車 | 車 真實結果與匹配結果: 6 | 6 真實結果與匹配結果: 美 | 美 真實結果與匹配結果: 家 | 家 真實結果與匹配結果: 館 | 館 真實結果與匹配結果: 5 | 5一樣也是完全正確。
圖像識別解析字形隨機的woff字體
上述代碼解決了輪廓圖順序隨機的問題,但是假如字形也發生隨機怎么破呢?例如用10套基礎字體隨機生成自定義字體。那么之前的獲取到的圖元數據就無法直接匹配。
此時我們需要使用機器學習或深度學習相關的算法,或者能夠完成圖元數據渲染字體圖形的大佬可以直接使用邏輯算法完成。
自己嘗試了一些分類模型發現效果并不比圖像識別算法好,所以最終我們依然還是決定使用一開始采用的圖像識別來解決這個問題,優點是通用性強,但缺點是準確率再也無法達到100%。
前面下載的字體文件定義最常用的601個字符,這里我們也只對這601個字符進行測試。
首先,創建文字識別類:
from ddddocr import DdddOcr, npclass OCR(DdddOcr):def __init__(self):super().__init__()def ocr(self, image):image = np.array(image).astype(np.float32)image = np.expand_dims(image, axis=0) / 255.image = (image - 0.5) / 0.5ort_inputs = {'input1': np.array([image])}ort_outs = self._DdddOcr__ort_session.run(None, ort_inputs)result = []last_item = 0for item in ort_outs[0][0]:if item == 0 or item == last_item:continueresult.append(self._DdddOcr__charset[item])last_item = itemreturn ''.join(result)ocr = OCR()定義需要被測試的正確字符:
chars = '1234567890店中美家館小車大市公酒行國品發電金心業商司超生裝園場食有新限天面工服海華水房飾城樂汽香部利子老藝花專東肉菜學福飯人百餐茶務通味所山區門藥銀農龍停尚安廣鑫一容動南具源興鮮記時機烤文康信果陽理鍋寶達地兒衣特產西批坊州牛佳化五米修愛北養賣建材三會雞室紅站德王光名麗油院堂燒江社合星貨型村自科快便日民營和活童明器煙育賓精屋經居莊石順林爾縣手廳銷用好客火雅盛體旅之鞋辣作粉包樓校魚平彩上吧保永萬物教吃設醫正造豐健點湯網慶技斯洗料配匯木緣加麻聯衛川泰色世方寓風幼羊燙來高廠蘭阿貝皮全女拉成云維貿道術運都口博河瑞宏京際路祥青鎮廚培力惠連馬鴻鋼訓影甲助窗布富牌頭四多妝吉苑沙恒隆春干餅氏里二管誠制售嘉長軒雜副清計黃訊太鴨號街交與叉附近層旁對巷棟環省橋湖段鄉廈府鋪內側元購前幢濱處向座下澩鳳港開關景泉塘放昌線灣政步寧解白田町溪十八古雙勝本單同九迎第臺玉錦底后七斜期武嶺松角紀朝峰六振珠局崗洲橫邊濟井辦漢代臨弄團外塔楊鐵浦字年島陵原梅進榮友虹央桂沿事津凱蓮丁秀柳集紫旗張谷的是不了很還個也這我就在以可到錯沒去過感次要比覺看得說常真們但最喜哈么別位能較境非為歡然他挺著價那意種想出員兩推做排實分間甜度起滿給熱完格薦喝等其再幾只現朋候樣直而買于般豆量選奶打每評少算又因情找些份置適什蛋師氣你姐棒試總定啊足級整帶蝦如態且嘗主話強當更板知己無酸讓入啦式笑贊片醬差像提隊走嫩才剛午接重串回晚微周值費性桌拍跟塊調糕'先對系統自帶的微軟雅黑字體進行測試:
from PIL import ImageFont, Image, ImageDrawsize = 64 font = ImageFont.truetype("msyh.ttc", size-24) error = 0 for char in chars:im = Image.new(mode='L', size=(size, size), color=255)draw = ImageDraw.Draw(im=im)w, h = draw.textsize(char, font)o1, o2 = font.getoffset(char)fontx, fonty = (size-w-o1)/2, (size-h-o2)/2draw.text(xy=(fontx, fonty), text=char, fill=0, font=font)result = ocr.ocr(im)[0]if result != char:print("正確結果:", char, ",識別結果:", result)error += 1 print("識別錯誤的字符數量:", error) 正確結果: 二 ,識別結果: 一 正確結果: 澩 ,識別結果: 檗 正確結果: 昌 ,識別結果: 目 正確結果: 町 ,識別結果: 盯 正確結果: 丁 ,識別結果: j 正確結果: 入 ,識別結果: 人 識別錯誤的字符數量: 6可以看到對該字體601字符的識別只存在6個錯誤,其他都正確。
再對之前下載的自定義字體進行測試:
from fontTools.ttLib import TTFontfont = TTFont("shopNum.woff") name2char = dict(zip(font.getGlyphOrder()[2:], chars))size = 64 imageFont = ImageFont.truetype("shopNum.woff", size-24) error = 0 for code, name in font.getBestCmap().items():if name not in name2char:continuechar = chr(code)real_char = name2char[name]im = Image.new(mode='L', size=(size, size), color=255)draw = ImageDraw.Draw(im=im)w, h = draw.textsize(char, imageFont)o1, o2 = imageFont.getoffset(char)fontx, fonty = (size-w-o1)/2, (size-h-o2)/2draw.text(xy=(fontx, fonty), text=char, fill=0, font=imageFont)result = ocr.ocr(im)[0]if result != real_char:print("正確結果:", real_char, "識別結果:", result)error += 1 print("識別錯誤的字符數量:", error) 正確結果: 町 識別結果: 盯 正確結果: 二 識別結果: 一 正確結果: 澩 識別結果: 嗅 識別錯誤的字符數量: 3可以看到對該字體601字符的識別只存在3個錯誤,其他都正確。
那么對于任何一個未知的自定義字體,如何通過圖像識別技術知道真實字符是什么呢?
我們改造一下前面的ocr類,封裝一下:
from ddddocr import DdddOcr, np from PIL import ImageFont, Image, ImageDrawclass FontOCR(DdddOcr):def __init__(self, font_name, font_size=40):super().__init__()self.font = ImageFont.truetype(font_name, font_size)self.cache = {}self.im_cache = {}def ocr(self, image):image = np.array(image).astype(np.float32)image = np.expand_dims(image, axis=0) / 255.image = (image - 0.5) / 0.5ort_inputs = {'input1': np.array([image])}ort_outs = self._DdddOcr__ort_session.run(None, ort_inputs)for item in ort_outs[0][0]:if item == 0:continuereturn self._DdddOcr__charset[item]def ocrFontChar(self, char):if char in self.cache:return self.cache[char]im = self.getCharImage(char)return self.cache.setdefault(char, self.ocr(im))def getCharImage(self, char):if char in self.im_cache:return self.im_cache[char]im = Image.new(mode='L', size=(64, 64), color=255)draw = ImageDraw.Draw(im=im)w, h = draw.textsize(char, self.font)o1, o2 = self.font.getoffset(char)fontx, fonty = (64-w-o1)/2, (64-h-o2)/2draw.text(xy=(fontx, fonty), text=char, fill=0, font=self.font)return self.im_cache.setdefault(char, im)調用方式:
fontocr = FontOCR("shopNum.woff") fontocr.getRealChar("\uF7F2") '讓'于是可以通過以下代碼可以對自定義字體的全部unicode代碼點識別一遍:
from fontTools.ttLib import TTFontfont_name = "address.woff" fontocr = FontOCR(font_name) font = TTFont(font_name) for name, real_char in zip(font.getGlyphOrder(), chars):if not name.startswith("uni"):continuechar = f"\\u{name[3:]}".encode().decode("unicode_escape")ocr_char = fontocr.ocrFontChar(char)print(name, real_char, ocr_char) uniec3e 1 1 unif3fc 2 2 uniea1f 3 3 unie7f7 4 4 unie258 5 5 unif5aa 6 6 unif48c 7 7 unif088 8 8 unif588 9 9 unif82e 0 0 unie7c5 店 店 unie137 中 中 unie2cb 美 美 unif767 家 家 ...可以看到這些數據都被正確的解析出來,至此我們就完成了對任意自定義字體的智能解析。
總結
今天,我首先演示了如何生成自定義字體,并對字體的格式結構進行了較為詳細的講解,順便演示如何通過python的fontools庫獲取相應的字體數據。
在上一篇文章中,我們通過二級緩存解決了cssURL和fontURL隨機以及Unicode代碼點順序點隨機的問題,本文進一步考慮針對自定義字體文件內部,輪廓圖甚至基礎字形也隨機怎么處理。
本文針對輪廓圖順序隨機,開發了FontMatch,傳入已知字體的輪廓圖順序,能處理任何針對該字體進行輪廓圖順序隨機的匹配,準確率能達到100%。
但針對字形也可能隨機的情況,中間個人進行了很多基礎研究,寫了很多算法,但最終都還不如圖像識別的效果更好。所以最終我封裝了一個基于圖像識別的OCR處理類,能夠針對任何自定義字體傳入輸入字符識別出相應的結果字符。目前測試的600個高頻字符,準確率達到98%以上,針對未來的不確定性,犧牲這一點準確率個人感覺也很值。
總結
以上是生活随笔為你收集整理的woff字体图元结构剖析,自定义字体的制作与匹配和识别的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: CCF CSP 小明放学
- 下一篇: 【币值最大化问题】