Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
22
Help us understand the problem. What is going on with this article?

More than 1 year has passed since last update.

@nishinoshake

Nuxt.jsでページ内の日本語だけでWebフォントのサブセットを作成したい(静的サイトの話)

Nuxt.jsで静的サイトを作ったときに、重たい日本語のWebフォントの容量を抑えるべく色々やった話です。
Nuxt.jsのアドベントカレンダーなのに、Nuxt成分が15%ぐらいしかないです、すみません。

screenshot.jpg
デモページ

日本語のWebフォントがメガからキロに

形式 容量
OTF 21MB
WOFF 23KB
WOFF2 22KB

その効果、1/1000。
というのは、すごく都合の良い結果で、キリのいい数字になるようにデモページの文字量を調整しました。何をやってるんだろう。デモページ以前に、初めてサブセットを試みたサイトでも 80KB ぐらいまでは減らせたので、小さいWebサイトではそこそこ効果がありそうです。

ローカルの環境

macOS 10.13
node 8.11
yarn 1.12
python 3.6
pip 18.1

きっかけ

「JavaScriptのイベントをたくさん見られるサイト」というWebサイトをNuxt.jsで作成した際に、明朝のWebフォントを使ったのですが、どうせ静的サイトで使う文字が決まっているわけだから容量を軽くできないかな、と思ったのがきっかけです。

フォント/形式/ツールの選定

前提として、日本語のWebフォントは控えめに言ってクソ重いので、使わないに越したことはありません。どうしても使いたいのであれば、できる限り容量を減らすよう努力する必要があります。

一番手っ取り早いのはお金を払ってFONTPLUSTypeSquareなどのサービスを使うことです。たいていのサービスでは動的にサブセットを生成してくれるので、容量を抑えられます。最近ではGoogle Fontsにも日本語のWebフォントがあるので、こちらも有力な選択肢になるかもしれません。@font-faceunicode-rangeで匠にサブセットしてくれているので、Webフォントをそのまま使うよりも容量が軽くて速いです。

それらを使えない場合は、おとなしく自前でサブセットを用意します。

フォント - Noto Serif

フォントは、きれいでオープンな明朝体のNoto Serifを選びました。現在はGoogle Fontsでサブセットされたものが使えますが、Webサイトを作った当初はまだEarly Accessで重くてつらかった(はず)なので、自前でサブセットを用意しました。

形式 - WOFF/WOFF2

ブラウザでサポートしているフォントの形式には、EOT/TTF/WOFFなど色々ありますが、モダンなブラウザではWebに最適化されたWOFFをサポートしているので、WOFFに絞りました。WOFFよりも圧縮率が高いWOFF2というものもあるので、合わせて使用しています。これらのフォントをサポートしていないブラウザは、おそらくWebフォントなんか読み込んでいる場合じゃないと思うので、TTFやEOTは使用していません。

Conformant user agents must skip downloading a font resource if the format hints indicate only unsupported or unknown font formats.

4.3. Font reference: the src descriptor

複数のフォントファイルは@font-faceのsrcで指定します。サポートしていない形式はスキップされるようなので、WOFF2→WOFFの順に書いておけば良さそうです。

@font-face {
  font-family: "Noto Serif";
  font-style: normal;
  font-weight: 400;
  src: url(noto-serif-sub.woff2) format("woff2"), url(noto-serif-sub.woff) format("woff");
}

ツール - pyftsubset

フォントのサブセットというと、武蔵システムさんのサブセットフォントメーカーが有名ですが、Webサイトを更新するたびにソフトを起動してポチポチやるのは大変なので、コマンドラインで利用できるpyftsubsetにしました。

WOFF 2.0 is a new font format using a new compression algorithm, Brotli, created by the Google Compression team.

Smaller Fonts with WOFF 2.0 and unicode-range

こちらはPython製のツールなので、pipでインストールします。WOFF2の生成にはBrotliという圧縮アルゴリズムを使うようなので、合わせてインストールしておきます。

$ pip install fonttools brotli

Nuxt.jsで日本語のサブセット

日本語のWebフォントのサブセットを作成する場合、ひらがな/カタカナ/記号/第一水準漢字あたりで絞ることが多いですが、今回はページに含まれている日本語のみで絞ります。以前に作ったサイトでは余計なコードが混ざっているので、この記事用にデモのページを作成しました。

https://neko.noplan.cc
https://github.com/noplan1989/nuxt-mincho

フォントまわりのディレクトリ構成はこんな感じです。

├ assets/
│  └ fonts/
│    ├ noto-serif.otf       # フォントの元ファイル 
│    ├ noto-serif-sub.woff  # サブセット後のWOFF
│    └ noto-serif-sub.woff2 # サブセット後のWOFF2
├ pages/
│ ├ index.vue # トップページ
│ └ neko.vue  # 吾輩は猫である
├ scripts/
│ ├ subset-japanese.js # サブセットを生成するスクリプト
│ └ subset.txt         # サブセットしたテキスト
└ nuxt.config.js

DOMからテキストを抜き出す

まず、DOMを取得するためにjsdomを追加します。
やり方はかなり強引ですが、Nuxt.jsのサーバーを起動して、各ルートのbodyからscriptstyleを削除して、textContentをまるっと抜き出しています。

$ yarn add -D jsdom
const path = require('path')
const { JSDOM } = require('jsdom')
const { Nuxt, Builder } = require('nuxt')

const configPath = path.resolve(__dirname, '../nuxt.config.js')
const config = require(configPath)
const nuxt = new Nuxt(config)

const listen = async () => {
  await new Builder(nuxt).build()
  await nuxt.listen(4000, 'localhost')
}

const scrape = async route => {
  const { html } = await nuxt.renderRoute(route)
  const dom = new JSDOM(html)
  const { body } = dom.window.document

  body.querySelectorAll('script, style').forEach(el => el.remove())

  return body.textContent
}

const scrapeAll = async routes => {
  const values = await Promise.all(routes.map(route => scrape(route)))

  return values.join('')
}

const main = async () => {
  const routes = ['/', '/neko']

  await listen()
  const text = await scrapeAll(routes)
}

main()

日本語を抽出してファイルに書き出し

こちらも強引ですが、さきほど取得したテキストから、Unicodeのコードポイントで日本語を絞り込み、重複をなくしてテキストファイルへ書き出しました。調べるまでは、Unicodeに日本語の漢字ゾーンみたいなのがあるものだと思っていましたが、CJK(Chinese-Japanese-Korean)でまとめられている模様。地道に調べながら書きましたが、いまいち自信がないです・・・何かが欠けている気がする。

- コードポイント ここで調べた
ひらがな \u3040-\u309f Hiragana
カタカナ \u30a0-\u30ff Katakana
句読点など \u3001-\u3007 CJK Symbols and Punctuation
CJK統合漢字 \u4e00-\u9fef CJK Unified Ideographs
CJK互換漢字 \uf900-\ufaff CJK Compatibility Ideographs
const fs = require('fs')
const path = require('path')

const subset = (text, regex) =>
  [...text]
    .filter(str => regex.test(str))
    .filter((str, i, self) => self.indexOf(str) === i)
    .join('')

const main = async () => {
  const subsetPath = path.resolve(__dirname, 'subset.txt')
  const japaneseRegex = /^[\u3040-\u309f\u30a0-\u30ff\u3001-\u3007\u4e00-\u9fef\uf900-\ufaff]$/

  await listen()                       // 上のコード例
  const text = await scrapeAll(routes) // 上のコード例
  const japanese = subset(text, japaneseRegex)

  fs.writeFileSync(subsetPath, japanese)
}

main()

最終的なコード

あとは、これをコマンドラインで実行すれば、ページに含まれる日本語がテキストファイルに書き出されます。

$ yarn font:subset
package.json
{
  "scripts": {
    "font:subset": "node ./scripts/subset-japanese.js",
  }
}
subset-japanese.js
const fs = require('fs')
const path = require('path')
const { JSDOM } = require('jsdom')
const { Nuxt, Builder } = require('nuxt')

const configPath = path.resolve(__dirname, '../nuxt.config.js')
const config = require(configPath)
const nuxt = new Nuxt(config)

const listen = async () => {
  await new Builder(nuxt).build()
  await nuxt.listen(4000, 'localhost')
}

const scrape = async route => {
  const { html } = await nuxt.renderRoute(route)
  const dom = new JSDOM(html)
  const { body } = dom.window.document

  body.querySelectorAll('script, style').forEach(el => el.remove())

  return body.textContent
}

const scrapeAll = async routes => {
  const values = await Promise.all(routes.map(route => scrape(route)))

  return values.join('')
}

const subset = (text, regex) =>
  [...text]
    .filter(str => regex.test(str))
    .filter((str, i, self) => self.indexOf(str) === i)
    .join('')

const main = async () => {
  const routes = ['/', '/neko']
  const subsetPath = path.resolve(__dirname, 'subset.txt')
  const japaneseRegex = /^[\u3040-\u309f\u30a0-\u30ff\u3001-\u3007\u4e00-\u9fef\uf900-\ufaff]$/

  await listen()
  const text = await scrapeAll(routes)
  const japanese = subset(text, japaneseRegex)

  fs.writeFileSync(subsetPath, japanese)

  nuxt.close()
  process.exit(0)
}

main()
subset.txt
きれいな明朝と猫吾輩はである。名前まだ無どこ生たかん見当がつぬ何も薄暗じめし所ニャー泣て事け記憶始人間うのを聞くそ書中一番獰悪種族っ時々我捕え煮に食話

pyftsubsetでWOFF/WOFF2を生成

つぎは、日本語を抽出したテキストファイルを指定して、pyftsubsetでWOFFとWOFF2を生成します。デモで作ったWebサイトは文字量が少ないので大幅に容量を削減できました。

形式 容量
サブセット前 21MB
WOFF 39KB
WOFF2 35KB
$ yarn font:woff
$ yarn font:woff2
package.json
{
  "scripts": {
    "font:woff": "pyftsubset ./assets/fonts/noto-serif.otf --flavor=woff --text-file=./scripts/subset.txt --output-file=./assets/fonts/noto-serif-sub.woff",
    "font:woff2": "pyftsubset ./assets/fonts/noto-serif.otf --flavor=woff2 --text-file=./scripts/subset.txt --output-file=./assets/fonts/noto-serif-sub.woff2"
  }
}
$ pyftsubset --help
# 使うやつの抜粋
Usage:
  pyftsubset font-file [glyph...] [--option=value]...
  --text-file=<path>
      Like --text but reads from a file. Newline character are not added to
      the subset.
  --output-file=<path>
      The output font file. If not specified, the subsetted font
      will be saved in as font-file.subset.
  --flavor=<type>
      Specify flavor of output font file. May be 'woff' or 'woff2'.
      Note that WOFF2 requires the Brotli Python extension, available
      at https://github.com/google/brotli
  --layout-features[+|-]=<feature>[,<feature>...]
      Specify (=), add to (+=) or exclude from (-=) the comma-separated
      set of OpenType layout feature tags that will be preserved.
      Examples:
        --layout-features=''
            * Drop all features.
        --layout-features='*'
            * Keep all features.

もっと削れる - OpenType features

この時点でも大幅に削れてはいますが、文字量に対して少し容量が大きくないか?と直感的に思ったので調べてみたところ、複数のOpenTypeの機能がデフォルトで有効になっていました。

$ pyftsubset ./assets/fonts/noto-serif.otf --layout-features='?'
Current setting for 'layout-features' is: ['abvf', 'abvm', 'abvs', 'akhn', 'blwf', 'blwm', 'blws', 'calt', 'ccmp', 'cfar', 'cjct', 'clig', 'cswh', 'curs', 'dist', 'dnom', 'fin2', 'fin3', 'fina', 'frac', 'half', 'haln', 'init', 'isol', 'kern', 'liga', 'ljmo', 'locl', 'ltra', 'ltrm', 'mark', 'med2', 'medi', 'mkmk', 'mset', 'nukt', 'numr', 'pref', 'pres', 'pstf', 'psts', 'rclt', 'rkrf', 'rlig', 'rphf', 'rtla', 'rtlm', 'rvrn', 'stch', 'tjmo', 'valt', 'vatu', 'vert', 'vjmo', 'vkrn', 'vpal', 'vrt2']

Font features or variants refer to different glyphs or character styles contained within an OpenType font. These include things like ligatures (special glyphs that combine characters like 'fi' or 'ffl'), kerning (adjustments to the spacing between specific letterform pairings), fractions, numeral styles, and a number of others.

OpenType font features guide | MDN

OpenTypeフォントには、合字やカーニングなどの情報が含まれており、Webサイトで使う場合は、CSSのfont-feature-settings4文字のキーワードを指定します。これ自体は素晴らしい機能で、日本語のフォントにプロポーショナルメトリクス(palt)などを指定している、きれいなWebサイトをよく見かけます。

しかし今回は、明朝を使いたいけど容量は抑えたいという複雑な気持ちだったので、 layout-featuresはすべて削りました。このおかげで、約40%軽くできたので満足です。

形式 容量
WOFF 39KB → 23KB
WOFF2 35KB → 22KB
package.json
{
  "scripts": {
    "font:woff": "pyftsubset ./assets/fonts/noto-serif.otf  --layout-features='' --flavor=woff --text-file=./scripts/subset.txt --output-file=./assets/fonts/noto-serif-sub.woff",
    "font:woff2": "pyftsubset ./assets/fonts/noto-serif.otf  --layout-features='' --flavor=woff2 --text-file=./scripts/subset.txt --output-file=./assets/fonts/noto-serif-sub.woff2"
  }
}

CSSで指定

苦労して生成した日本語のWebフォントをCSSで指定します。せっかく日本語のみを抽出したので、英数字には別のフォントをあててみました(せっかく容量を削ったのに増やしてどうする)。やっぱり英数字のフォントは軽い(11.1KB)。

Nuxt.jsにデフォルトで設定されているwebpack > url-loader > file-loaderのおかげで、フォントのファイル名が[hash:7].[ext]で書き出されるので、キャッシュ対策に頭を抱えずに済みます。ありがたや。

layouts/default.vue
<style lang="scss">
/*!
 * Noto Serif CJK JP licensed under the SIL Open Font License
 * https://www.google.com/get/noto/
 */
@font-face {
  font-family: "Noto Serif";
  font-style: normal;
  font-weight: 400;
  src: url(~assets/fonts/noto-serif-sub.woff2) format("woff2"), url(~assets/fonts/noto-serif-sub.woff) format("woff");
}

body {
  font-family: "Roboto Slab", "Noto Serif", serif;
}
</style>
nuxt.config.js
module.exports = {
  head: {
    link: [
      {
        rel: 'stylesheet',
        href: 'https://fonts.googleapis.com/css?family=Roboto+Slab:300'
      }
    ]
  }
}

静的サイトを生成して完成

screenshot.jpg
デモページ

$ yarn generate

CircleCIでやる

これで、コマンドを実行したらフォントを生成できるようになりましたが、毎回実行するのは面倒なので、CircleCIでサブセットを生成するように設定しました。PythonとNode.jsが使えるDockerイメージを選んで、ずらずらコマンドを実行します。 実際は、yarn generate したあとにデプロイしていますが、そこは省略しています。

.circleci/config.yml
version: 2

jobs:
  build:
    working_directory: ~/app
    docker:
      - image: circleci/python:3.6-jessie-node-browsers
    steps:
      - checkout
      - restore_cache:
          name: Restore Yarn Package Cache
          keys:
            - yarn-packages-{{ checksum "yarn.lock" }}
      - run: yarn install
      - save_cache:
          name: Save Yarn Package Cache
          key: yarn-packages-{{ checksum "yarn.lock" }}
          paths:
            - ~/.cache/yarn
      - run: sudo pip install fonttools brotli
      - run: yarn font:subset
      - run: yarn font:woff
      - run: yarn font:woff2
      - run: yarn generate

おわり

デモで作ったサイトは、都合よく文字量が少ないので容量をかなり削れましたが、それなりの規模のサイトになると大量の日本語のサブセットが必要になるので、そこまでの効果が見込めず、労力に見合わないかもしれません。

また、シンプルな静的サイトの場合は文字が決まっていますが、動的にコンテンツを取得する部分がある場合は、この方法は使えないです。というよりも、使えるケースの方が稀だと思います。

リピーターが多いWebサイトの場合は、絞ったサブセットを頻繁に更新するよりも、多くの日本語をカバーしたサブセットでフォントを作成して、キャッシュを活かした方が良さそうなのが悩みどころ。

GitHub
https://github.com/noplan1989/nuxt-mincho

22
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
22
Help us understand the problem. What is going on with this article?