Unicodeの結合文字をスマートフォンで表示したときに発生した問題の対応策

こんにちは、MERY開発部のhoshinoです。

前回投稿した「制御文字が含まれていることで発生した問題の対応策」に引き続き、今回はUnicodeの結合文字をスマートフォンで表示したときに発生した問題の対応策*1紹介する内容は、技術書典6で頒布された「INSIDE MERY」の「6.5 Unicodeの結合文字による問題」と同じ内容です。について紹介します。

MERYの記事には、前回の投稿で紹介した制御文字と同様に、記事作成ツールを通して使用してほしくないものも含めてさまざまな文字が入り込んできます。Webサイト、iOSアプリ、Androidアプリを対象として記事を配信しているため、使用されている端末によっては表示崩れが発生してしまうこともあります。

ここでは、一部のAndroid端末において、図1のように濁点と半濁点がずれて表示されてしまう問題に対応した話を紹介します。

図 1 濁点と半濁点がずれる(アプリの表示例)

結合文字があることで発生する問題

一部のAndroid端末において、特定の記事の濁点と半濁点がずれているという報告を受け、Webサイト(PC)、iOSアプリ、Androidアプリの3種類を確認しましたが、私が検証した環境では問題なく表示されていました。そこで、検証用として十数台ある端末を使用して確認すると、1台のAndroid端末でだけ濁点と半濁点がずれる現象が発生しました。

表示に使用される文字列に問題がないことを確認するため、データベースに登録されている文字列を直接確認すると、「が」や「ポ」のように濁点と半濁点が使用される文字が二文字に分割されていました。Unicodeに詳しい方であれば、この時点で原因となりうることに気が付くのではないでしょうか。

結論から述べると、Unicodeには複数の文字を組み合わせて一文字を表現する方法があり、「が」の場合は「か」と「 ゙」の二文字を組み合わせることで「が」を表現できます。複数の文字を組み合わせて表現する文字を一文字として表示することに対応している端末では、表示上では一文字として表示されますが、非対応の端末では文字が分割された状態で表示されてしまい、場合によっては文字がずれて表示されてしまうこともあります。

複数の文字を組み合わせる文字は図2のように、基底文字結合文字を組み合わせて一文字として表示されます。

図 2 複数の文字を組み合わせた文字

表示上では一文字の「が」のように見えますが、文字コードで見ると図3のように、異なる文字であることがわかります。

図 3 表示上は同じでも文字コードが異なる文字

ここでは説明をわかりやすくするため、図3のように文字コード上では複数の文字で構成されている文字のことを結合文字列、単一の文字で構成されている文字のことを合成済み文字と呼ぶことにします。

対応策(結合文字列を正規化する)

前述のとおり、Unicodeには同じ文字を表現するための方法が複数あり、それによってさまざまな問題が発生します。そのため、Unicodeには文字を表現する方法を統一するための手法として、正規化するしくみが用意されています。

正規化には下記のように4種類の方式があります。*2参考文献: 『[改訂新版]プログラマのための文字コード技術入門』(矢野啓介 著、技術評論社、2018年)

方式名称
NFDNormalization Form Canonical Decomposition
NFCNormalization Form Canonical Composition
NFKDNormalization Form Compatibility Decomposition
NFKCNormalization Form Compatibility Composition

各方式の詳細な説明は省略しますが、図4のように、方式名にD(Decomposition)が付く場合は、文字が分解されて結合文字列として表現され、方式名にC(Composition)が付く場合は、文字が合成されて合成済み文字として表現されます。注意点としては、NFCによる正規化を行っても結合文字が全てなくなるわけではありません。複数の文字を合成できなかった場合は、結合文字はそのまま残ることになります。また、NFKDとNFKCにおいては、結合文字ではない文字が、結合文字に置き換えられる文字もあります。

図 4 文字の合成と分解

図5〜8の4つの図は、RubyでStringクラスのunicode_normalizeを用いて、同じ文字を各方式で正規化をした結果です。

図 5 正規化の各方式による違い(NFD)
図 6 正規化の各方式による違い(NFC)
図 7 正規化の各方式による違い(NFKD)
図 8 正規化の各方式による違い(NFKC)

図5と図7の①からは、NFDとNFKDで結合文字列を正規化しても、結合文字列のままであることがわかります。一方、図6と図8の①からは、NFCとNFKCで結合文字列を正規化すると、合成済み文字になることがわかります。

また、Unicodeには、合成用の濁点(U+3099)・半濁点(U+309A)と、合成用ではない濁点(U+309B)・半濁点(U+309C)があります。③と④では、それぞれの文字が正規化によってどのような変化をするのかを確認しています。図5と図6の③と④からは、NFDとNFCで濁点を正規化しても、正規化前と変わらないことがわかります。一方、図7と図8の③と④からは、NFKDとNFKCで合成用ではない濁点を正規化すると、結合文字に置き換えられ、文字の前に半角スペースが付与されることがわかります。

今回の問題は、結合文字があることで表示がずれてしまうことが原因のため、基底文字と結合文字を合成済みの一文字の文字に置き換えてくれるNFCかNFKCの方式を使用することになります。各方式の特徴を確認してからMERYで使用することになったのはNFCになりますが、ここでさらなる問題が発生します。正規化によって別の文字に置き換えられてしまう文字が存在しました。

リスト1のように、一部の漢字が別の漢字に置き換えられてしまいます。影響を受ける漢字が人名等に使用されていた場合、別の漢字に置き換えられた状態で記事が公開されてしまうため、文字を合成する以外の処理は行わないようにする必要があります。

[1] pry(main)> ['塚', '晴', '益', '神', '祥', '福', '靖', '精', '羽'].each do |v|
[2] pry(main)>   v_nfc = v.unicode_normalize(:nfc)
[3] pry(main)>   puts "#{v}(#{v.each_codepoint.map { |n| n.to_s(16) }}) => " \
[4] pry(main)>        "#{v_nfc}(#{v_nfc.each_codepoint.map { |n| n.to_s(16) }})"
[5] pry(main)> end
塚(["fa10"]) => 塚(["585a"])
晴(["fa12"]) => 晴(["6674"])
益(["fa17"]) => 益(["76ca"])
神(["fa19"]) => 神(["795e"])
祥(["fa1a"]) => 祥(["7965"])
福(["fa1b"]) => 福(["798f"])
靖(["fa1c"]) => 靖(["9756"])
精(["fa1d"]) => 精(["7cbe"])
羽(["fa1e"]) => 羽(["7fbd"])

そこで、リスト2のように、結合文字に濁点と半濁点を使用していて、合成できる可能性のある文字に対してだけ正規化をしてくれる処理をModuleとして作成し、データベースに登録する前にbefore_validationで正規化するようにしました。

# Concern
module UnicodeNormalizer
  extend ActiveSupport::Concern

  COMBINING_PATTERN = /[ゝうか-とは-ほヽウカ-トハ-ホワ-ヲ]゙|[は-ほハ-ホ]゚/

  included do
    def normalize_combining_characters(attributes = [])
      attributes.each do |attribute|
        if self[attribute].present?
          self[attribute].gsub!(COMBINING_PATTERN) do |val|
            val.unicode_normalize(:nfc)
          end
        end
      end
    end
  end
end

# Model
class Content < ActiveRecord::Base
  include UnicodeNormalizer

  # 正規化をしたい属性名のシンボルを配列で渡す
  before_validation -> { normalize_combining_characters([:title, :description]) }
end

MERYでは、複数のアプリケーションから結合文字がデータベースに登録されてしまう可能性があるため、Moduleを複製してアプリケーション毎に追加する必要がありました。そのため、Gemとして作成して、アプリケーションからincludeして使用しています。

また、リスト2の他に、結合文字が含まれているか確認する処理、正規化した文字列を返してくれる処理を含めることで、汎用的に使用できるようにしています。

ダイアクリティカルマーク

商品などを扱っているWebサイトであれば、ブランド名などにダイアクリティカルマークが使用されていることもあります。結合文字にダイアクリティカルマークを使用していて、合成できる可能性のある文字の組み合わせは、リスト3のように正規化することで異なる文字列になる組み合わせを抽出することで調べることができます。

[1]  pry(main)> chars1 = ('A'..'Z').to_a + ('a'..'z').to_a
[2]  pry(main)> chars2 = ("\u0300".."\u036f").to_a
[3]  pry(main)> chars1.each do |char1|
[4]  pry(main)>   chars2.each do |char2|
[5]  pry(main)>     before_chars = "#{char1}#{char2}"
[6]  pry(main)>     before_codepoints = before_chars.each_codepoint.map { |n| n.to_s(16) }
[7]  pry(main)>     after_chars = "#{char1}#{char2}".unicode_normalize(:nfc)
[8]  pry(main)>     after_codepoints = after_chars.each_codepoint.map { |n| n.to_s(16) }
[9]  pry(main)>     unless before_chars == after_chars
[10] pry(main)>       puts "#{before_chars}: before => #{before_chars}#{before_codepoints}, after => #{after_chars}#{after_codepoints}"
[11] pry(main)>     end
[12] pry(main)>   end
[13] pry(main)> end
À: before => À["41", "300"], after => À["c0"]
Á: before => Á["41", "301"], after => Á["c1"]
Â: before => Â["41", "302"], after => Â["c2"]
Ã: before => Ã["41", "303"], after => Ã["c3"]
Ā: before => Ā["41", "304"], after => Ā["100"]
Ă: before => Ă["41", "306"], after => Ă["102"]
Ȧ: before => Ȧ["41", "307"], after => Ȧ["226"]
Ä: before => Ä["41", "308"], after => Ä["c4"]
Ả: before => Ả["41", "309"], after => Ả["1ea2"]
Å: before => Å["41", "30a"], after => Å["c5"]
Ǎ: before => Ǎ["41", "30c"], after => Ǎ["1cd"]
Ȁ: before => Ȁ["41", "30f"], after => Ȁ["200"]
Ȃ: before => Ȃ["41", "311"], after => Ȃ["202"]
Ạ: before => Ạ["41", "323"], after => Ạ["1ea0"]
Ḁ: before => Ḁ["41", "325"], after => Ḁ["1e00"]
Ą: before => Ą["41", "328"], after => Ą["104"]
À: before => À["41", "340"], after => À["c0"]
Á: before => Á["41", "341"], after => Á["c1"]
A̓: before => A̓["41", "343"], after => A̓["41", "313"]
Ä́: before => Ä́["41", "344"], after => Ä́["c4", "301"]
Ḃ: before => Ḃ["42", "307"], after => Ḃ["1e02"]
Ḅ: before => Ḅ["42", "323"], after => Ḅ["1e04"]
Ḇ: before => Ḇ["42", "331"], after => Ḇ["1e06"]
B̀: before => B̀["42", "340"], after => B̀["42", "300"]
B́: before => B́["42", "341"], after => B́["42", "301"]
B̓: before => B̓["42", "343"], after => B̓["42", "313"]
B̈́: before => B̈́["42", "344"], after => B̈́["42", "308", "301"]
Ć: before => Ć["43", "301"], after => Ć["106"]
…
z̀: before => z̀["7a", "340"], after => z̀["7a", "300"]
ź: before => ź["7a", "341"], after => ź["17a"]
z̓: before => z̓["7a", "343"], after => z̓["7a", "313"]
z̈́: before => z̈́["7a", "344"], after => z̈́["7a", "308", "301"]

抽出したものを確認すると、ダイアクリティカルマークが別の文字に変わってしまったり、1文字から2文字に分割されるものがあります。そのため、範囲指定で全てのダイアクリティカルマークを対象にして、正規化をすることは避けた方が良いです。

ダイアクリティカルマークが別の文字に変わってしまう問題の対応策としては、別の文字に変わってしまう組み合わせを除くようにして、正規表現で範囲指定をすることです。正規化によって合成できなかった組み合わせの中で、異なる文字に変わってしまう組み合わせは、リスト4のように調べることができます。

[1]  pry(main)> results = {}
[2]  pry(main)> chars1 = ('A'..'Z').to_a + ('a'..'z').to_a
[3]  pry(main)> chars2 = ("\u0300".."\u036f").to_a
[4]  pry(main)> chars1.each do |char1|
[5]  pry(main)>   chars2.each do |char2|
[6]  pry(main)>     before_chars = "#{char1}#{char2}"
[7]  pry(main)>     before_codepoints = before_chars.each_codepoint.map { |n| n.to_s(16) }
[8]  pry(main)>     after_chars = "#{char1}#{char2}".unicode_normalize(:nfc)
[9]  pry(main)>     after_codepoints = after_chars.each_codepoint.map { |n| n.to_s(16) }
[10] pry(main)>
[11] pry(main)>     if !(before_chars == after_chars) && after_codepoints.size >= 2
[12] pry(main)>       results["#{before_codepoints[1]}"] ||= []
[13] pry(main)>       results["#{before_codepoints[1]}"] << char1
[14] pry(main)>     end
[15] pry(main)>   end
[16] pry(main)> end
[17] pry(main)> results.sort.each { |key, val| puts "#{key} => #{val.join}" }
340 => BCDFGHJKLMPQRSTVXZbcdfghjklmpqrstvxz
341 => BDFHJQTVXbdfhjqtvx
343 => ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
344 => ABCDEFGHJKLMNOPQRSTVWXYZabcdefghjklmnopqrstvwxyz

正規化を避けた方が良い文字の組み合わせは、次のようになっていました。

  • ダイアクリティカルマーク: ̀(U+0340)
    基底文字: BCDFGHJKLMPQRSTVXZbcdfghjklmpqrstvxz
  • ダイアクリティカルマーク: ́(U+0341)
    基底文字: BDFHJQTVXbdfhjqtvx
  • ダイアクリティカルマーク: ̓(U+0343)
    基底文字: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz(全ての英字が正規化してはいけない)
  • ダイアクリティカルマーク: ̈́(U+0344)
    基底文字: ABCDEFGHJKLMNOPQRSTVWXYZabcdefghjklmnopqrstvwxyz(対象外のIUiuだけ合成済み文字が存在する)

正規化を避けた方が良い文字の組み合わせを除く正規表現は、リスト5のようになります。ただし、この正規表現では2文字以上のダイアクリティカルマークを使用して、表示上では1文字になる文字の組み合わせには対応していないため、全ての組み合わせに対応することはできません。

/[A-Za-z][̀-̿]|[AEINOUWYaeinouwy]̀|[ACEGIK-PRSUWYZacegik-prsuwyz]́|[IUiu]̈́|[A-Za-z][ͅ-ͯ]/

濁点と半濁点の正規表現も組み合わせる場合は、リスト6のようになります。

/[ゝうか-とは-ほヽウカ-トハ-ホワ-ヲ]゙|[は-ほハ-ホ]゚|[A-Za-z][̀-̿]|[AEINOUWYaeinouwy]̀|[ACEGIK-PRSUWYZacegik-prsuwyz]́|[IUiu]̈́|[A-Za-z][ͅ-ͯ]/

正規表現の組み合わせは、使用環境に合わせて変更してください。

おわりに

Unicodeの結合文字をスマートフォンで表示したときに発生した問題について、原因と対応策について紹介しました。

結合文字は、表示上では合成済み文字と同じように見えてしまうことが多いため、結合文字が含まれているのか目視で判別することは難しいです。また、正規表現で特定の文字を一括で置換する場合、結合文字を使用して同じ文字を作ることができるのであれば、正規表現では別の文字として扱われるため、結合文字の存在を忘れていると文字を表示したときに、置換ができなかった結合文字が表示されてしまう可能性もあります。そのため、何かしらの影響がある結合文字であれば、できるだけ取り込まないように合成済み文字に変換することをお勧めします。

みなさまも文字の一部分の表示位置がずれる問題に遭遇したときは、結合文字が含まれていないか確認してみてください。

   [ + ]

1.紹介する内容は、技術書典6で頒布された「INSIDE MERY」の「6.5 Unicodeの結合文字による問題」と同じ内容です。
2.参考文献: 『[改訂新版]プログラマのための文字コード技術入門』(矢野啓介 著、技術評論社、2018年)
yuki.hoshino

yuki.hoshino

    関連記事
    FFmpegを使用した動画変換処理で発生した問題の対応策
    トップへ戻る