解決網頁中文字型的缺字問題

2019-10-30

Missing Chinese Font Cover

最近有個項目需要在網頁使用一些手寫風格的中文字型,眾所周知這些字型的缺字問題是非常嚴重的,在正常情況下系統會自動補字,但我發現在網頁上很多缺字都會直接變成空白,以下就來看看我解決的方法

找出沒有補字的原因

如果沒興趣可以跳過這個部份

下載並找出 Setofont 的缺字

首先下載瀨戶字體 Seto font,取得一個 .ttf 檔,這裡我把它命名為 setofont.ttf,然後建立一個簡單的 html 網頁測試字體

<!doctype html>
<html>
  <head>
    <title></title>
    <style type="text/css">
      @font-face {
        font-family: setofont;
        src: url(setofont.ttf);
      }
      strong {
        font-family: setofont, "微軟正黑體", "華文黑體", serif;
      }
    </style>
  </head>
  <body>
    <p>白川鄉旅行</p>
    <p><strong>白川鄉旅行</strong></p>
  </body>
</html>

在網頁打開 html 可以看到 setofont 的「鄉」字變成空白了,就算是使用了 Windows 的「微軟正黑體」和 MacOS 的「華文黑體」作為 fallback 也沒辦法成功補字

Missing Chinese Font

用 FontForge 找出問題所在

為了找出問題所在,我們需要使用一些工具檢視字體,所以這邊我們下載一個叫 FontForge 的多功能字型編輯軟件,下載好後打開並選擇剛下載的 setofont.ttf,會看到這些很生動的手寫字體 :3

Font Forge

然後在選單中找到 View > Goto,因為我們知道「鄉」是其中一個缺字,所以就直接搜尋「鄉」

Font Forge Goto Search Font Forge Search Result

搜尋後發現「鄉」在這個字體中真的是空白一片,以我的理解是因為這字體把「鄉」字用空白填滿了,所以就算在我們眼中它是一個缺字,但系統也不這樣認為,至少 CSS 是這樣,所以接下來我們嘗試把「鄉」從字型中刪掉,在下面的空格地方右鍵然後選擇 Clear

Font Forge Clear Font Forge Clear Result

刪除後我們發現空格變成一個叉包了,之後在選單中選擇 File > Generate Fonts 儲存字型後我們再測試一次字體,這次「鄉」字有成功補上

Missing Chinese Auto-fill

移除所有空白字

為了讓系統把字補上,我的解決方法是把所有空白的字都刪除掉,所以我就寫了一個簡單的 Node.js 腳本

事前準備

我們需要以下工具

安裝 pipfonttools

先安裝 python 然後用以下指令安裝 pip

curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
python get-pip.py

接著安裝 fonttools

pip install fonttools

用 fonttools 把 .ttf 變成 xml

如果你還沒有下載瀨戶字體 Seto font,先下載好然後再用 fonttools 指令取得 setofont.ttx.ttxfonttools 的格式,實際上就是一個 xml 檔,其他字型檔論理上也可以用 fonttools 拆解成 xml,我們將會取得一個接近 100MB.ttx 檔…

ttx ./setofont.ttf

使用 Node.js 腳本把空白的字刪除

由於檔案太大,在沒有做 optimize 的情況下我們需要 8GB 的記憶體才能夠運行,在同一目錄下建立 index.js 檔案,複製下面的原碼然後執行以下指令

npm init -y
npm i node-expat xmlbuilder
node --max-old-space-size=8192 index.js

這是 index.js

const fs = require("fs");
const expat = require("node-expat");
const builder = require("xmlbuilder");

// Change the source and dest file here
const src = fs.readFileSync("./setofont.ttx");
const destFileName = "out.ttx";

// Entry point
(async () => {
  const filterList = await scan();
  console.log(`Char to filter: ${filterList.length}`);
  const xml = await parseXml(filterList);
})();

function scan() {
  return new Promise((rs, rj) => {
    let firstEle;
    const filterList = [];
    const parser = new expat.Parser("UTF-8");
    parser.on("startElement", function (name, attrs) {
      if (!firstEle) {
        firstEle = name;
      }
      if (name === "TTGlyph" && attrs.yMin === undefined) {
        filterList.push(attrs.name);
      }
    });

    parser.on("endElement", function (name) {
      if (firstEle === name) {
        rs(filterList);
      }
    });

    parser.on("error", function (error) {
      console.error(error);
      return rj(error);
    });

    parser.write(src);
  });
}

function parseXml(filterList) {
  return new Promise((rs, rj) => {
    const parser = new expat.Parser("UTF-8");
    let firstEle;
    let root;
    let levels = [];
    let skip = false;
    let skipEleName;

    parser.on("startElement", function (name, attrs) {
      if (!root) {
        firstEle = name;
        root = builder.create(name);
        Object.keys(attrs).forEach((key) => {
          root.att(key, attrs[key]);
        });
        levels.push(root);
      } else if (!skip) {
        // Skip
        if (
          filterList.includes(attrs.name) ||
          filterList.includes(attrs.glyph) ||
          filterList.includes(attrs.in)
        ) {
          skip = true;
          skipEleName = name;
        } else {
          const ele = levels[levels.length - 1].ele(name, attrs);
          levels.push(ele);
        }
      }
    });

    parser.on("endElement", function (name) {
      if (firstEle === name) {
        // End
        const xml = root.end({ pretty: false });
        fs.writeFileSync(destFileName, xml);
      } else {
        if (skip) {
          if (skipEleName === name) {
            skip = false;
          }
        } else {
          levels.pop();
        }
      }
    });

    parser.on("text", function (text) {
      if (!skip) {
        levels[levels.length - 1].text(text);
      }
    });

    parser.on("error", function (error) {
      console.error(error);
    });

    parser.write(src);
  });
}

運行後如無意外會產生 out.ttx,大小會比原來的 .ttx 檔小,因為我們把沒用的 xml tag 刪掉了,接下來再次使用 fonttools 指令把 ttx 還原成 ttf

ttx ./out.ttx

最後我們就可以得到不會出現缺字變成空白的 out.ttf 了﹗