2024年4月22日月曜日

フォントを様々な形式に変換する fontconv を作った

フォントを様々な形式に変換する fontconv を作りました。 .ttf, .otf, .svg, .woff, .woff2 の相互変換とサブセット化に対応しています。.eot も出力だけは対応しています。

fontconv

なぜ作ったかというと、フォントを Web 上で利用するための woff2 最適化に苦労したからです。 私の場合は以前 ttf2svg を自作したので、 ttf2svg -> svg2woff2 の2回で woff2 は最適化できるようにしていたのですが、 巷のアイコンは woff2 形式でしか配布されていないことがあることに気付きました。 これがなかなか強敵で、woff2 をロードして最適化した後、 woff2 へ再変換できるライブラリが見つかりませんでした。 具体例を上げれば Material Icons の最適化は大変で、びっくりしました。 さすがにこれはおかしいなと思い、様々な形式に対応したフォントコンバーターを作りました。

ちなみに類似ライブラリとしては以下があることは認識しているのですが、 依存性が更新されていなかったり、手元で動かないのが厳しかったです。 どうやら Node.js に関してはきちんと動くものは作り直すしかないみたい。 pyftsubset は動きますが、Python でインストールがやや面倒で、異体字に対応していない問題があります。 良いものないなあ…と思っていたら、公開直前に tdewolff/font が出てきました。 拡張子ごとにパーサをフル実装しているようで凄いですが、私のおきらく実装でも選択肢が増えるのは良いんじゃないかな…。

OTF からの変換が難しすぎる!!!

実装上で一番厳しかったのは OTF を他の形式に変換するライブラリがなかったところです。 TTF と WOFF/WOFF2 の変換ライブラリは山ほどあるんですが、OTF を他形式に変換するライブラリがまったくありません。 フォントの最適化には OTF 出力しかできない opentype.js を使っていますが、 代替になるライブラリは現状存在しないので、OTF をベースに実装することは絶対条件です。 最初は、唯一きちんとした otf2ttf 実装に見えた fonteditor-core を試してみました。 しかし glyph-name の情報をバッサリ切り捨ててしまう実装だったので、とてもフォント最適化には使えないとわかりました。

その後色々なライブラリを確認してみたものの、otf2ttf で動くものは見つかりませんでした。 結局のところ今までのように ttf2svg を使って font→svg→ttf で変換するのが、一番仕様がわかりやすくて良いとわかりました。 ただ ttf2svg の実装も色々と問題があるのがわかっていたので、 良い機会なのでバッサリ書き直し、fontconv と同じオプションで動くようにしました。

EOT 形式について

いまは TTF/OTF/WOFF/WOFF2 が標準的なフォント形式ですが、昔は EOT という IE でしか動かないフォント形式があったらしいです。 もはや誰も使っていないのでサポートする必要はない気がしますが、一応サポートしてみようと思いました。 しかし案の定これも誰も作っていない。 fonteditor-core に eot2ttf が付いていたので試してはみましたが、 バグってて動かなかったので EOT 形式の入力は無理そうです。 自分で eot2ttf を作れば何とかなりますが、もはや誰も使わない EOT 形式のコードを書く気はさすがに起きなかったです。 ChatGPT 3.5 が作ってくれるかなと期待して雑に依頼してみたりもしましたが、案の定できませんでした。 AGI はまだまだ遠いか…。4 なら作れたりするのかな。

使い方

実装で色々詰まりましたが、きちんと完成はしました。 fontconv を使えばフォントの最適化は簡単に実行できます。 まずは以下のコマンドで CLI をインストールします。
deno install -fr --allow-read --allow-write --name fontconv \
https://raw.githubusercontent.com/marmooo/fontconv/main/cli.js
例えば Material Icons から特定のコードポイントだけを抽出して最適化する場合、使いたいコードポイントを行区切りでリスト化し、codepoints.lst として保存します。 その後、以下のコマンドを打てば完了です。
fontconv --code-file codepoints.lst in.woff2 out.woff2
ファイルを用意するのが面倒なときは、文字列やコンマ区切りで最適化ができます。
fontconv --text abcdef in.ttf out.woff
fontconv --code 0x61,98 in.otf out.eot


問題点

これでもうフォント最適化には困らないで済むわ〜と思ったら Material Icons は ligature (合字) を使っているので、合字の最適化ができませんでした。 というより fontconv は SVG フォントを経由して変換をしていますが、SVG フォントには合字がありませんからサポートは困難と言えます。 とはいえ svg2ttfunicode 属性を拡張して ligature に対応している ので、 ttf2svg で ligature 属性を考慮すればサポートできるような気はします。 あまり実装したくないのですが、opentype.js でも ligature の解析は対応しているみたい

ひとまず初期リリースの時点においては、Material Icons のように合字を使ったフォントの最適化については「合字を使わない」が結論になりそうです。 合字の定義だけでファイルサイズが増えてしまいますから、コードポイントを使って最適化し、HTML からもコードポイントで指定するのが良さそうです。 Bootstrap Icons のようにクラス名を使ってくださいな

fontconv を作ってみて、改めてフォントを扱うのは難しいなあと感じました。 他の課題としては、現時点では異体字 (IVD) をサポートしていません。 これは opentype.js 1.3.4 が対応していないからですが、 最新版では対応しているようなので、opentype.js の更新に期待です。

初めて denoShims のバグを踏んだっぽい

さて npm に公開しようと思ったら、どうやら初めて denoShims のバグを踏みました。 たとえば以下は Deno 上ならどちらも動きますが、dnt のテストでは上だけ動かないです。 数行のコードでも発生するとなれば、おそらく Deno.readFileSync() の shims がきちんと動いてないです。
import { parse } from "npm:opentype.js@1.3.4";

Deno.test("Input check", async () => {
  const uint8array = Deno.readFileSync("test/format-check.otf");
  const font = parse(uint8array.buffer); // dnt でなぜかパースに失敗
});
readFile() なら動きます。さすがにバグだよなこれ…。
import { parse } from "npm:opentype.js@1.3.4";

Deno.test("Input check", async () => {
  const uint8array = await Deno.readFile("test/format-check.otf");
  const font = parse(uint8array.buffer);
});
まあ動いたから良いか…。

0 件のコメント: