どうせ和布蕪るなら、の「発端」の続き (分かち書けばいい、よりは発展形)

元はといえば textwrap の話だったわけだ。

「MeCab 使うなら textwrap いらんやんけ」を実際にやってみる、な話、それだけ:

  1 # -*- coding: utf-8 -*-
  2 #
  3 # textwrap 「のようなこと」を MeCab.py を使って行う、わけなん
  4 # だけれど、「ぴっちり右端を揃える」ということが至上命題と
  5 # 考える人は、即刻興味を失うべし。なぜならワタシがやりたいのは
  6 # そういうことではなくて、「切って欲しくないところで切って
  7 # 欲しくない」ことをテーマにしているから。つまりは英文のみの
  8 # 場合と同じように、「ワードの中途半端な位置でちょん切らない
  9 # ようにしたい」てことをしたいわけ。たとえば「ぱふゅ~む」と
 10 # いう語を辞書に登録してあるとして、たとえこれが横幅を超える
 11 # 位置だったとしても「ぱ」「ふゅ~む」はヤダ、「ぱふ」「ゅ~む」
 12 # はヤダ、てことね。そもそもそういうことをしたいんでない限り
 13 # MeCab を使う意味なんかないわけなんだから。
 14 #
 15 import io
 16 import re
 17 from MeCab import Tagger
 18 # ↓コピーして抱え込もうとも思ったがダルいので。∴要 docutils。
 19 from docutils.utils import column_width
 20 
 21 
 22 def _create_tagger(udicdir, ffun=lambda x: x):
 23     if udicdir:
 24         import os
 25         from glob import glob
 26         args = "-u " + ",".join([
 27                 d.replace("\\", "/")
 28                 for d in glob(os.path.join(
 29                         udicdir, "*.dic"))
 30                 if ffun(d)])
 31         return Tagger.create(args)
 32     return Tagger()
 33 
 34 
 35 def _isnotww(s):
 36     return column_width(s) == len(s)
 37 
 38 
 39 # MeCab による分解を活用することには当然メリット・デメリット
 40 # 両面ある。メリットは無論「日本語の扱いに長ける」ことだが、
 41 # デメリットは例えば数値(3.14 など)の扱いである。これは辞書
 42 # エントリそのものに登録していない限りは、unk.def の定義により
 43 # 「3」「.」「14」と分解してしまい、今やりたいことに反する。
 44 # (これに関してはたとえば IP アドレス、みたいなのまで考え出すと
 45 # かなりキリがなくて、なので、「句点」の扱いで済む範囲内のこと
 46 # しかしてない。たとえば「5.1 ch」ならば「5.」+「空白」+「1」
 47 # となる。)
 48 # もう一つは「元のテキストに含まれる空白」が取り除かれてしまう
 49 # こと。これは多くのケースでは問題ない(空白を分解ポイントにする
 50 # ことだから)が、テキストによっては問題になることもある。
 51 # もとより完全さは求めていない。考え出すと結構キリがないし、
 52 # 実現も結構厄介で、また、「辞書の方をごにょる」ことで解決する
 53 # こともある。
 54 # 当然の事ながら、MeCab による形態要素への分解は「パーフェクト」
 55 # ではないので、そこが意図したものになってないなら、このワタシの
 56 # 書いた処理だってもろともトチる。(記号の扱いで結構やらかす。
 57 # 括弧としてつかまえて欲しいのに unknown (「サ変接続」)になる、
 58 # とか色々。)
 59 class MeCabTextWrap(object):
 60     def __init__(self, udicdir="", ffun=lambda x: x):
 61         self._tagger = _create_tagger(udicdir)
 62 
 63     def _tokenize_inner(self, text):
 64         pend = []
 65         for line in self._tagger.parse(text).split("\n"):
 66             sur, _, fea = line.partition("\t")
 67             fea = fea.split(",")[:-3]
 68             if pend:
 69                 psur, pfea = pend.pop(0)
 70                 if fea and (sur in (".", ",") or fea[1] in (
 71                         "読点", "句点", "括弧閉",)):
 72                     yield psur + sur
 73                     continue
 74                 elif pfea:
 75                     yield psur
 76             pend.append((sur, fea))
 77         # pend が残るがそれは EOS なので捨てればいい。
 78 
 79     def _tokenize(self, text):
 80         otks = list(self._tokenize_inner(text))
 81         for i, sur in enumerate(otks):
 82             p_e = _isnotww(otks[i - 1][-1]) if i > 0 else False
 83             c_b, c_e = _isnotww(sur[0]), _isnotww(sur[-1])
 84             n_b = _isnotww(otks[i + 1][0]) if i < len(otks) - 1 else False
 85             sp1, sp2 = "", ""
 86             if p_e or c_b:
 87                 sp1 = " "
 88             if c_e or n_b:
 89                 sp2 = " "
 90             yield sp1 + sur + sp2
 91 
 92     def _wrap(self, text, width, indent):
 93         result = [""]
 94         pos = column_width(indent)
 95         for w in self._tokenize(text):
 96             pos += column_width(w)
 97             result[-1] += w
 98             if pos > width:
 99                 result.append("")
100                 pos = column_width(indent)
101         return "\n".join([
102                 indent + re.sub(r"\s+", " ", line.strip())
103                 for line in result])
104 
105     def wrap(self, text, width=80, indent=""):
106         for line in re.split(r"\r?\n", text):
107             yield self._wrap(line, width, indent)
108 
109 if __name__ == '__main__':
110     import argparse
111     parser = argparse.ArgumentParser()
112     parser.add_argument("input")
113     parser.add_argument("output")
114     parser.add_argument("--width", type=int, default=80)
115     parser.add_argument("--indent", default="")
116     parser.add_argument("--encoding", default="utf-8")
117     parser.add_argument("--dir_mecabdicts")
118     args = parser.parse_args()
119 
120     text = io.open(args.input, encoding=args.encoding).read()
121     twr = MeCabTextWrap(args.dir_mecabdicts)
122     with io.open(args.output, "wb") as fo:
123         for line in twr.wrap(text, width=args.width, indent=args.indent):
124             fo.write((line + "\n").encode(args.encoding))

MeCab モジュールは無論これで作ったやつ。はっきりいって完璧を目指そうとしてないので、人によっては満足できないものだろうと思う。たとえば、現実のテキストに適用した以下:

抜粋
 1 #
 2 #    コスプレパッチと認証機能を除いた要素は、 Xbox 360 版でも DLC (ダウンロード
 3 #    コンテンツ)として『演出強化パック』 2010 年 9 月 24 日に配信された。実績
 4 #    に 250 ポイント分が追加される。当初は解除した実績が XboxLive 上で正常認識
 5 #    されないという不具合が発生していたが、現在は修正されている。
 6 #    音源は Xbox 360 版の 5. 1 ch から 2. 1 ch に削減。画面サイズは 720
 7 #    p から 1024 × 768 (ゲーム画面は 1024 × 576)に変更されている。
 8 #    初期バージョンでは、既読機能が正常認識できない、一部のエンディングに到達でき
 9 #    ない、特定の場面でフリーズするなどの多数の重大なバグが問題となっていた。ニトロプラス
10 #    は公式サイトで謝罪をした上で、 2010 年 9 月 3 日と 9 月 17 日に修正
11 #    パッチを配信し、現在では重大なバグは概ね修正されている [ 20 ]。ただし、パッチ
12 #    適用前と後ではセーブデータの互換性がないので、パッチ適用後はゲームを最初から
13 #    やり直す必要がある。また、本作は 2010 年現在の Windows 用アドベンチャーゲーム
14 #    としては要求スペックが比較的高いため、低スペックマシン向けの演出軽減パッチも
15 #    用意されている [ 20 ]。
16 #    志倉は twitter 上で、 Windows 版はニトロプラスの管轄になるため、上記の不具合
17 #    に関して 5pb. として手が出せないと語っている。これは前作『 CHAOS ; HEAD』
18 #    も同様であるとのこと。
19 ...
20 #
21 #    岡部たちは電話レンジの改良を進めながら、ラボメン達を実験台にして試行錯誤を繰り返す。
22 #    D メール実験の対象者はそれぞれに変えたい過去を隠し持っており、それを修正しよ
23 #    うと事実上の過去改変が次々に行われる。しかし、これらの改変は一見ささいな物で
24 #    も、バタフライ効果の影響で予想外に大規模な影響を周囲にもたらすのだ。
25 #    さらに、橋田と紅莉栖は電話レンジの機能を拡張して、 SERN の LHC (大型ハドロン
26 #    衝突型加速器)への接続を行うことにより、人の記憶を過去の自分に届けるタイムリープマシン
27 #    を完成させる。これが原因で、岡部らはタイムマシンの秘密を狙う本物の秘密組織に、
28 #    命を狙われることになる。
29 ...

textwrap に期待することが「右端がばっしりきっちり揃わんかいおりゃ」てあるならば、この結果は受け容れがたいに違いない。実装は要は

  1. MeCab 辞書に含まれるエントリの表層形
  2. または未知語

を「基本単位」として決して千切らないようにしている。

あるいは、「分かち書く部分」に関しても、あなたの望むものとは違う可能性がある。ワタシは「日本語 + 空白 + 英数 + 空白 + 日本語」という「正書法」に則った書き方が読みやすいと思っているので、概ね不満はないんだけれど、括弧系などもう少し工夫の余地はある、とも思う、のみならず、「正書法」が好みでない人も結構いるんではないかと。とりわけ「発売日 XB 360 : 2009 年 10 月 15 日」は、ワタシでも「発売日 XB 360 : 2009年10月15日」としたくなる。

ついでにいえばこれ、本質的には「折り返し制御」というよりは「標準化」といったほうが的を得ている。つまり、元から含まれる空白等々を無視して空白を再挿入することになるため、これが意図するものとは異なる結果になることがある。

てな具合で、「万人が満足するもの」にはほど遠いんだけれど、「とりあえず版」としては結構いいんじゃないかしら、と思う。


と、ここまで書いたはいいんだけれども、やっぱしね、「5.1 ch」が「5. 1 ch」はさすがになぁ、と。思ったんだけれど、うん、ダメ。以下「失敗作」:

  1 # -*- coding: utf-8 -*-
  2 # MeCab を「部分的に」使うなら、うまくいくかしら、と思ってうまく
  3 # いかなかったヤツね。
  4 import io
  5 import re
  6 from MeCab import Tagger
  7 from docutils.utils import column_width
  8 
  9 def _create_tagger(udicdir, ffun=lambda x: x):
 10     if udicdir:
 11         import os
 12         from glob import glob
 13         args = "-u " + ",".join([
 14                 d.replace("\\", "/")
 15                 for d in glob(os.path.join(
 16                         udicdir, "*.dic"))
 17                 if ffun(d)])
 18         return Tagger.create(args)
 19     return Tagger()
 20 
 21 
 22 def _isnotww(s):
 23     return column_width(s) == len(s)
 24 
 25 
 26 class MeCabTextWrap(object):
 27     def __init__(self, udicdir="", ffun=lambda x: x):
 28         self._tagger = _create_tagger(udicdir)
 29 
 30     def _tokenize_by_mecab(self, text):
 31         pend = []
 32         for line in self._tagger.parse(text).split("\n"):
 33             sur, _, fea = line.partition("\t")
 34             fea = fea.split(",")[:-3]
 35             if pend:
 36                 psur, pfea = pend.pop(0)
 37                 if fea and (sur in (".", ",") or fea[1] in (
 38                         "読点", "句点", "括弧閉",)):
 39                     yield psur + sur
 40                     continue
 41                 elif pfea:
 42                     yield psur
 43             pend.append((sur, fea))
 44         # pend が残るがそれは EOS なので捨てればいい。
 45 
 46     def _tokenize_inner(self, text):
 47         excrgxes = (
 48             # とりあえず、目立つ数値系だけ。
 49             re.compile(r"([+-]?(?:\d+,)+\d{3,})"),  # number
 50             re.compile(r"([+-]?\d+(?:\.\d+(?:[eE][-+]\d+)?)?)"),  # float number
 51             re.compile(r"([+-]?\d+(?:\.\d+)+)"),  # float number or IP address, etc.
 52             )
 53         for s in re.split(r"\s+", text):
 54             # 「5,230本」というのを「5,230」 + 「本」としたい一方で、社名で
 55             # 「5pb.」なんてのもあるので、数値の後ろに続くものが全角かどうか
 56             # で「そうする」かどうか決めようかと。
 57             for r in excrgxes:
 58                 m = r.search(s)
 59                 #if "5pb." in s:
 60                 #    print(m)
 61                 if m:
 62                     s1, s3 = s[:m.span()[0]], s[m.span()[1]:]
 63                     #if "5pb." in s:
 64                     #    print(s1, s3, not s3, _isnotww(s3))
 65                     if not s3 or not _isnotww(s3):
 66                         yield from self._tokenize_by_mecab(s1)
 67                         yield m.group(1)
 68                         yield from self._tokenize_by_mecab(s3)
 69                         break
 70             else:
 71                 yield from self._tokenize_by_mecab(s)
 72 
 73     def _tokenize(self, text):
 74         otks = list(self._tokenize_inner(text))
 75         for i, sur in enumerate(otks):
 76             p_e = _isnotww(otks[i - 1][-1]) if i > 0 else False
 77             c_b, c_e = _isnotww(sur[0]), _isnotww(sur[-1])
 78             n_b = _isnotww(otks[i + 1][0]) if i < len(otks) - 1 else False
 79             sp1, sp2 = "", ""
 80             if p_e or c_b:
 81                 sp1 = " "
 82             if c_e or n_b:
 83                 sp2 = " "
 84             yield sp1 + sur + sp2
 85 
 86     def _wrap(self, text, width, indent):
 87         result = [""]
 88         pos = column_width(indent)
 89         for w in self._tokenize(text):
 90             pos += column_width(w)
 91             result[-1] += w
 92             if pos > width:
 93                 result.append("")
 94                 pos = column_width(indent)
 95         return "\n".join([
 96                 indent + re.sub(r"\s+", " ", line.strip())
 97                 for line in result])
 98 
 99     def wrap(self, text, width=80, indent=""):
100         for line in re.split(r"\r?\n", text):
101             yield self._wrap(line, width, indent)
102 
103 if __name__ == '__main__':
104     import argparse
105     parser = argparse.ArgumentParser()
106     parser.add_argument("input")
107     parser.add_argument("output")
108     parser.add_argument("--width", type=int, default=80)
109     parser.add_argument("--indent", default="")
110     parser.add_argument("--encoding", default="utf-8")
111     parser.add_argument("--dir_mecabdicts")
112     args = parser.parse_args()
113 
114     text = io.open(args.input, encoding=args.encoding).read()
115     twr = MeCabTextWrap(args.dir_mecabdicts)
116     with io.open(args.output, "wb") as fo:
117         for line in twr.wrap(text, width=args.width, indent=args.indent):
118             fo.write((line + "\n").encode(args.encoding))

「失敗」の中身は _tokenize_inner 内。「適用対象によっては概ね良い」とは言えるんだけれど、要するに「5pb.」という社名、「5,321本」という「数値 + 本」、そして「5.1ch」、これらがまさに「こっちを立てればあっちが立たず」状態。まぁ…「5. 1 ch」が悶絶するほど読みにくいかといえばそうでもないし、処理後に正規表現で置換してしまう手もあるし、てことでもいいかな、と思った。



Related Posts