Skip to content

Commit df3f367

Browse files
committed
bpo-12499: textwrap.wrap: add control for fonts with different character widths
This also provides a generic solution for bpo-24665
1 parent b4b6342 commit df3f367

3 files changed

Lines changed: 39 additions & 9 deletions

File tree

Doc/library/textwrap.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,12 @@ hyphenated words; only then will long words be broken if necessary, unless
281281
.. versionadded:: 3.4
282282

283283

284+
.. attribute:: text_len
285+
286+
(default: ``len``) Used to determine the length of a string. You can
287+
provide a custom function, e.g. to account for wide characters.
288+
289+
284290
.. index:: single: ...; placeholder
285291

286292
.. attribute:: placeholder

Lib/test/test_textwrap.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#
1010

1111
import unittest
12+
import unicodedata
1213

1314
from textwrap import TextWrapper, wrap, fill, dedent, indent, shorten
1415

@@ -1076,5 +1077,24 @@ def test_first_word_too_long_but_placeholder_fits(self):
10761077
self.check_shorten("Helloo", 5, "[...]")
10771078

10781079

1080+
class WideCharacterTestCase(BaseTestCase):
1081+
def setUp(self):
1082+
def text_len(text):
1083+
n = 0
1084+
for c in text:
1085+
if unicodedata.east_asian_width(c) in ['F', 'W']:
1086+
n += 2
1087+
else:
1088+
n += 1
1089+
return n
1090+
1091+
self.wrapper = TextWrapper(width=5, text_len=text_len)
1092+
1093+
def test_wide_character(self):
1094+
text = "123 🔧"
1095+
result = self.wrapper.wrap(text, **kwargs)
1096+
self.check(result, ["123", "🔧"])
1097+
1098+
10791099
if __name__ == '__main__':
10801100
unittest.main()

Lib/textwrap.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ def __init__(self,
125125
tabsize=8,
126126
*,
127127
max_lines=None,
128-
placeholder=' [...]'):
128+
placeholder=' [...]',
129+
text_len=len):
129130
self.width = width
130131
self.initial_indent = initial_indent
131132
self.subsequent_indent = subsequent_indent
@@ -138,6 +139,7 @@ def __init__(self,
138139
self.tabsize = tabsize
139140
self.max_lines = max_lines
140141
self.placeholder = placeholder
142+
self.text_len = text_len
141143

142144

143145
# -- Private methods -----------------------------------------------
@@ -217,7 +219,7 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
217219
if self.break_long_words:
218220
end = space_left
219221
chunk = reversed_chunks[-1]
220-
if self.break_on_hyphens and len(chunk) > space_left:
222+
if self.break_on_hyphens and self.text_len(chunk) > space_left:
221223
# break after last hyphen, but only if there are
222224
# non-hyphens before it
223225
hyphen = chunk.rfind('-', 0, space_left)
@@ -259,7 +261,8 @@ def _wrap_chunks(self, chunks):
259261
indent = self.subsequent_indent
260262
else:
261263
indent = self.initial_indent
262-
if len(indent) + len(self.placeholder.lstrip()) > self.width:
264+
if self.text_len(indent) +
265+
self.text_len(self.placeholder.lstrip()) > self.width:
263266
raise ValueError("placeholder too large for max width")
264267

265268
# Arrange in reverse order so items can be efficiently popped
@@ -280,7 +283,7 @@ def _wrap_chunks(self, chunks):
280283
indent = self.initial_indent
281284

282285
# Maximum width for this line.
283-
width = self.width - len(indent)
286+
width = self.width - self.text_len(indent)
284287

285288
# First chunk on line is whitespace -- drop it, unless this
286289
# is the very beginning of the text (ie. no lines started yet).
@@ -303,11 +306,11 @@ def _wrap_chunks(self, chunks):
303306
# fit on *any* line (not just this one).
304307
if chunks and len(chunks[-1]) > width:
305308
self._handle_long_word(chunks, cur_line, cur_len, width)
306-
cur_len = sum(map(len, cur_line))
309+
cur_len = sum(map(self.text_len, cur_line))
307310

308311
# If the last chunk on this line is all whitespace, drop it.
309312
if self.drop_whitespace and cur_line and cur_line[-1].strip() == '':
310-
cur_len -= len(cur_line[-1])
313+
cur_len -= self.text_len(cur_line[-1])
311314
del cur_line[-1]
312315

313316
if cur_line:
@@ -323,16 +326,17 @@ def _wrap_chunks(self, chunks):
323326
else:
324327
while cur_line:
325328
if (cur_line[-1].strip() and
326-
cur_len + len(self.placeholder) <= width):
329+
cur_len + self.text_len(self.placeholder) <= width):
327330
cur_line.append(self.placeholder)
328331
lines.append(indent + ''.join(cur_line))
329332
break
330-
cur_len -= len(cur_line[-1])
333+
cur_len -= self.text_len(cur_line[-1])
331334
del cur_line[-1]
332335
else:
333336
if lines:
334337
prev_line = lines[-1].rstrip()
335-
if (len(prev_line) + len(self.placeholder) <=
338+
if (self.text_len(prev_line) +
339+
self.text_len(self.placeholder) <=
336340
self.width):
337341
lines[-1] = prev_line + self.placeholder
338342
break

0 commit comments

Comments
 (0)