色なし絵文字に色をつけよう

こんにちは!エンジニアの藤井(touyou)です! 今回はFlutterのアプリ開発中に見つけた、ちょっとした小ネタの紹介になります。

現在、自分が携わっているFlutterアプリでは、個人的な裏テーマとしてiOSとAndroidをできる限りネイティブ志向で作り上げるということを大事にしており年末にもそれに関する記事を書きました。

goodpatch-tech.hatenablog.com

今回はそんな開発の中で出てきたフォントにまつわるお話をしていこうと思います。

何があったのか?

では何があったのか?というところをまず説明していこうと思います。 そのアプリではフォントの設定をiOSではシステムフォント(Flutterの内部でSFフォントを呼び出している想定、ただしうまく動いていない課題はあります)、AndroidではNoto Sans JPを使って作っていました。

こちら、アプリ動作においては基本問題ないのですが、何も考えずに指定していると次のような表示になります。

何がおかしいのか?注目してほしいのは以下の画像の赤丸で囲った部分です。

特にiOSだとわかりやすいのですが、本来ならば色付きの太陽や祝の絵文字が文字の中に入ると白黒のものになってしまっています。 大きな課題ではなかったものの、ずっとひっかかっていたのでこちらを今回解決していきました。

前提

前提として、どういう実装をしていたのかを紹介します。 このアプリではTheme Extensionを使って次のように実装していました。

class AppTheme {
  static ThemeData light(bool isIOS) {
    final defaultTheme = ThemeData.light(useMaterial3: true);
    final textThemeExtension = _lightTextTheme(isIOS);
    return defaultTheme.copyWith(
      extensions: [
        textThemeExtension,
    ]);
  }

  static AppTextThemeExtension _lightTextTheme(bool isIOS) {
    return isIOS ? _lightDefaultTextTheme : _lightNotoSansTextTheme;
  }
  
  static final _lightDefaultTextTheme = AppTextThemeExtension(
    medium: const TextStyle(
      fontSize: 16,
      height: 1.4,
    ),
    mediumBold: const TextStyle(
      fontSize: 16,
      height: 1.4,
      fontWeight: FontWeight.bold,
    ),
  );

  static final _lightNotoSansTextTheme = AppTextThemeExtension(
    medium: GoogleFonts.notoSansJp(
      fontSize: 16,
      height: 1.4,
    ),
    mediumBold: GoogleFonts.notoSansJp(
      fontSize: 16,
      height: 1.4,
      fontWeight: FontWeight.bold,
    ),
  );
}

似たようなところは割愛していますが、これを修正していきます。

iOS側を直す

まずはiOS側を直していきます。FontFamilyを指定していなければシステムフォントになるためその性質を活用していたのが元々のコードでした。ですが絵文字を採用してもらうにはfontFamilyFallbackを指定する必要があり、これを指定すると今度はfontFamilynullの際に全てフォールバックされてしまうのでこの二つを同時に指定する、というのがiOS側の解決策になります。

システムフォントは.AppleSystemUIFontとしておけばとってきてくれるそうなのでこちらを使って以下のように書き直していきます。

static const _appleSystemFont = '.AppleSystemUIFont`;
static const _appleEmojiFont = 'Apple Color Emoji';
static final _lightAppleTextTheme = AppTextThemeExtension(
  medium: const TextStyle(
    fontFamily: _appleSystemFont,
    fontFamilyFallback: [_appleEmojiFont],
    fontSize: 16,
    height: 1.4,
  ),
  mediumBold: const TextStyle(
    fontFamily: _appleSystemFont,
    fontFamilyFallback: [_appleEmojiFont],
    fontSize: 16,
    height: 1.4,
    fontWeight: FontWeight.bold,
  ),
);

これでAppleの方は解決します🎉

Android側の作戦を考える

続いてAndroid側です。Notoシリーズの色付き絵文字としてはNoto Color Emojiがあるため、Androidも同様にフォールバックすれば良さそうです。試しにその方針で書いてみると次のようになります。

static const _notoEmojiFont = GoogleFonts.notoColorEmoji().fontFamily!;
static _lightNotoSansTextTheme = AppTextThemeExtension(
  medium: GoogleFonts.notoSansJp(
    fontSize: 16,
    height: 1.4,
  ).copyWith(
    fontFamilyFallback: [_notoEmojiFont],
  ),
  mediumBold: GoogleFonts.notoSansJp(
    fontSize: 16,
    height: 1.4,
    fontWeight: FontWeight.bold,
  ).copyWith(
    fontFamilyFallback: [_notoEmojiFont],
  ),
);

GoogleFonts.notoSansJpの引数にはfontFamilyFallbackは用意されていないのでcopyWithを使います。 しかしこれでも表示は変わりません。Noto Sans JPにも該当の絵文字に対応するものが存在するのでフォールバックが起こらないのです。

そのためどうにかして絵文字だけをフォールバックさせるというのが考える作戦になります。

💡 コラム

copyWithを使わなければいけないということで、それなら書き方としてfontFamily: GoogleFonts.notoSansJp().fontFamilyとすればいいのではないか?というアイデアもありますが、これは避けたいところです。
なぜかというと、このようにfontFamilyプロパティのみを抜き出し指定した場合、引数としてfontFamilyに渡されるのはNotoSansJP_Regularという文字列になるからです。結果、特にパフォーマンス最適化のためフォントファイルをローカルに埋め込んでいる場合はウェイトの変更がうまく効かず以下のように字体が一部潰れてしまいます。 GoogleFontsのライブラリ側が引数で渡されたウェイトによってどれをプライマリのフォントファミリーに指定するのかの内部処理を行っているので、しっかりcopyWithを使って書きましょう。

Android側を解決する

ではフォントをどのようにフォールバックすればいいのでしょうか?
ヒントはFlutterのGoogle Fontsライブラリのアセット活用機能にあります。

どういうことかというと、FlutterのGoogle Fontsライブラリはインターネットからフォントを取ってくるのはもちろんのこと、インターネットが使えない環境でも使えるようにassets/google_fontsにあるフォントファイルを優先して使ってくれるという機能があります。
これは今回のようなケースでなくともよく使われており、アプリサイズを減らすためにサブセット化したフォントを入れるということをやっている人も多いです。

今回扱っているアプリはSNSのようにユーザー生成コンテンツを表示するためなるべく対応文字を減らしたくはありません。一方で絵文字だけはNoto Color Emojiが使えればOKです。
そこで自分はNoto Color Emojiの対応文字だけ省いたサブセットを用意すればいいのではないか?と考えました。

結果どのように行なったのかをステップごとにみていきましょう。

Step 0. fonttoolsをインストール

フォントのサブセットを作るにも、対応文字を知るためにも欠かせないfonttoolsをまずインストールします。

$ pip install fonttools

Step 1. Google Fontsから該当フォントのダウンロード

続いて元となるフォントファイルをダウンロードします。
今回はNotoシリーズのNoto Sans Japanese - Google FontsNoto Color Emoji - Google Fontsをダウンロードしました。

このうち必要なのはNotoColorEmoji-Regular.ttfNotoSansJP-Regular.ttfそしてNotoSansJP-Bold.ttfの三つなのでこの三つをassets/google_fontsに移動します。

今後の作業用にNoto Sans JPの二つは複製しておくと事故を防げていいかなと思います。

Step 2. 対応文字を取り出す

続いてそれぞれのフォントファイルから対応文字を取り出していきましょう。

こちらに関してはPerplexityに聞いたPythonコードを用いて行います。スクリプトでもインタプリタ上でも動くかなと思います。

from fontTools.ttLib import TTFont

def list_supported_characters(font_path):
  font = TTFont(font_path)
  cmap = font['cmap'].getBestCmap()
  supported_chars = [chr(code) for code in cmap.keys()]
  return supported_chars

supported_chars = list_supported_characters("NotoSansJP-Regular-original.ttf")
supported_emoji = list_supported_characters("NotoColorEmoji-Regular.ttf")

Step 3. サブセット用のデータセットを作る

次にサブセットを実際に作っていくのですがここで注意することがあります。
もちろんNoto Color Emojiの対応文字は省いていきたいのですが、実際にsupported_emojiを書き出してみると以下のような結果になります。

>>> print(supported_emoji)
[' ', '#', '*', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'©', '®', '\u200d', '‼', '⁉', '⃣', '™', 'ℹ', '↔', '↕', '↖', '↗', '↘',
'↙', '↩', '↪', '⌚', '⌛', '⌨', '⏏', '⏩', '⏪', '⏫', '⏬', '⏭', '⏮',
'⏯', '⏰', '⏱', '⏲', '⏳', '⏸', '⏹', '⏺', 'Ⓜ', '▪', '▫', '▶', '◀',
'◻', '◼', '◽', '◾', '☀', '☁', '☂', '☃', '☄', '☎', '☑', '☔', '☕', '☘',
'☝', '☠', '☢', '☣', '☦', '☪', '☮', '☯', '☸', '☹', '☺', '♀', '♂', '♈',
'♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '♟', '♠', '♣',
'♥', '♦', '♨', '♻', '♾', '♿', '⚒', '⚓', '⚔', '⚕', '⚖', '⚗', '⚙', '⚛', 
'⚜', '⚠', '⚡', '⚧', '⚪', '⚫', '⚰', '⚱', '⚽', '⚾', '⛄', '⛅', '⛈', 
'⛎', '⛏', '⛑', '⛓', '⛔', '⛩', '⛪', '⛰', '⛱', '⛲', '⛳', '⛴', 
'⛵', '⛷', '⛸', '⛹', '⛺', '⛽', '✂', '✅', '✈', '✉', '✊', '✋', '✌', 
'✍', '✏', '✒', '✔', '✖', '✝', '✡', '✨', '✳', '✴', '❄', '❇', '❌', 
'❎', '❓', '❔', '❕', '❗', '❣', '❤', '➕', '➖', '➗', '➡', '➰', 
'➿', '⤴', '⤵', '⬅', '⬆', '⬇', '⬛', '⬜', '⭐', '⭕', '〰', '〽', 
'㊗', '㊙', '🀄', '🃏', '🅰', '🅱', '🅾', '🅿', '🆎', '🆑', '🆒', '🆓', 
'🆔', '🆕', '🆖', '🆗', '🆘', '🆙', '🆚', '🇦', '🇧', '🇨', '🇩', '🇪', 
'🇫', '🇬', '🇭', '🇮', '🇯', '🇰', '🇱', '🇲', '🇳', '🇴', '🇵', '🇶', 
'🇷', '🇸', '🇹', '🇺', '🇻', '🇼', '🇽', '🇾', '🇿', '🈁', '🈂', '🈚', 
'🈯', '🈲', '🈳', '🈴', '🈵', '🈶', '🈷', '🈸', '🈹', '🈺', '🉐', '🉑', 
'🌀', '🌁', '🌂', '🌃', '🌄', '🌅', '🌆', '🌇', '🌈', '🌉', '🌊', '🌋', 
'🌌', '🌍', '🌎', '🌏', '🌐', '🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', 
'🌘', '🌙', '🌚', '🌛', '🌜', '🌝', '🌞', '🌟', '🌠', '🌡', '🌤', '🌥', 
# ...(割愛)

特に問題なのが前半の空白と数字たちです。これまでNoto Color Emojiが適用されてしまうと通常の数字より少し幅の広い数字がレンダリングされてしまい、表示が崩れてしまいます。

ではどこまでをNoto Color Emojiにするか?はなかなかに判断が難しい部分ではありますが、一旦数字まで省いていれば十分であろうということで自分はそれ以外を省いたリストを作ることにしました*1

supported_emoji = supported_emoji[13:]
result = list(filter(lambda x: x not in supported_emoji, supported_chars))

これで必要な対応文字表ができたのでサブセットのデータとして扱えるようテキストファイルに書き出しておきます。

with open("characters.txt", "w", encoding="utf-8") as f:
  f.write("".join(result))

Step 4. 実際にサブセットフォントファイルを作る

ここまできたらあとはサブセット化です。ウェイトごとの対応文字の差はないだろうということで、今回は作ったファイルをBoldとRegularの両方に適用する形で対応しました。

$ pyftsubset NotoSansJP-Regular-original.ttf --text-file=characters.txt --output-file=NotoSansJP-Regular.ttf
$ pyftsubset NotoSansJP-Bold-original.ttf --text-file=characters.txt --output-file=NotoSansJP-Bold.ttf

これでお目当てのフォントファイルが作れました。

結果

それでは以上の対策を行なったアプリを、実際に実行してみましょう。

晴れてiOSとAndroidに色のある世界がやってきました💯

まとめ

以上、Flutterで絵文字を意図通りに出す方法をご紹介しました。
人によって必ずしもこの手段を取らないといけないというわけではないかと思いますが、見逃されやすい話でもあるかなと思うので少しでもデザインにこだわりたいFlutterエンジニアのもとに届けば幸いです。

直近ではCupertinoSheetRouteがmainチャンネルにマージされたりとネイティブ体験を目指す上で役立つアップデートもより増えてきています。
いますぐ全てを完璧にできるわけではないですが、こういったアップデートも積極的に活用してより良いFlutterアプリを目指していきたいですね。


Goodpatchではデザイン好きなエンジニアの仲間を募集しています。 少しでもご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!

*1:2025/2/14追記:あとから気づいたのですがコピーライトマーク©️はアプリ内でかなりの確率で使われるので元のフォントにしておくのが良さそうです。