ラスター画像フォントを作ってターミナルで表示する実験（その3、Misskeyカスタム絵文字のフォントを生成する）

この記事は前回と前々回の続きです。
https://qiita.com/vipper36/items/22169594f6546a9c01b9
https://qiita.com/vipper36/items/2dd7fcda17a51701dad0

いよいよ本命としてMisskey端末クライアント向けに（ほぼ）全部のカスタム絵文字が入ったMisskey外字フォントを生成するコードを書いてみました。今回はMisskey最大手のMisskey.ioサーバー向けとします。

なお生成される外字フォントは画像の高さ別に265個となります。本当はOpenTypeのsbix仕様のStrikeを複数作って一つのフォントに収められたら良かったのですが、何か上手く行かなかったので妥協しました。代わりにフォントコレクションのOTCにすると良いかもしれませんが、自分には不要であるため今回は省略します。

それとMisskeyカスタム絵文字は一部がパブリックドメインなのですが、他の絵文字のライセンスは良く分からないため生成したフォント自体は配布しません。その代わり生成するコードを置いておくので自分と同じように端末クライアントを作ってる方などは自分で生成していただけると幸いです。

```python|generate_misskey_emoji_font.py
import requests

# https://friendsyu.me/api-doc#operation/emojis より
response = requests.get("https://misskey.io/api/emojis") # Misskey.io以外ならここを変える

import orjson
emoji_dict = None
emojis_root = orjson.loads(response.text)
emoji_lists =  emojis_root["emojis"]
emoji_dict = {i["name"]:i["url"] for i in emoji_lists} # aliasesは検索用で本文には使えない？
emoji_url_list = [i["url"] for i in emoji_lists]

import os
if not os.path.exists("icons"):
    os.makedirs("icons")

import multiprocessing
import psutil

# Function to download a file
def download_file(url):
    try:
        file_name = "./icons/" + url.split("/")[-1]
        if os.path.exists(file_name):
            return (0, f"Skipped file {file_name} as it already exists.")

        response = requests.get(url)
        if response.status_code == 200:
            with open(file_name, 'wb') as f:
                f.write(response.content)
            return (0, f"Downloaded {file_name}")
        else:
            return (-1, f"Failed to download {url} - Status code: {response.status_code}")
    except Exception as e:
        return (-1, f"Failed to download {url} - {str(e)}")

num_cpus = multiprocessing.cpu_count()

#emoji_url_list = emoji_url_list[0:20] # XXX: for debug

child_process_ids = []

for cpu_id in range(num_cpus): # Fork processes
    cpid = os.fork()
    if cpid: # In the parent process
        child_process_ids.append(cpid)
    else:  # In the child process
        os.sched_setaffinity(0, [cpu_id])
#        print("%s :::: %s"%(cpu_id, emoji_url_list[cpu_id::num_cpus]))

        for url in emoji_url_list[cpu_id::num_cpus]:
            err, result = download_file(url)
            if err:
                print(result)

# Failed to download https://s3.arkjp.net/misskey/ca66f316-da7f-47c4-85be-dfbab6e06893.png - Status code: 404
# Failed to download https://s3.arkjp.net/misskey/92917eb9-9ed6-44f4-9255-241b3508f6a2.png - Status code: 404
# Failed to download https://s3.arkjp.net/misskey/0d7b68c6-22bd-4e96-a683-2b108ff3d131.png - Status code: 404

        os._exit(0)  # Exit the child process

download_results = {}
for pid in child_process_ids: # Wait for child processes to finish and get exit status
    _, exit_status = os.waitpid(pid, 0)
    if os.WIFEXITED(exit_status):
        download_results[pid] = os.WEXITSTATUS(exit_status)

for pid, exit_code in download_results.items(): # Check download results
    if exit_code == 0:
        print(f"Download process {pid} completed successfully.")
    else:
        print(f"Download process {pid} failed with exit code {exit_code}")

emoji_file_list = {i:("./icons/" + j.split("/")[-1]) for (i,j) in emoji_dict.items()}
name_to_char = {}

# pip install python-magic
import magic
m = magic.Magic()
#m_mime = magic.Magic(mime=True) # animated/gif is unsupported
import images2font2

i = 0x00100000 + 339
font_images = {}
def add_font_images(key, val_dict):
    global font_images
    if key not in font_images:
        font_images[key] = {}
    font_images[key] |= val_dict

from PIL import Image, ImageSequence
from io import BytesIO
import math

## XXXXX: per-height multiple strikes is not working?
for name, path in emoji_file_list.items():
    if not os.path.exists(path):
        print("File %s is not exsists so the download was failed for unknown reason..."%path)
        continue
    fformat = m.from_file(path)
    if "PNG "in fformat:
        img = Image.open(path)
        add_font_images(img.height, {i: path})
        name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
        i += 1
    elif "JPEG "in fformat:
        img = Image.open(path)
        add_font_images(img.height, {i: (path, "jpg ")})
        name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
        i += 1
    elif "GIF "in fformat or "Web/P "in fformat:
        if "YUV" in fformat:
            print("YUV Web/P is not supported") # TODO: will try to convert to YUV TIFF
            continue
        src_data = open(path, 'rb').read()
        src_io = BytesIO(src_data)
        src_image = Image.open(src_io)
        if src_image.is_animated:
            frames = [frame.copy() for frame in ImageSequence.Iterator(src_image)]
            png_images = []

            for frame in frames:
                png_io = BytesIO()
                frame.save(png_io, format='PNG')
                add_font_images(frame.height, {i: png_io.getvalue()})
                name_to_char |= {name: [i, math.ceil(frame.width/(frame.height/2))]}
                i += 1
        else:
            png_io = BytesIO()
            src_image.save(png_io, format='PNG')
            add_font_images(src_image.height, {i: png_io.getvalue()})
            name_to_char |= {name: [i, math.ceil(img.width/(img.height/2))]}
            i += 1
    elif "SVG "in fformat:
        print("SVG is not supported") # TODO: will try to convert to OpenType-SVG
    else:
        print("File &s is unrecognized format %s"%(path, fformat))

#print(name_to_char)
import pickle
with open('misskey_name_to_char.pkl', 'wb') as f:
    pickle.dump(name_to_char, f)

import os
if not os.path.exists("fonts"):
    os.makedirs("fonts")

for i, fontimgs in font_images.items():
    images2font2.images2font(fontimgs, "MisskeyCustomEmojiFont-%s"%i, "ミスキーカスタム絵文字フォント-%s"%i, font_path="./fonts/misskey-%s.otf"%i, cache_update=False)

import os
os.system("fc-cache -f")
```

```python|images2font2.py
from fontTools.ttLib import TTFont
from fontTools.fontBuilder import FontBuilder
from fontTools.ttLib.ttFont import newTable
from fontTools.ttLib.tables._s_b_i_x import table__s_b_i_x
from fontTools.ttLib.tables import sbixGlyph, sbixStrike
from fontTools.pens.t2CharStringPen import T2CharStringPen

# everything should work:
# font_images = {0x10FFFF: img_path}}
# font_images = {0x10FFFF: img_data}}
# font_images = {0x10FFFF: (img_path, graphicType in sbix like "png ")}
# font_images = {0x10FFFF: (img_data, graphicType in sbix like "png ")}
# XXX: 順序が保存されない？

# WARNING: ALL IMAGES SHOULD HAVE SAME HEIGHT
from PIL import Image
from io import BytesIO

def images2font(font_images={}, familyName_en = "Image2Font", familyName_ja = "Image2Font",
               font_path="./my_font.otf", styleName = "Monospace", version = "0.1", install=True, cache_update=True, img_width=1000, img_height=1000, ppem=256):
    fb = FontBuilder(unitsPerEm = 1000, isTTF=False)
    imgglyph_ids =  [hex(i) for i in font_images.keys()]
    glyphs = [".notdef", ".null", ".CR"] + imgglyph_ids
    fb.setupGlyphOrder(glyphs)
    fb.setupCharacterMap({k: hex(k) for k in font_images.keys()})
    advanceWidths = {".notdef": img_width, ".null": 0, ".CR": 0} | {i: img_width for i in imgglyph_ids}

    nameStrings = dict(
        familyName=dict(en=familyName_en, ja=familyName_ja),
        styleName=dict(en=styleName, ja="Mono"),
        uniqueFontIdentifier="fontBuilder: " + familyName_en + "." + styleName,
        fullName=familyName_en + "-" + styleName,
        psName=familyName_en + "-" + styleName,
        version="Version " + version,
    )


    # Create an sbix table
    sbix = newTable("sibx")
    sbix_table = table__s_b_i_x()

    #ppem = 12
    #ppem = 256

    sbix_strikes = {}
    for glyph_name, image in zip(imgglyph_ids, list(font_images.values())):
        image_data = None
        if type(image) is tuple:
            image = image[0]
            graphicType = image[1]
        else:
            graphicType = "png "

        if type(image) is str:
            with open(image, "rb") as image_file:
                image_data = image_file.read()
        else:
            image_data = image

        image = Image.open(BytesIO(image_data))

        if image.height not in sbix_strikes:
            sbix_strikes[image.height] = sbixStrike.Strike(ppem=image.height, resolution=72)
            sbix_table.numStrikes += 1

        sbix_strike = sbix_strikes[image.height]
        sbix_glyph = sbixGlyph.Glyph(glyphName=glyph_name, graphicType=graphicType, imageData=image_data) # referenceGlyphName=glyph_name,
        sbix_strike.glyphs[glyph_name] = sbix_glyph
        sbix_table.strikes[image.height] = sbix_strike


    fb.font["sbix"] = sbix_table


    pen = T2CharStringPen(1000, None)
    pen.moveTo((0, 0))
    pen.lineTo((0, img_height))
    pen.lineTo((img_width, img_height))
    pen.lineTo((img_width, 0))
    pen.closePath()

    charString = pen.getCharString()
    charStrings = {
        ".notdef": charString,
        ".null": charString,
        ".CR": charString,
        } | {i: charString for i in imgglyph_ids}


    fb.setupCFF(nameStrings["psName"], {"FullName": nameStrings["psName"]}, charStrings, {})
#    lsb = {gn: cs.calcBounds(None)[0] for gn, cs in charStrings.items()}
#    lsb2 = {gn: cs.calcBounds(None)[1] for gn, cs in charStrings.items()}

    h_metrics = {}
    for gn, advanceWidth in advanceWidths.items():
    #    metrics[gn] = (0, 0)
    #    h_metrics[gn] = (advanceWidth, lsb[gn])
        h_metrics[gn] = (advanceWidth, 0)
    fb.setupHorizontalMetrics(h_metrics)

    v_metrics = {}
    for gn, advanceWidth in advanceWidths.items():
    #    v_metrics[gn] = (1000, lsb2[gn])
        v_metrics[gn] = (1000, 0)
    fb.setupVerticalMetrics(v_metrics)

    fb.setupHorizontalHeader(ascent=img_height, descent=0) # ascent=824, descent=200
    fb.setupVerticalHeader(ascent=img_width, descent=0) # ascent=824, descent=200
    fb.setupNameTable(nameStrings)
    fb.setupOS2(sTypoAscender=img_height, sTypoDescender=0, usWinAscent=img_height, usWinDescent=0)
    fb.setupPost()
    #fb.setupDummyDSIG()

    fb.save(font_path)

    if install == True:
        import os
        os.system("cp %s ~/.local/share/fonts/" % font_path)
    if cache_update == True:
        import os
        os.system("fc-cache -f")
```

なお misskey_name_to_char.pkl には {"名前": [文字番号, 半角換算の必要文字幅], ...} が入っています。

それとフォントのグリフ幅は画像幅を無視して全角 (1000) としていますが、端末はそれを無視してこの文字範囲を半角扱いするようです。そのためMisskeyのテキストのカスタム絵文字コマンドを外字フォールバック対応の端末向けに変換して表示するコードはこんな感じになります。
```python
import pickle
misskey_name_to_char = None
with open("misskey_name_to_char.pkl", "rb") as pkl:
    misskey_name_to_char = pickle.load(pkl)

import re
def replace_custom_emoji(text):
    pattern = re.compile(':'+(':|:'.join(map(re.escape, misskey_name_to_char.keys())))+":")
    
    def replace(match):
        item = misskey_name_to_char[match.group(0)[1:-1]]
        return ""+chr(item[0]) + (" "*(item[1]-1))
        
    return pattern.sub(replace, text)

print(replace_custom_emoji(":misskey::mi_::misuhai::note::nyanpuppu::ablobaww:"))
```
![misskey_emoji2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109174/8530f077-13d9-4683-11e3-349f9452cb0e.png)