元はといえば 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 に期待することが「右端がばっしりきっちり揃わんかいおりゃ」てあるならば、この結果は受け容れがたいに違いない。実装は要は
- MeCab 辞書に含まれるエントリの表層形
- または未知語
を「基本単位」として決して千切らないようにしている。
あるいは、「分かち書く部分」に関しても、あなたの望むものとは違う可能性がある。ワタシは「日本語 + 空白 + 英数 + 空白 + 日本語」という「正書法」に則った書き方が読みやすいと思っているので、概ね不満はないんだけれど、括弧系などもう少し工夫の余地はある、とも思う、のみならず、「正書法」が好みでない人も結構いるんではないかと。とりわけ「発売日 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」が悶絶するほど読みにくいかといえばそうでもないし、処理後に正規表現で置換してしまう手もあるし、てことでもいいかな、と思った。