Skip to content

Instantly share code, notes, and snippets.

@clicube
Last active February 27, 2024 01:05
Show Gist options
  • Save clicube/6047816 to your computer and use it in GitHub Desktop.
Save clicube/6047816 to your computer and use it in GitHub Desktop.
アイコンを140文字にする話

アイコンを140文字にする話

なに?

この前ドット絵のアイコン書いたんですよ、こんなの。

@kyubing

結構うまくできたかなって満足してます。

それで、このアイコン、16x16なので、そのくらいならTwitterに140文字にして投稿できないかなって思ったんです。

そういうお話。

ルールと方針を決める

バイナリデータである画像を文字列に圧縮するにあたって、次のルールを決めました。

  • 140文字に納めること(バイト数は問わず = マルチバイト文字も1文字)
  • 一般に使われてる処理系(Rubyなど)やエンコード方式(Base64など)は前提にしてもよい
  • それ以外のエンコード方式などは、デコード処理も含めて140文字以内に納める

これをふまえて、方針を立てます。 スタート時点で、画像のサイズは16x16のpngで180バイト程度です。

単純にBase64方式でエンコードする方法を最初に考えました。 でも、Base64はデータ量が133%になる = 105バイト以下のデータの必要がある のでダメです。

じゃあやっぱりマルチバイト文字が使えるってことを利用するしかないねってことで、次の方針を立てました。

  1. 元データの画像をできるだけ圧縮する
  2. マルチバイト文字を使って1文字にできるだけ多くの文字を詰め込む。
  3. デコード処理をできるだけ短く書く

それでは、実際に進めていきます。

画像を圧縮する

はじめに、画像をできるだけ小さく圧縮します。

画像形式はPNGとGIFの両方について、画像サイズ圧縮のwebサービスを使いました。 その結果、最もサイズが小さくなったのはGIFファイルを

http://tools.dynamicdrive.com/imageoptimizer/index.php

で圧縮した場合でした。

これで画像サイズが142バイトになりました。

icon

マルチバイト文字を使ってバイナリを圧縮する

UTF-8のことを調べる

文字列への変換において必要な条件は以下の2点です。

  • (Twitterにポストできるような)有効な文字である
  • 任意のデータを表すことができる

まずは一般的なエンコード方式ってことで UTF-8 について調べます。

わかったことを以下に示します。

  • 規格として表現できる範囲は0x000000〜0x10FFFF
  • 割り当てられている領域は連続していない(抜けがある)
  • ほとんどの領域(0x01FFFF〜0x10FFFF)は "Supplementary Private Use Area" (何が入ってるんだ・・・)
  • 0xFEFFなど非文字が存在する(0x0000(NUL)などの制御文字もありますね・・・)

これらをふまえると、単純に例えば0x0000〜0xFFFF(2バイト)の領域をそのまま使ってエンコードするのは難しく、 一部の領域に限定して使用する必要がありそうです。

圧縮に使う文字領域を選ぶ

さきほどの、文字の割当領域リストから、一番広いものを探してみると、

4E00..9FFF; CJK Unified Ideographs

というものがあります。漢字エリアです。この中に無効な文字が含まれている可能性はありましたが、 領域を一つにしぼってとりあえず圧縮してみます。

この範囲は、末尾から先頭を引いて

0x9FFFF - 0x4E00 = 0x51FF

なので、

0x51FF > 0x3FFF = 0b 0011 1111 1111 1111 (14bit)

ということで14bitは確保できそうです。

14bitの範囲で文字に圧縮する

それでは実際に圧縮のコードを書いていきます。 私はRubyを使いました。

コードの流れとしては、

  1. データを読み込む
  2. 14bitごとにチャンクに分割する
  3. 14bitのチャンクを 0x4E00 から14bitの範囲に変換(単純に0x4E00を足せばよい)
  4. その値をUTF-8にエンコードする(14bitの値が1文字になる)
  5. すべての14bitチャンクに対して3-4の処理を繰り返す
  6. 圧縮結果を出力する

となります。

data = File.open(ARGV[0]){|f| f.read } # ファイルの読み込み

bit_str = data.unpack("B*")[0]         # 1bitずつ0/1で表された文字列に変換

encoded_data =
  bit_str.split(//)
  .each_slice(14)                        # 14bitずつ処理
  .map{|arr| arr.join.to_i(2) + 0x4e00 } # 0/1の文字列を数値に変換し0x4E00を足す
  .pack("U*")                            # UTF-8エンコード
  .join

puts encoded_data

これで、

忒扣滥漐丄丏嘀一一呵歃捚松尘跷哅跿跰一一一借爐伀一丂縀一丄丁一七拊墭凸渄嚆故徙翛
劖稅茑坼嚬兆柮堦埱味甝乱痮疾曵沰坊嵈礚潍皌脧洭亲縕嘪彐硶殄绊椛綑峄噼筴掠褋址浱唂与七

という文字列に画像データを変換できました。(適宜改行しています) これで82文字です。

次にデコードの処理を書いてみましょう。

デコードの処理を書く

デコードの処理はエンコードの処理を逆にたどるだけですので、以下の通りです。

  1. 文字列を1文字ずつUTF-8としてデコードする(数値が得られる)
  2. 得られた数値から0x4E00を引いて14bitのチャンクを得る(数値は14bitの範囲に収まる)
  3. それぞれの文字について同様に14bitのチャンクを取り出し、結合する
  4. データを出力する
str = "忒扣滥漐丄丏嘀一一呵歃捚松尘跷哅跿跰一一一借爐伀一丂縀一丄丁一七拊墭凸渄嚆故徙翛劖稅茑坼嚬兆柮堦埱味甝乱痮疾曵沰坊嵈礚潍皌脧洭亲縕嘪彐硶殄绊椛綑峄噼筴掠褋址浱唂与七"

data01 =
  str
  .unpack("U*")                # 文字ごとにUTF-8としてデコード
  .map{|n| n-0x4e00 }          # 得られた値から0x4E00を引く
  .map{|n| sprintf("%14b", n } # 14bit長の0/1文字列に変換
  .join                        # 14bit長の0/1データを結合
data = [data01].pack("B*")      # 0/1文字列をバイナリに変換

print data

これで元のデータを取り出すことができました。やったー!

$ ruby icon.rb > icon.gif

とすることでアイコンのGIF画像が得られます。

あとは、このコードをできるだけ短くしていきます。

  • 変数代入をなくして、2つのmapは統合
  • sprintf の代わりに String#%
  • 0x4e00 は10進表記の 19968

実際に適用した結果がこちら。

print ["忒扣滥漐丄丏嘀一一呵歃捚松尘跷哅跿跰一一一借爐伀一丂縀一丄丁一七拊墭凸渄嚆故徙翛劖稅茑坼嚬兆柮堦埱味甝乱痮疾曵沰坊嵈礚潍皌脧洭亲縕嘪彐硶殄绊椛綑峄噼筴掠褋址浱唂与七".unpack("U*").map{|n|"%14b"%[n-19968]}.join].pack('B*')

これで146文字です。 目標の140文字にはまだ届きませんが、"ファイルをデコード処理を含んだ文字列に変換する"ということは実現できました!わーい!

140文字に納めたい

140字に納めるにはもっと短くする必要がありますが、デコード処理の部分はもうムリ。。。 データ部分をどうにか短くできないか考えます。

エンコード後の文字列は80文字程度。無作為にUnicodeの範囲から選んだとしても、有効な文字が存在する確率ってとても高そうですね。

さっきまでのコードでは、 元のデータを14bitごとに 0x4E00 シフトして、UTF-8としてエンコードしました。 ここで、元のデータを15bitごとに分割しても、"ある適当な量" シフトさせれば運良く全部のチャンクが有効な文字に当たるのでは??と考えました。

そこで、チャンクの長さを15bitに変え、試行錯誤でいろいろシフト量を変えてみました。

その結果、シフト量 0x5000 で、すべての文字が有効な文字列になり、ちょうど140文字に納まりました!!!目標達成!!

print ['玤ꆎ圬愀傀又倀倀倳ꗔ檫癟霌迷嶋쿿쾀倀倀倂忈怄倀倀昀倀倂倁倀倍癑竐켐儈錮镆莎뱉薀굄璾墬嚌랸ꄱ漑鹳靀裏뺞쳇ꗫ儩珒斍煍ꄙ鲝쥨嬬劤媑硔왶妆禱귲掱吾絴筁밬騃윑끀倻'.unpack('U*').map{|n|'%15b'%[n-20480]}.join].pack('B*')

※もちろん、元のデータが変われば適したシフト量は変わってしまいます。 この後、すべての文字が有効な文字になるまでシフト量を変えて試すスクリプトを書きました。

もっと短くしたい

もっと短くするには、チャンク長を16bitにするしかありません。

シフト量を変えて試すスクリプトを使って、16bitチャンクに適したシフト量を探しました。 その結果、適したシフト量の一つとして 0x0025 が挙げられました。 エンコード後の文字列が以下です。

䝮䙝㦆ဥဥ%%杼僺媊句逢웪𐀤E%%F朗ĥ%Q%%ဥဥ(卍ꫵ﹅чᦙ咋爀ቿ삂䑮
籇끙暠詋⟩杞읥燄몠廕╍웆䷇㍗矰䃗쁹苉呏皛ጱꛫ㬵蟰崺ꄑⲹࠁ䜧`

71文字です・・・!!

しかも、チャンクが16bitということで、デコードの処理も簡単になります。

print '䝮䙝㦆ဥဥ%%杼僺媊句逢웪𐀤E%%F朗ĥ%Q%%ဥဥ(卍ꫵ﹅чᦙ咋爀ቿ삂䑮籇끙暠詋⟩杞읥燄몠廕╍웆䷇㍗矰䃗쁹苉呏皛ጱꛫ㬵蟰崺ꄑⲹࠁ䜧`'.unpack('U*').map{|n|n-37}.pack('n*')

116文字まで短くなりました!!!!!!!

http://twitter.com/kyubing/status/358186039858307073

2015/7/24 動作確認 https://twitter.com/kyubing/status/624462346581180417

おわりに

これを読んだ方は、自分のアイコンも140文字化したくなったことでしょう? まずは小さいアイコンを作るのです・・・!

最後に、これをやっているときに助言をくれた各位ありがとうございました。

実際のスクリプトなどはこちらにあります: https://gist.github.com/clicube/6031642

@kyubing

http://twitter.com/kyubing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment