分かち書けばいい – Python の textwrap の話

なんつーのかなぁ、昔より技術が進化してるがゆえ、というか。

Python の textwrap なんだけれど、当然というか日本語にはあんましよろしくなくて、なんなら英語ですら少し不満があるほどだったりする。色々単純すぎるわけなんだけれど、そりゃまぁ英語だけですら本来かなりの規模の処理が必要なものだからね、「あるだけありがたい」の最たるものだったりするわけだ。

「英語ですら少し不満」については、具体的にはいわゆるカーニングの件だわ。こういうの、もちろん「ブラウザ」だの TeX なんぞの「ちゃんとした」ヤツらは今では優秀にこれをこなしてくれる。昔はそれらですらダメで、ちうか「辛うじて TeX (や roff)」だけしかちゃんとしてくれてなかったので、つまりは「そういうちゃんとしたことをしてくれる何か」の存在というのは昔はとても貴重で重要なのであった。

ところが、その「ブラウザ」なわけだね。これがちゃんとして、なおかつ css, javascript, html5 などなどといった充実によって「大抵のことはブラウザでこなせる」になってきたことによって、何をするにも「html を生成すれば事足りる」に段々なってきていて、今ではよほどのことがない限り「ブラウザ以外を相手にする」必要がない。UI のメインがブラウザでない場合でも、html のレンダリングが出来るインフラも今や全然レアではないしね。だからあんまし textwrap のお世話になる機会もない。

じゃぁ、「たまにそういうことをしたくなる」のってどんな場合か。もちろん「ブラウザ相手でない」場合がそうなんだけれど、ブラウザ相手であってもたとえば「テーブルの列幅の問題を回避したくて、セル内のテキストを明示的に折り返したい」であるとか、あとは svg だの cytoscape のノードのテキストなんかもそう。そういうやつらはほっとくと延々横長のノードを描画してしまう。

「ちゃんとしたテキスト折り返しのインフラが欲しいぞ」てのもまぁ考え方としてはアリだし、ブラウザであれば Chronium なんかはソースが手に入る(C だけど)わけだし、日本語 TeX のソースもあるから、やろうと思えば移植も可能かもしれないし、もちろんそうした「ちゃんとしたライブラリ」を作ってる人はたぶんいると思う。確か XML DB 関係のライブラリを書いた人がいたんじゃなかったかなと思う(確か java)。C でライブラリ化されてるなら Cython でのラップも考えられるだろうし。

で、まさに textwrap そのものを改造(改善)したものとしては、 Sphinx で使ってるヤツがあって、インストールしてるなら sphinx.writers.text にあるヤツが使える。少しというかかなりマシ…、ただし。

簡単に言うと、Python 標準の方の textwrap は「日本語部分を、千切るなと言えば絶対に千切ってくれない」ことに対するストレスがあるのだが、 Sphinx のはこれを変えてる。けれども今度はこっちはこっちで「切って欲しくないところで切ってしまう」問題がある。まぁこれは昔から馴染みの問題なわけだ。それこそ Windows 前夜の「専用ワードプロセッサ」時代の初期は、これの出来も製品のウリにすらなってた(「禁則処理」)。

とここまで書いてきて、さて、では私が今求めているのはどの水準のものなのか、て話。これは「Sphinx がやってる程度で概ね良い」のね。だから「Sphinx がやってる程度を少しさらにマシに出来るや否や?」てのが私の今のニーズ。完璧でなくとも良いわけである。

たとえばこういうことね:

 1 >>> import textwrap
 2 >>> #
 3 >>> s = "Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売、館内マップも ナタリー"
 4 >>> print("\n".join(textwrap.wrap(s.strip(), width=28)))
 5 Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売
 6 、館内マップも ナタリー
 7 >>> #
 8 >>> print("\n".join(textwrap.wrap(s.strip(), width=17)))
 9 Perfume×伊勢丹コラボ!新P
10 V制作、ハイヒール販売、館内マップ
11 も ナタリー
12 >>> #
13 >>> from sphinx.writers.text import my_wrap
14 >>> print("\n".join(my_wrap(s.strip(), width=28)))
15 Perfume×伊勢丹コラボ!新PV制
16 作、ハイヒール販売、館内マッ
17 プも ナタリー
18 >>> print("\n".join(my_wrap(s.strip(), width=17)))
19 Perfume×伊勢丹コ
20 ラボ!新PV制作、
21 ハイヒール販売、
22 館内マップも ナタ
23 リー
24 >>> print("\n".join(my_wrap(s.strip(), width=22)))
25 Perfume×伊勢丹コラボ!
26 新PV制作、ハイヒール販
27 売、館内マップも ナタ
28 リー
29 >>> print("\n".join(my_wrap(s.strip(), width=23)))
30 Perfume×伊勢丹コラボ!
31 新PV制作、ハイヒール販
32 売、館内マップも ナタリ
33 
34 >>> print("\n".join(my_wrap(s.strip(), width=24)))
35 Perfume×伊勢丹コラボ!新
36 PV制作、ハイヒール販売、
37 館内マップも ナタリー
38 >>> print("\n".join(my_wrap(s.strip(), width=25)))
39 Perfume×伊勢丹コラボ!新
40 PV制作、ハイヒール販売、
41 館内マップも ナタリー
42 >>> print("\n".join(my_wrap(s.strip(), width=26)))
43 Perfume×伊勢丹コラボ!新PV
44 制作、ハイヒール販売、館内
45 マップも ナタリー
46 >>> print("\n".join(my_wrap(s.strip(), width=27)))
47 Perfume×伊勢丹コラボ!新PV
48 制作、ハイヒール販売、館内
49 マップも ナタリー
50 >>> print("\n".join(my_wrap(s.strip(), width=28)))
51 Perfume×伊勢丹コラボ!新PV制
52 作、ハイヒール販売、館内マッ
53 プも ナタリー
54 >>> print("\n".join(my_wrap(s.strip(), width=6)))
55 Perfu
56 me×伊
57 勢丹コ
58 ラボ!
59 新PV制
60 作、ハ
61 イヒー
62 ル販売
63 、館内
64 マップ
65 も ナ
66 タリー
67 >>> print("\n".join(my_wrap(s.strip(), width=6, break_long_words=False)))
68 Perfume×
69 伊勢丹
70 コラボ
71 !新PV
72 制作、
73 ハイヒ
74 ール販
75 売、館
76 内マッ
77 プも
78 ナタリ
79 

たぶん Sphinx のでほとんど良いんだよね。ただ、理想を言えば「ナタリー」は本来は禁則が適用されるべきで、切って欲しくないわけだ。

で、思ったんだけれど、オリジナルの方の textwrap の振る舞いでたとえば:

 1 >>> # 適宜空白挿入
 2 >>> s = "Perfume ×伊勢丹コラボ! 新 PV 制作、 ハイヒール販売、 館内マップも ナタリー"
 3 >>> #
 4 >>> print("\n".join(textwrap.wrap(s.strip(), width=19)))
 5 Perfume ×伊勢丹コラボ! 新
 6 PV 制作、 ハイヒール販売、
 7 館内マップも ナタリー
 8 >>> print("\n".join(textwrap.wrap(s.strip(), width=20)))
 9 Perfume ×伊勢丹コラボ! 新
10 PV 制作、 ハイヒール販売、
11 館内マップも ナタリー
12 >>> print("\n".join(textwrap.wrap(s.strip(), width=21)))
13 Perfume ×伊勢丹コラボ! 新 PV
14 制作、 ハイヒール販売、 館内マップも
15 ナタリー
16 >>> print("\n".join(textwrap.wrap(s.strip(), width=28)))
17 Perfume ×伊勢丹コラボ! 新 PV 制作、
18 ハイヒール販売、 館内マップも ナタリー
19 >>> print("\n".join(textwrap.wrap(s.strip(), width=28, break_long_words=False)))
20 Perfume ×伊勢丹コラボ! 新 PV 制作、
21 ハイヒール販売、 館内マップも ナタリー
22 >>> print("\n".join(textwrap.wrap(s.strip(), width=22)))
23 Perfume ×伊勢丹コラボ! 新 PV
24 制作、 ハイヒール販売、 館内マップも
25 ナタリー
26 >>> print("\n".join(textwrap.wrap(s.strip(), width=22, break_long_words=False)))
27 Perfume ×伊勢丹コラボ! 新 PV
28 制作、 ハイヒール販売、 館内マップも
29 ナタリー
30 >>> print("\n".join(textwrap.wrap(s.strip(), width=23)))
31 Perfume ×伊勢丹コラボ! 新 PV
32 制作、 ハイヒール販売、 館内マップも
33 ナタリー
34 >>> print("\n".join(textwrap.wrap(s.strip(), width=23, break_long_words=False)))
35 Perfume ×伊勢丹コラボ! 新 PV
36 制作、 ハイヒール販売、 館内マップも
37 ナタリー
38 >>> print("\n".join(textwrap.wrap(s.strip(), width=24)))
39 Perfume ×伊勢丹コラボ! 新 PV
40 制作、 ハイヒール販売、 館内マップも ナタリー
41 >>> print("\n".join(textwrap.wrap(s.strip(), width=24, break_long_words=False)))
42 Perfume ×伊勢丹コラボ! 新 PV
43 制作、 ハイヒール販売、 館内マップも ナタリー
44 >>> print("\n".join(textwrap.wrap(s.strip(), width=25)))
45 Perfume ×伊勢丹コラボ! 新 PV 制作、
46 ハイヒール販売、 館内マップも ナタリー
47 >>> print("\n".join(textwrap.wrap(s.strip(), width=25, break_long_words=False)))
48 Perfume ×伊勢丹コラボ! 新 PV 制作、
49 ハイヒール販売、 館内マップも ナタリー
50 >>> print("\n".join(textwrap.wrap(s.strip(), width=26)))
51 Perfume ×伊勢丹コラボ! 新 PV 制作、
52 ハイヒール販売、 館内マップも ナタリー
53 >>> print("\n".join(textwrap.wrap(s.strip(), width=26, break_long_words=False)))
54 Perfume ×伊勢丹コラボ! 新 PV 制作、
55 ハイヒール販売、 館内マップも ナタリー
56 >>> print("\n".join(textwrap.wrap(s.strip(), width=6)))
57 Perfum
58 e ×伊勢丹
59 コラボ! 新
60 PV 制作、
61 ハイヒール販
62 売、
63 館内マップも
64 ナタリー
65 >>> print("\n".join(textwrap.wrap(s.strip(), width=6, break_long_words=False)))
66 Perfume
67 ×伊勢丹コラボ!
68 新 PV
69 制作、
70 ハイヒール販売、
71 館内マップも
72 ナタリー

という具合で、「切るべきポイントが 分かち書きでないので 見つからない!!」という事態そのものをちょっとばかし改善してあげる限り、オリジナルの textwrap でもそれなりに良い結果を生成してくるんだよね。

であれば:

 1 >>> import re
 2 >>> import textwrap
 3 >>> #
 4 >>> #
 5 >>> def _wrap(s, **textwrapargs):
 6 ...     s = re.sub(r'([A-Za-z_./-]+)(?![。、A-Za-z_./-])', r"\1 ", s)
 7 ...     s = re.sub(r'(?<![。、A-Za-z_0-9./-])([A-Za-z0-9_./-]+)', r" \1", s)
 8 ...     s = re.sub(r"\s+", " ", re.sub(r"([。、・)」&!』])", r"\1 ", s))
 9 ...     return "\n".join(textwrap.wrap(s.strip(), **textwrapargs))
10 ...
11 >>> #
12 >>> s = "Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売、館内マップも ナタリー"
13 >>> print(_wrap(s, width=18))
14 Perfume ×伊勢丹コラボ! 新
15 PV 制作、 ハイヒール販売、
16 館内マップも ナタリー
17 >>> print(_wrap(s, width=19))
18 Perfume ×伊勢丹コラボ! 新
19 PV 制作、 ハイヒール販売、
20 館内マップも ナタリー
21 >>> print(_wrap(s, width=20))
22 Perfume ×伊勢丹コラボ! 新
23 PV 制作、 ハイヒール販売、
24 館内マップも ナタリー
25 >>> print(_wrap(s, width=21))
26 Perfume ×伊勢丹コラボ! 新 PV
27 制作、 ハイヒール販売、 館内マップも
28 ナタリー
29 >>> print(_wrap(s, width=22))
30 Perfume ×伊勢丹コラボ! 新 PV
31 制作、 ハイヒール販売、 館内マップも
32 ナタリー
33 >>> print(_wrap(s, width=6))
34 Perfum
35 e ×伊勢丹
36 コラボ! 新
37 PV 制作、
38 ハイヒール販
39 売、
40 館内マップも
41 ナタリー
42 >>> print(_wrap(s, width=6, break_long_words=False))
43 Perfume
44 ×伊勢丹コラボ!
45 新 PV
46 制作、
47 ハイヒール販売、
48 館内マップも
49 ナタリー

なお、 _wrap 内の空白挿入部分単独の振る舞いはたとえばこんなね:

 1 >>> def _mk_splittable(s):
 2 ...     s = re.sub(r'([A-Za-z_./-]+)(?![。、A-Za-z_./-])', r"\1 ", s)
 3 ...     s = re.sub(r'(?<![。、A-Za-z_0-9./-])([A-Za-z0-9_./-]+)', r" \1", s)
 4 ...     s = re.sub(r"\s+", " ", re.sub(r"([。、・)」&!』])", r"\1 ", s))
 5 ...     return s.strip()
 6 ...
 7 >>> #
 8 >>> s
 9 'Perfume×伊勢丹コラボ!新PV制作、ハイヒール販売、館内マップも ナタリー'
10 >>> _mk_splittable(s)
11 'Perfume ×伊勢丹コラボ! 新 PV 制作、 ハイヒール販売、 館内マップも ナタリー'
12 >>> #
13 >>> s = "Perfume代々木最終公演がBD/DVD化、密着ドキュメントも収録 ナタリー"
14 >>> _mk_splittable(s)
15 'Perfume 代々木最終公演が BD/DVD 化、 密着ドキュメントも収録 ナタリー'
16 >>> #
17 >>> s = "“Perfumeが52曲入りベストアルバム「知ってるけど聴いたことがない人たちにも」”."
18 >>> _mk_splittable(s)
19 '“ Perfume が 52曲入りベストアルバム「知ってるけど聴いたことがない人たちにも」 ” .'

例によって完璧であるとは言わない。例にしたものだけみてもちょっとヘンだし(”の後の空白)。ただ、私と同じく「ヒド過ぎなければいい」程度なら、これでも結構使えるんじゃないかしらと思う。