上週聊到中文點陣字型,年輕同學們可能沒啥感覺,但經歷過 DOS 時代的老人隔了幾十年後再摸到老東西,滿滿的回憶呀,感受格外強烈,本週就繼續在其中找樂子。

上回說到我沒找到明確授權且不是 GPL 的中文點陣字型(Open Source 沒問題,但真心不喜歡被 GPL 掐住脖子的感覺),我打算用思源黑體轉換成點陣字型以避開版權爭議,為準備後續大量批次作業,我先寫好一個將國喬、倚天字型 byte[] 資料轉中文字圖檔的小函式。

以國喬中文系統為例,普通中文字的尺寸是 16x14 點,在 16x16 字型檔 KCCHIN16.F00 會佔 28 個 Byte,例如「黑」這個字的字型資料是 00-04-1F-FE-10-84-16-B4-10-84-1F-FC-00-80-3F-FE-00-80-7F-FF-00-00-14-48-12-26-22-22,每一列為 2 Byte 共 16 Bit 記錄 16 個點是否塗色,共 14 列。若將這串數字稍加排列並轉成二進位,點陣資料的儲存原理就不難理解了。

註:以上的示意圖是用 JavaScript 寫的,這陣子較少碰前端,我刻意寫成網頁維持手感:線上版

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>中文 16x16 點陣字範例</title>
  <style>
    .z { margin-left: 16px; font-size: 9pt; }
    .z > span { 
      display: inline-block; width: 14px; 
      border: 1px solid gray;
      text-align: center; margin: 1px;
    }
    .z .v1 {
      color: white; background-color: #444;
    }
  </style>
</head>
<body>
  <div id=display></div>
  <script>
    var hex = '00-04-1F-FE-10-84-16-B4-10-84-1F-FC-00-80-3F-FE-00-80-7F-FF-00-00-14-48-12-26-22-22';
    var a = hex.split('-');
    var h = [];
    function toBin(v) {
      v = parseInt(v, 16);
      for (var j = 0; j < 8; j++) {
        var d = ((v << j) & 0x80) > 0 ? '1' : '0';
        h.push('<span class=v' + d + '>' + d + '</span>');
      }
    }
    for (var i = 0; i < a.length / 2; i++) {
      var o = i * 2;
      h.push('<div>' + a[o] + ' ' + a[o + 1] + '<span class=z>');
      toBin(a[o]);
      toBin(a[o + 1]);
      h.push('</span></div>');
    }
    document.getElementById('display').innerHTML = h.join('');
  </script>
</body>
</html>

按照這個原理,要將 byte[28] 轉成圖形,最直覺的做法是建一個 16x16 的 Bitmap,x 由 0 到 16,y 由 0 到 14,依據各位元是 1 是 0 將對映像素設成黑色或白色,例如:

public byte[] GetCharPng(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var bmp = new Bitmap(cd.Category == CharCategories.Ascii ? 8 : 16, 16);
    var h = cd.Category == CharCategories.Symbol ? 16 : (cd.Category == CharCategories.Chinese ? 14 : 15);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var offset = 0;
    for (var y = 0; y < h; y++)
    {
        var b = fontData[offset];
        for (var x = 0; x < w; x++)
        {
            if ((b & 0x80) != 0)
                bmp.SetPixel(x, y, Color.Black);
            if (w > 8 && x == 7)
                b = fontData[++offset];
            else
                b = (byte)(b << 1);
        }
        offset++;
    }
    using (var ms = new MemoryStream())
    {
        bmp.Save(ms, ImageFormat.Png);
        return ms.ToArray();
    }
}

以「黑」為例,這個 16x16 的 PNG 檔,大小為 192 Bytes,若 13195 個中文 + 765 個全形英數字符號,總大小應不超過 3MB(每個字的圖檔會因壓縮大小不一)。

但想想這個方法不算太有效率,.NET 的 Bitmap 圖檔型別支援 PixelFormat.Format1bppIndexed 格式,當顏色深度為 1,系統會用一個位元記錄黑白,儲存格式跟中文字型檔的原生格式相同,代表我們可以將其直接複製到 Bitmap 內部,以 Byte 為單位,取代一個一個像素設定。針對這種低階資料作業,Bitmap 提供了 LockBits()BitmapData.Scan0 等 API,允許開發人員藉由低階資料存取提升圖形運算速度。之前我用過它展示 Unsafe 的威力(延伸閱讀:Unsafe, But Fast!),這次的情境也非常適合靠它加速,除了將字型資料圖形化,未來向量字點陣字時,會先顯示成圖形再轉成相容的 byte[] 格式,亦可用相同技巧取代 GetPixel() 一點一點掃瞄,速度可望大幅提升。

廢話不多說,直接上 Code:

public byte[] GetCharBmp(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var fontDataStride = w / 8;
    var h = 16;
    var bmp = new Bitmap(w, h, PixelFormat.Format1bppIndexed);
    var bmd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.Read, PixelFormat.Format1bppIndexed);
    var idx = 0;
    unsafe
    {
        byte* p = (byte*)bmd.Scan0;
        for (var y = 0; y<h; y++)
        {
            for (var s = 0; s < bmd.Stride; s++)
            {
                if (s < fontDataStride && idx < fontData.Length)
                {
                    p[0] = fontData[idx++];
                }
                p++;
            }
        }
    }
    bmp.UnlockBits(bmd);
    using (var ms = new MemoryStream())
    {
        bmp.Save(ms, ImageFormat.Bmp);
        return ms.ToArray();
    }
}

實際動手寫才會發現眉角,例如:BitmapData 有個 Stride 屬性,代表一列像素要用多少個 Byte 儲存,依直覺 16 點用兩個 Byte 就夠了。但實際上它會以 4 為單位遞增(可能要湊 32 Bits 與 CPU、暫存器的運算單位一致吧),不足 4 時以 4 計,官方說明有提到這點(如下),所以在複製資料時,每兩個 Byte 之後再跳過兩個 Byte,不是連續寫入。

The stride is the width of a single row of pixels (a scan line), rounded up to a four-byte boundary.

而存成 .bmp 檔時,每一列像素也是用 4 Bytes,加上 .bmp 檔的標頭,16x16 的黑白兩色圖檔大小為 126 Bytes,比 .png 小。

接下來跑個 Benchmark,用一小段程式對範例字串逐字元呼叫 GetCharPng() 及 GetCharBmp() 1000 次:

public IActionResult OnGet()
{
    var sw = new Stopwatch();
    var s = "我達達的馬蹄是美麗的錯誤";
    var ary = s.ToCharArray();
    int times = 1000;
    for (int j = 0; j < 3; j++)
    {
        sw.Restart();
        for (var i = 0; i < times; i++)
            s.ToCharArray().Select(ch => kcfa.GetCharPng(ch)).ToList();
        sw.Stop();
        Debug.WriteLine($"PNG: {sw.ElapsedTicks:n0}");
        sw.Restart();
        for (var i = 0; i < times; i++)
            s.ToCharArray().Select(ch => kcfa.GetCharBmp(ch)).ToList();
        sw.Stop();
        Debug.WriteLine($"BMP: {sw.ElapsedTicks:n0}");
    }
    return Content("OK");
}

數據顯示,GetCharBmp() 比 GetCharPng() 快了三倍多,但領先幅度不如預期大。

PNG: 25,683,409
BMP: 7,291,316
PNG: 25,021,673
BMP: 7,334,556
PNG: 24,495,296
BMP: 7,004,444

Visual Studio 2019 Diagnostic Tools 的效能圖表,SetPixel() 不意外榜上有名,但發現 Bitmap 建構式與 Save() 也挺耗 CPU,Save() 無法避免,但採用低階指令複製 byte[],Bitmap 物件應可以共用。

於是我再改寫一版,預先建好半形及全形用兩個 Bitmap 靜態變數,之後直接共用不要每次重建(考慮可能多緒執行,宜加 lock 保護):

static Bitmap Bmp4Ascii = new Bitmap(8, 16, PixelFormat.Format1bppIndexed);
static Bitmap Bmp4Chinese = new Bitmap(16, 16, PixelFormat.Format1bppIndexed);

public byte[] GetCharBmp(char ch)
{
    var cd = new CharData(ch);
    var fontData = ReadFont(ch);
    var w = cd.Category == CharCategories.Ascii ? 8 : 16;
    var fontDataStride = w / 8;
    var h = 16;
    var bmp = cd.Category == CharCategories.Ascii ? Bmp4Ascii : Bmp4Chinese;
    lock (bmp)
    {
        var bmd = bmp.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadWrite, PixelFormat.Format1bppIndexed);
        var idx = 0;
        unsafe
        {
            byte* p = (byte*)bmd.Scan0;
            for (var y = 0; y < h; y++)
            {
                for (var s = 0; s < bmd.Stride; s++)
                {
                    if (s < fontDataStride && idx < fontData.Length)
                    {
                        p[0] = fontData[idx++];
                    }
                    p++;
                }
            }
        }
        bmp.UnlockBits(bmd);
        using (var ms = new MemoryStream())
        {
            bmp.Save(ms, ImageFormat.Bmp);
            return ms.ToArray();
        }
    }
}

由實測結果,這項調整有抓到重點,Bitmap 產生時間再縮短一半:

PNG: 25,782,349
BMP: 3,352,245
PNG: 24,865,341
BMP: 3,334,074
PNG: 24,673,937
BMP: 3,479,733

若還想再加速,還有一些調整手法,例如:省下迴圈直接寫死 p[0] = fontData[0]; p[1] = fontData[1]; p[4] = fontData[2]; p[5] = fontData[3]...,但這類技巧要犠牲可讀性及可維護性,代價偏高。這次處理量不過一萬多筆,直覺不必搞到太極端,未來等真有需要再改也不遲。

今天的 Coding4Fun 練習了 JavaScript、Bitmap 低階操作、unsafe 及簡單效能調校,收獲滿滿,Coding 樂無窮~

Example of how to convert dot matrix byte array data to Bitmap efficiently with .NET and some performance tips.


Comments

# by CY

好帥啊阿

# by joker

偶像!!

# by RGM-79[G]

看不懂真的是無字天書.......只能服了......

Post a comment