[Swift] テキストから顔文字を抽出する

誰にだってテキストから顔文字を抽出したいことがあると思います(要出典)。

# 手法
2016年のこちらの論文の第2節で提案されているものを用います。
風間 一洋, 水木 栄, 榊 剛史『Twitterにおける顔文字を用いた感情分析の検討』
https://www.jstage.jst.go.jp/article/pjsai/JSAI2016/0/JSAI2016_3H3OS17a4/_article/-char/ja

# 「顔文字抽出」とは
一口に「顔文字抽出」と言っても指すところは曖昧です。例えば「(・∀・)ﾓｳﾔﾒﾚ!!」という顔文字はテキストを含みますが、この部分は顔文字として扱われるべきでしょうか。抽出されるべきは「(・∀・)」なのか、それとも「(・∀・)ﾓｳﾔﾒﾚ!!」なのか。あるいは「(・∀・)ﾓｳﾔﾒﾚ」を一区切りとする考え方もありそうです。
この論文で言われている顔文字は「基本形」と呼ばれるもので、1つの顔として認識され、かつテキストを含まない「(・∀・)」の部分のようです。

# 実装
構文的に意味を取りにくい部分があったので、適宜実験的に補完しながら実装しました。また意味的な読みやすさのため、場合分けや条件文をかなり冗長に書いています。

## 方針
顔文字をそうでない部分と区別する「顔文字主要文字」を中心として、その周辺を探索し、それらしき範囲を確定させていきます。

## 顔文字主要文字
ここでいきなりかなり迷いました。顔文字判定の核となる概念である**顔文字主要文字**の定義がイマイチわからなかったのです。

>Unicode文字プロパティの一般カテゴリが表1に示す値を持つ記号類か，表2に示す日本語の文字以外の文字を，顔文字主要文字とする．

とあるのですが、構文上

* `Unicode文字プロパティの一般カテゴリが表1に示す値を持つ記号類`or`表2に示す日本語の文字以外の文字`
* `Unicode文字プロパティの一般カテゴリが表1に示す値を持つ記号類 and 表2に示す日本語の文字`以外の文字

のどちらを指すのかわからず。実験的にはどうやら前者のようだったので、そちらで実装します。

```swift
func isKaomojiMain(_ unicodeScalar: UnicodeScalar) -> Bool {
    if CharacterSet.punctuationCharacters.contains(unicodeScalar){
        return true
    }
    if CharacterSet.symbols.contains(unicodeScalar){
        return true
    }
    return !inJapaneseBlock(unicodeScalar)
}

func inJapaneseBlock(_ unicodeScalar: UnicodeScalar) -> Bool {
    let value = unicodeScalar.value
    if 0x0000...0x007F ~= value{
        return true
    }
    if 0xFF61...0xFF9F ~= value{
        return true
    }
    if 0x4E00...0x9FFF ~= value{
        return true
    }
    if 0x3041...0x309F ~= value{
        return true
    }
    if 0x30A0...0x30FF ~= value{
        return true
    }
    if 0xFF01...0xFF60 ~= value{
        return true
    }
    return false
}
```

これで顔文字主要文字の判定ができました。

## 顔文字探索
STEP1とされている顔文字探索を実装します。この手法では文字列をUnicode文字列として扱う必要があるため、変換します。

```swift
func search(from text: String, G: Int, L: Int) -> [String] {
    let unicodeScalars = Array(text.unicodeScalars)
    var results: [String] = []
    var i = 0
    while true{
        if unicodeScalars.endIndex == i{
            break
        }
        if isKaomojiMain(unicodeScalars[i]){
            extract(from: unicodeScalars, center: i, G: G)
        }
        i += 1
    }
}
```

`extract`関数は後々実装するとして、ひとまずSTEP1は完了です。forループではなくwhileを用いているのは後々のためです。

## 領域拡張
STEP2を実装します。`extract`関数の内部に突っ込むのがいいでしょう。まだSTEP2の実装なので返り値は指定していません。
引数に与えられる`center`はSTEP1で発見した顔文字主要文字のindexです。
顔文字主要文字同士の間に最大`G`文字を許容しながら領域を拡張するということは、端点からG文字以内に顔文字主要文字があればそこまで領域を拡張する、ということを繰り返せばいいことになります。

```swift
func extract(from scalars: [UnicodeScalar], center: Int, G: Int){
    var result = scalars[center...center]
    #領域拡張
    while true{
        var changed = false
        if let i = (max(0, result.startIndex - G) ..< result.startIndex).first{isKaomojiMain(scalars[$0])}{
            if result.startIndex != i{
                changed = true
            }
            result = scalars[i..<result.endIndex]
        }
        if let i = (result.endIndex ..< min(scalars.endIndex, result.endIndex + G)).last{isKaomojiMain(scalars[$0])}{
            if result.endIndex != i+1{
                changed = true
            }
            result = scalars[result.startIndex..<i+1]
        }
        if !changed{
            break
        }
    }
}
```

`max`とか`min`とかが入り混じって少し読みにくいですが、この部分のお気持ちは「領域外(`index<0`とか)に飛び出さないように」に尽きるので、適当に読んでいただければ大丈夫です。
## 領域縮小
STEP3の領域縮小もやります。これも`extract`に書いていけば良さそうです。
ただ、論文には
> 顔文字は文末に使われる傾向があるために「(^^)『」や「。(^^)」のような前後の括弧や句読点と隣接することが多いので，領域の前後でそれぞれ一部括弧類の向きが異なる38文字を削除する．

としか書いておらず、その38文字が具体的になんなのかがわかりません。仕方がないのでこの部分では実験的に誤検出されることが多かった数文字を削除することにしました。

補助として次の二つを定義します。

```swift
func shouldBeDroppedLeft(_ unicodeScalar: UnicodeScalar) -> Bool {
    let charcterSet = CharacterSet(charactersIn: ")）」』］】｝〉〕。、,.!?！？…→")
    if charcterSet.contains(unicodeScalar){
        return true
    }
    return false
}

func shouldBeDroppedRight(_ unicodeScalar: UnicodeScalar) -> Bool {
    let charcterSet = CharacterSet(charactersIn: "(（「『［【｛〈〔。、,.!?！？…←")
    if charcterSet.contains(unicodeScalar){
        return true
    }
    return false
}
```
これらを用いて実装していきます。

```swift
    //領域縮小
    while result.count != 0 && shouldBeDroppedLeft(result[result.startIndex]){
        result = scalars[result.startIndex + 1 ..< result.endIndex]
    }
    while result.count != 0 && shouldBeDroppedRight(result[result.endIndex - 1]){
        result = scalars[result.startIndex ..< result.endIndex - 1]
    }
```

## 領域補完
STEP4です。これが難しい。

>iOSでサポートされている137文字の顔文字リストを，顔文字の前後に対して，3の処理を施した後で，顔文字と判定されなかった部分を隣接する1文字と共に抽出して，拡張用の部分文字列として使用する．例えば「m(_ _)m」からは左側の「m(」と右側の「)m」が抽出される．

とあるのですが、現在のiOSの顔文字リストは137どころではなく、自動抽出する方法も見当たらず、手作業で200ほど集めたところ(約半分)で気力が尽きました。集まったところまでで許してください。

まず補助関数として次の二つを定義します。

```swift
func isPermittedLeftSequence(_ unicodeScalars: [UnicodeScalar]) -> Bool {
    let list: [[UnicodeScalar]] = [
        ["m","("],
        ["(","("],
        ["ヽ","("],
        ["ヾ","("],
        ["o","("],
        [".","°"],
        ["。","・"],
        ["ヾ","ﾉ"],
        [" ","ﾉ"],
    ]
    return list.contains(unicodeScalars)
}

func isPermittedRightSequence(_ unicodeScalars: [UnicodeScalar]) -> Bool {
    let list: [[UnicodeScalar]] = [
        [")",")"],
        [")","m"],
        [")","ﾉ"],
        [")","ノ"],
        ["`","A"],
        [";",")"],
        [")","o"],
        ["°","."],
        ["・","。"],
        ["=","3"],
    ]
    return list.contains(unicodeScalars)
}
```
よく見るシリーズです。
領域補完も`extract`に追加しましょう。この部分はあんまりうまい実装が浮かばなかったので、ベタ書きです。

```swift
    //領域補完
    let leftside = Array(scalars[max(0, result.startIndex - 1)..<min(result.startIndex+1, result.endIndex)])
    if isPermittedLeftSequence(leftside){
        result = scalars[max(0, result.startIndex - 1) ..< result.endIndex]
    }
    let rightside = Array(scalars[max(result.startIndex, result.endIndex-1)..<min(scalars.endIndex, result.endIndex + 1)])
    if isPermittedRightSequence(rightside){
        result = scalars[result.startIndex ..< min(scalars.endIndex, result.endIndex + 1)]
    }
```
以上で`extract`が完成します。この関数は`result`の`startIndex`と`endIndex`を返すことにしましょう。

```swift
func extract(from scalars: [UnicodeScalar], center: Int, G: Int) -> (start: Int, end: Int) {
    var result = scalars[center...center]
    //領域拡張
    //コード省略
    //領域縮小
    //コード省略
    //領域補完
    //コード省略
    return (result.startIndex, result.endIndex)
}
```

## 顔文字判定

顔文字判定は`judge`として別の関数に書きましょう。`search`を次のようにします。

```swift
    var results: [String] = []
    while true{
        if unicodeScalars.endIndex == i{
            break
        }
        if isKaomojiMain(unicodeScalars[i]){
            let (start, end) = extract(from: unicodeScalars, center: i, G: G)
            if end <= i || start == end{
                i += 1
                continue
            }
            if judge(Array(unicodeScalars[start..<end]), L: L){
                let kaomoji = unicodeScalars[start..<end].map{String($0)}.joined()
                results.append(kaomoji)
                i = end
                continue
            }
        }
        i += 1
    }
```
少し早いですが、`i=end`の部分で先にSTEP6を実装してしまいました。顔文字だった部分を飛ばしてそのさきを調べるだけで大したことではないので気にしなくて大丈夫です。

さて、`judge`の中身ですが、次のようにします。「文字数」が`UnicodeScalars`の`count`としてなのか、`String`の`count`としてなのか定かでなかったため、とりあえず`UnicodeScalars`の方で実装しました。

```swift
func judge(_ unicodeScalars: [UnicodeScalar], L: Int) -> Bool {
    if unicodeScalars.count < L{
        return false
    }
    if Set(unicodeScalars).count <= 1{
        return false
    }
    let first = unicodeScalars.first!
    let last = unicodeScalars.last!
    if first == "「" && last == "」"{
        return false
    }
    if first == "『" && last == "』"{
        return false
    }
    if first == "”" && last == "”"{
        return false
    }
    if first == "”" && last == "”"{
        return false
    }
    if first == "\"" && last == "\""{
        return false
    }
    if first == "(" && last == ")"{
        let middle = unicodeScalars.dropLast().dropFirst()
        //数値
        if middle.allSatisfy({0x0030...0x0039 ~= $0.value}){
            return false
        }
        //漢字
        if middle.allSatisfy({0x4E00...0x9FFF ~= $0.value}){
            return false
        }
    }
    let filtered = unicodeScalars.filter{scalar in
        let category = scalar.properties.generalCategory
        return [.decimalNumber, .letterNumber, .uppercaseLetter, .lowercaseLetter, .otherLetter, .titlecaseLetter, .modifierLetter, .otherNumber].contains(category)
    }
    if Double(filtered.count) / Double(unicodeScalars.count) > 0.5{
        return false
    }
    return true
}
```

## 完成
STEP6はもう実装してあるので、これで完成です。全体のコードは折りたたんでおくので見たい人が見てください。
<details><summary>全体のコード</summary><div>

```swift
func search(from text: String, G: Int, L: Int) -> [String] {
    let unicodeScalars = Array(text.unicodeScalars)
    var results: [String] = []
    var i = 0
    while true{
        if unicodeScalars.endIndex == i{
            break
        }
        if isKaomojiMain(unicodeScalars[i]){
            let (start, end) = extract(from: unicodeScalars, center: i, G: G)
            if end <= i || start == end{
                i += 1
                continue
            }
            if judge(Array(unicodeScalars[start..<end]), L: L){
                let kaomoji = unicodeScalars[start..<end].map{String($0)}.joined()
                results.append(kaomoji)
                i = end
                continue
            }
        }
        i += 1
    }
    return results
}

func extract(from scalars: [UnicodeScalar], center: Int, G: Int){
    var result = scalars[center...center]
    #領域拡張
    while true{
        var changed = false
        if let i = (max(0, result.startIndex - G) ..< result.startIndex).first{isKaomojiMain(scalars[$0])}{
            if result.startIndex != i{
                changed = true
            }
            result = scalars[i..<result.endIndex]
        }
        if let i = (result.endIndex ..< min(scalars.endIndex, result.endIndex + G)).last{isKaomojiMain(scalars[$0])}{
            if result.endIndex != i+1{
                changed = true
            }
            result = scalars[result.startIndex..<i+1]
        }
        if !changed{
            break
        }
    }

    //領域縮小
    while result.count != 0 && shouldBeDroppedLeft(result[result.startIndex]){
        result = scalars[result.startIndex + 1 ..< result.endIndex]
    }
    while result.count != 0 && shouldBeDroppedRight(result[result.endIndex - 1]){
        result = scalars[result.startIndex ..< result.endIndex - 1]
    }

    //領域補完
    let leftside = Array(scalars[max(0, result.startIndex - 1)..<min(result.startIndex+1, result.endIndex)])
    if isPermittedLeftSequence(leftside){
        result = scalars[max(0, result.startIndex - 1) ..< result.endIndex]
    }
    let rightside = Array(scalars[max(result.startIndex, result.endIndex-1)..<min(scalars.endIndex, result.endIndex + 1)])
    if isPermittedRightSequence(rightside){
        result = scalars[result.startIndex ..< min(scalars.endIndex, result.endIndex + 1)]
    }
    return (result.startIndex, result.endIndex)
}

func judge(_ unicodeScalars: [UnicodeScalar], L: Int) -> Bool {
    if unicodeScalars.count < L{
        return false
    }
    if Set(unicodeScalars).count <= 1{
        return false
    }
    let first = unicodeScalars.first!
    let last = unicodeScalars.last!
    if first == "「" && last == "」"{
        return false
    }
    if first == "『" && last == "』"{
        return false
    }
    if first == "”" && last == "”"{
        return false
    }
    if first == "”" && last == "”"{
        return false
    }
    if first == "\"" && last == "\""{
        return false
    }
    if first == "(" && last == ")"{
        let middle = unicodeScalars.dropLast().dropFirst()
        //数値
        if middle.allSatisfy({0x0030...0x0039 ~= $0.value}){
            return false
        }
        //漢字
        if middle.allSatisfy({0x4E00...0x9FFF ~= $0.value}){
            return false
        }
    }
    let filtered = unicodeScalars.filter{scalar in
        let category = scalar.properties.generalCategory
        return [.decimalNumber, .letterNumber, .uppercaseLetter, .lowercaseLetter, .otherLetter, .titlecaseLetter, .modifierLetter, .otherNumber].contains(category)
    }
    if Double(filtered.count) / Double(unicodeScalars.count) > 0.5{
        return false
    }
    return true
}

func isKaomojiMain(_ unicodeScalar: UnicodeScalar) -> Bool {
    if CharacterSet.punctuationCharacters.contains(unicodeScalar){
        return true
    }
    if CharacterSet.symbols.contains(unicodeScalar){
        return true
    }
    return !inJapaneseBlock(unicodeScalar)
}

func inJapaneseBlock(_ unicodeScalar: UnicodeScalar) -> Bool {
    let value = unicodeScalar.value
    if 0x0000...0x007F ~= value{
        return true
    }
    if 0xFF61...0xFF9F ~= value{
        return true
    }
    if 0x4E00...0x9FFF ~= value{
        return true
    }
    if 0x3041...0x309F ~= value{
        return true
    }
    if 0x30A0...0x30FF ~= value{
        return true
    }
    if 0xFF01...0xFF60 ~= value{
        return true
    }
    return false
}

func shouldBeDroppedLeft(_ unicodeScalar: UnicodeScalar) -> Bool {
    let charcterSet = CharacterSet(charactersIn: ")）」』］】｝〉〕。、,.!?！？…→")
    if charcterSet.contains(unicodeScalar){
        return true
    }
    return false
}

func shouldBeDroppedRight(_ unicodeScalar: UnicodeScalar) -> Bool {
    let charcterSet = CharacterSet(charactersIn: "(（「『［【｛〈〔。、,.!?！？…←")
    if charcterSet.contains(unicodeScalar){
        return true
    }
    return false
}

func isPermittedLeftSequence(_ unicodeScalars: [UnicodeScalar]) -> Bool {
    let list: [[UnicodeScalar]] = [
        ["m","("],
        ["(","("],
        ["ヽ","("],
        ["ヾ","("],
        ["o","("],
        [".","°"],
        ["。","・"],
        ["ヾ","ﾉ"],
        [" ","ﾉ"],
        ["ｷ","ﾀ"],
        ["ｲ","ｴ"],
    ]
    return list.contains(unicodeScalars)
}

func isPermittedRightSequence(_ unicodeScalars: [UnicodeScalar]) -> Bool {
    let list: [[UnicodeScalar]] = [
        [")",")"],
        [")","m"],
        [")","ﾉ"],
        [")","ノ"],
        ["`","A"],
        [";",")"],
        [")","o"],
        ["°","."],
        ["・","。"],
        ["=","3"],
    ]
    return list.contains(unicodeScalars)
}
```
</div></details>
使ってみましょう。

```swift
print(search(from: "嬉しいです(≧▽≦)", G: 3, L: 3))   //["(≧▽≦)"]
print(search(from: "嫌い(｀ε´)　絶交しよ( ￣っ￣)", G: 3, L: 3))   //["(｀ε´)　", "( ￣っ￣)"]
```

もう少し長い文章でもいけるでしょうか。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">地震だ！┗(^o^;)┓震度どのくらいかな？？ｗｗＷｗｗＷＷｗ┏(;^o^)┛ニュースになってるかな？？ｗＷＷＷｗｗｗｗ(´･｀:) こ…これ…これは…………僕の貧乏揺すりだあああああ ┗(^o^)┛ＷｗｗｗＷＷ┏(^o^)┓ﾄﾞｺﾄﾞｺﾄﾞｺﾄﾞｺｗｗｗｗｗｗｗｗ</p>&mdash; ドコドコbot (@dokodoko_bot) <a href="https://twitter.com/dokodoko_bot/status/1326302512341643265?ref_src=twsrc%5Etfw">November 10, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

```swift
//["┗(^o^;)┓", "┏(;^o^)┛", "(´･｀:) こ…これ", "┗(^o^)┛", "┏(^o^)┓"]
print(search(from: "地震だ！┗(^o^;)┓震度どのくらいかな？？ｗｗＷｗｗＷＷｗ┏(;^o^)┛ニュースになってるかな？？ｗＷＷＷｗｗｗｗ(´･｀:) こ…これ…これは…………僕の貧乏揺すりだあああああ ┗(^o^)┛ＷｗｗｗＷＷ┏(^o^)┓ﾄﾞｺﾄﾞｺﾄﾞｺﾄﾞｺｗｗｗｗｗｗｗｗ", G: 3, L: 3))
```
若干誤検出していますが、しっかり抜き出せていますね。

## 所感
しばらく使用してみているのですが、「ｷﾀ━━━(ﾟ∀ﾟ)━━━!!!」などの顔文字がとても間抜けな結果になります。

```swift
print(search(from: "ｷﾀ━━━(ﾟ∀ﾟ)━━━!!!", G: 3, L: 3))  //["━━━(ﾟ∀ﾟ)━━━"]
```
私の用途ではこの種の顔文字ではテキストも抜き出せたほうがいいので、多少エスケープ処理を足す必要がありそうです。
また、シンプルな構成の顔文字が検出されにくいという問題があります。「(ｰ ｰ;)」「(..)」「(ToT)」など標準的な記号のみで表現された顔文字が引っかからないため、この辺りも調整が必要かと思われます。

当初流行りの深層学習で……など考えていましたが、どうなんでしょう。系列データとして扱う場合は合成文字の処理が辛そうですから、画像認識系のタスクになるんでしょうか。どなたかやってみて欲しいところです。

## パッケージにしました
https://github.com/ensan-hcl/KaomojiParser

「ｷﾀ━━━(ﾟ∀ﾟ)━━━!!!」のパースの問題などを修正し、Swift Packageとして公開しています。