DRYな備忘録

Don't Repeat Yourself.

Font Awesome のアイコンのアセットサイズが大きかったので利用するアイコンだけのサイズに削減したい

問題

現在開発しているChrome拡張において、Font Awesome のアイコンのためのフォントファイルが占める割合が大きいことがわかった。

% ls -l dist/assets | awk '{print $5"\t"$9}' | sort -n
15463  Mado-c38406e7.js
77160  fontawesome-webfont.woff2
98024  fontawesome-webfont.woff
165548 fontawesome-webfont.ttf
165742 fontawesome-webfont.eot
444379 fontawesome-webfont.svg
680908 index.css

もちろん、CSSサードパーティフレームワークを安直にscssで@import 'bulma'とかしているだけなので、改善の余地は大いにあるんだが...

いったん今回は、svgはじめ、他のフォントファイルのサイズも減らしていきたい。

追記: 2024-08-20

  • Unicode emoji に global に定義がある(=各プラットフォームでネイティブにサポートされている)ものについて、正しく動いていないような気がする。ただし、aliasを使うと解決するっぽい
    • share f064 (alias = fa-mail-forward)
    • bell f0f3
  • 要原因調査

tl;dr

できました。

github.com

% ls -l dist/assets | awk '{print $5"\t"$9}' | sort -n
3088   fontawesome-webfont.woff2
3804   fontawesome-webfont.woff
5852   fontawesome-webfont.ttf
6048   fontawesome-webfont.eot
13730  fontawesome-webfont.svg
15463  Mado-c38406e7.js
680908 index.css

できてます。444Kが13Kになって、とりあえずいい感じ。

考え方

  • 700近くある FontAwesome Icons のうち、使ってるアイコンはせいぜい20とかなので、素朴に考えて「使ってるやつだけ抜粋する」というアプローチでシンプルに減らせるはず
  • 同様に、以前 @fortawesome/fortawesome-react における library は使ったことあって、これは最終的にbundleのサイズが使ってるやつだけにするやつ
  • だが、またnpmのパッケージ増やすのもあれだし、仕組みがあんまりよくわかってないし、勉強だと思って自分でやってみたい

eotやttfやwoffに比べて、svgはリーダブルだし扱いやすいかなということで、以下の方針で考えた

  1. プロジェクトのソースコードの中から「使ってるアイコン」を特定する
  2. 元のsvgファイルの中から、「使ってるアイコン」だけを残して「使ってないアイコン」を消し、新たなsvgファイルとして保存する
  3. このsvgファイルを下に、ttfやwoff、eotファイルを生成する

1. プロジェクトのソースコードの中から「使ってるアイコン」を特定する

厳密にやってもいいんですが、今回はシンプルに、grepでもいいし、まあjsで line by line でregexpしていこうかと思いました。

  const target_folder_path = path.join(PROJECT_ROOT, default_target_folder);
  const summary: { [name: string]: { glyph: string, unicode: string, appearance: { file: string, line: number }[] } } = {};

  // ターゲットとなるsrcフォルダの前ファイルエントリを取得
  const entries = await fs.readdir(target_folder_path, { recursive: true });

  // 地道に回す
  for (let i = 0; i < entries.length; i++) {
    const e = entries[i];

    // スキャン対象ではない拡張子のファイルは無視
    const ext = e.split(".").pop() || "";
    if (!default_scan_extensions.includes(ext)) continue;

    // 内容取得
    const file_path = path.join(target_folder_path, e);
    const contents = await fs.readFile(file_path, "utf-8");

    // 内容を一行ずつ見る
    contents.split("\n").forEach((line, line_number) => {

      // 雑に "fa fa-plus" みたいに宣言している行rをregexpで見る
      // まあさすがにclassNameを複数行で書いたりしてないでしょ
      // 後述ポイント (1)
      const match = line.matchAll(/fa[ ]+fa-(?<name>[a-z0-9-]+)/g);
      for (const m of match) {
        if (m.groups?.name) {
          // SVGファイルだとglyphと呼称され、アンダーバーなのでそうしとく
          summary[m.groups.name] = summary[m.groups.name] || {
            /**
             * dictonaryっていきなり出てきたけど、次のセクションで触れる
            **/
            glyph: m.groups.name.replace(/-/g, "_"), unicode: dictionary[m.groups.name],
            appearance: [],
          };
          // 別に何回参照されてるとかは不要な情報なんだけど、なんとなく
          summary[m.groups.name].appearance.push({ file: file_path, line: line_number });
        }
      }
    });
  }

後述ポイント (1) className={"fa " + (flag ? "fa-plus" : "fa-minus")} みたいに宣言してるところ。動的にclassName変えたいときとかやってしまうんですが、まあ今回は小さいプロジェクトだし、

className={flag ? "fa fa-plus" : "fa fa-minus"}

とすることでどうにかした。いや〜雑でいいですね。個人開発ならでは、って感じ。

以上で、このプロジェクトが参照しているfont awesome のアイコンは summary の中にまとめることができました。

2-a. 元のsvgファイルの中から、「使ってるアイコン」がどれか知る

まずSVGファイルの中身がどうなっているかを知る必要がある。

どうやら、 svg>defs>font>glyph[] となってて、glyphがタグがたくさんあるのがだいたい700ぐらいありそう。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg>
  <metadata>Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 By ,,, Copyright Dave Gandy 2016. All rights reserved.
  </metadata>
  <defs>
    <font id="FontAwesome" horiz-adv-x="1536" >
      <font-face font-family="FontAwesome" />
      <missing-glyph horiz-adv-x="896" d="M224 112h448v1312h-448v-1312zM112 0v1536h672v-1536h-672z" />
      <glyph glyph-name=".notdef" horiz-adv-x="896" d="M224 112h448v1312h-448v-1312zM112 0v1536h672v-1536h-672z" />
      <glyph glyph-name=".null" horiz-adv-x="0" />
      <!-- 以下、 `glyph` が続く -->
      <!-- たとえばこんなの -->
      <!-- パターンA -->
      <glyph glyph-name="infinity" unicode="&#x221e;" horiz-adv-x="1792" />
      <glyph glyph-name="notequal" unicode="&#x2260;" horiz-adv-x="1792" />
      <!-- 他にもこんなの -->
      <!-- パターンB -->
      <glyph glyph-name="glass" unicode="&#xf000;" horiz-adv-x="1792" d="M1699 1350q0 -35 -43 -78l-632 -632v-768h320q26 0 45 -19t19 -45t-19 -45t-45 -19h-896q-26 0 -45 19t-19 45t19 45t45 19h320v768l-632 632q-43 43 -43 78q0 23 18 36.5t38 17.5t43 4h1408q23 0 43 -4t38 -17.5t18 -36.5z" />
      <glyph glyph-name="music" unicode="&#xf001;" d="M1536 1312v-1120q0 -50 -34 -89t-86 -60.5t-103.5 -32t-96.5 -10.5t-96.5 10.5t-103.5 32t-86 60.5t-34 89t34 89t86 60.5t103.5 32t96.5 10.5q105 0 192 -39v537l-768 -237v-709q0 -50 -34 -89t-86 -60.5t-103.5 -32t-96.5 -10.5t-96.5 10.5t-103.5 32t-86 60.5t-34 89 t34 89t86 60.5t103.5 32t96.5 10.5q105 0 192 -39v967q0 31 19 56.5t49 35.5l832 256q12 4 28 4q40 0 68 -28t28 -68z" />
      <!-- さらにこんなのも -->
      <!-- パターンC -->
      <glyph glyph-name="f1fc" unicode="&#xf1fc;" horiz-adv-x="1792" d="M1615 1536q70 0 122.5 -46.5t52.5 -116.5q0 -63 -45 -151q-332 -629 -465 -752q-97 -91 -218 -91q-126 0 -216.5 92.5t-90.5 219.5q0 128 92 212l638 579q59 54 130 54zM706 502q39 -76 106.5 -130t150.5 -76l1 -71q4 -213 -129.5 -347t-348.5 -134q-123 0 -218 46.5 t-152.5 127.5t-86.5 183t-29 220q7 -5 41 -30t62 -44.5t59 -36.5t46 -17q41 0 55 37q25 66 57.5 112.5t69.5 76t88 47.5t103 25.5t125 10.5z" />
      <!-- ここが味噌 -->
    </font>
  </divs>
</svg>
  • パターンA: infinityとかnotequalとかは、Unicode Emoji でたぶん定義されててそれを使ってるにすぎないので、path定義無いのだと推察
  • パターンB: FontAwesome独自のアイコンで、適当なhex4桁のunicodeに割り当てて、pathを書いて、フォントを定義しているのだと推察
    • glyph-name も、css的なclassNameで指定するものと一致している
  • 問題はパターンC: glyph-name が human readable な単語ではなく、unicodeに当ててる hex 4桁を流用している。なんでだろ
    • ちなみにたとえば f1fc とは、paint-brush というアイコンに対応している

パターンCがあるため、単純にソースコードから抽出したアイコン名(e.g., fa-paint-brush)ではなく、ユニコードに割り当てた hex 4桁 をひいてこないと、このSVGファイルから「使ってるやつglyph」「使ってないglyph」を判別できないということである。

おそらくfont-awesome自体は、クラス名から font-awesome.scss の中で、クラス名と hex 4桁 の対応をひいてくる部分があると思われ、npmでダウンロードしたnode_modulesの中のfont-awesomeの中を覗いてみると、_variable.scss というのが臭そう

% tree node_modules/font-awesome/scss/
node_modules/font-awesome/scss/
├── _animated.scss
├── _bordered-pulled.scss
├── _core.scss
├── _fixed-width.scss
├── _icons.scss
├── _larger.scss
├── _list.scss
├── _mixins.scss
├── _path.scss
├── _rotated-flipped.scss
├── _screen-reader.scss
├── _stacked.scss
├── _variables.scss
└── font-awesome.scss

1 directory, 14 files

クラス名とunicodeのhexの対応をみつけた

これを踏まえ、この _variables.scss ファイルをもとに辞書をつくって、fa-paint-brush あるいは f1fcSVGファイルのglyphを検索できるようにすればよかろう。 ということで、以下のコードでオンメモリの辞書をつくる。

  const dictionary: { [name: string]: string } = {};
  const refs = await fs.readFile(unicode_reference_file_path, "utf-8");
  refs.split("\n").forEach((line) => {
    const match = line.match(/fa-var-(?<name>[a-z0-9-]+): "\\(?<unicode>[a-f0-9]+)"/);
    if (match?.groups?.name && match?.groups?.unicode) {
      dictionary[match.groups.name] = match.groups.unicode;
    }
  });

これで、SVGファイルから「使ってるアイコン」と「使ってないアイコン」が峻別できるようになったはずである

2-b. 元のsvgファイルの中から、「使ってるアイコン」だけを残す

  // Open SVG file and remove all the unnecessary lines except for the keys in the summary
  const svg_file_path = path.join(PROJECT_ROOT, "dist", "assets", "fontawesome-webfont.svg");
  // XML形式のファイルをjsで扱いたいのでJSDOMでDOM化する
  const doc: JSDOM = await JSDOM.fromFile(svg_file_path);
  const glyphs: SVGElement[] = [];
  Object.values(summary).forEach(({ glyph, unicode }) => {
    // glyph-name か、あるいは unicode で検索する
    const node: SVGAElement = doc.window._document.querySelector(`glyph[glyph-name=${glyph}]`)
      || doc.window._document.querySelector(`glyph[unicode="\\${unicode}"]`);
    if (node) {
      // 見つかったら溜めとく
      glyphs.push(node as SVGElement);
    } else {
      warn("  [!] ", "GLYPH NOT FOUND: ", unicode, glyph);
    }
  });
  info("  > ", "Glyphs to be saved:", glyphs.length);

2-c. 「使ってないアイコン」を消して、あたらたなSVGファイルを保存する

  // Remove all glyphs
  const font: SVGElement = doc.window._document.querySelector("font");
  const allGlyphs = font.querySelectorAll("glyph");
  allGlyphs.forEach((g) => g.remove());
  info("  > ", "Removed glyphs:", allGlyphs.length);
  // Insert only necessary glyphs
  font.append(...glyphs);

  // Backup old file
  // await fs.cp(svg_file_path, svg_file_path.replace(".svg", ".backup.svg"));

  // Save the file
  await fs.rm(svg_file_path, { force: true });
  const content = doc.serialize();
  await fs.writeFile(svg_file_path, content);

3. SVGファイルを他のフォントファイルへ変換する

ここを自力でやってもよかったが、深淵なるフォントの世界に踏み入れることになるかなーとか思ったので、いったんインターネッツにあるものの力を借りました。

marmooo/fontconvさんを読む限り、SVGが手元のインプットとしてある場合、

                  WOFF
                   ↑
               {ttf2woff}
                   ↑
SVG → {svg2ttf} → TTF → {wawoff2} → WOFF2
                   ↓
               {ttf2eot}
                   ↓
                  EOT

という流れで他のファイルが作れるようである。ttfファイがこの世界の中心なのか。しらんけど。

ちょっと読んでみましたが、いきなりコードから入ってわかるレベルではなかった。勉強したかったら仕様から入らんとダメな雰囲気がした。

github.com

ということで、fontconvを使って他のフォントファイルを生成します。

  // Convert SVG to TTF, WOFF, WOFF2, EOT
  for (let i = 0; i < destination_fontfile_extensions.length; i++) {
    const ext = "." + destination_fontfile_extensions[i];
    const dest_content = await fontconv(content, ext, {});
    const dest_file = svg_file_path.replace(".svg", ext);
    await fs.rm(dest_file, { force: true });
    await fs.writeFile(dest_file, dest_content);
    info("  > ", "Created font file:", path.basename(dest_file));
  }
  info("[DONE]", "Minimized fontawesome SVG file with icons actually used in the project\n");

完成したもの

Before After

github.com

雑感

  • しょせんプログラマは「自分が理解できる限界のレイヤで踊らされているにすぎない」ということを改めて痛感しました
  • でも、できる限り「堀りにいく」という経験や態度は、は非常に重要だなということも、再度実感しました
  • また、個人開発で思いっきり雑で勢い重視の実装や調査をするのは、たのしいなあ
  • 9月からまた仕事だが、できればこの「技術が好き」という、生む金は大きく無いが、輪郭のはっきりとした情熱を活かしていければと思う

DRYな備忘録として