对字符串使用 Python textwrap.shorten 但字节宽度
Using Python textwrap.shorten for string but with bytes width
我想使用 textwrap.shorten
或类似的函数来缩短字符串。该字符串可能包含非 ASCII 字符。这里的特殊之处在于 最大 width
是针对字符串 bytes
编码的 。这个问题的动机是几个数据库列定义和一些消息总线有一个基于 bytes
的最大长度。
例如:
>>> import textwrap
>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
# Available function that I tried:
>>> textwrap.shorten(s, width=27)
'☺ Ilsa, le méchant ☺ [...]'
>>> len(_.encode())
31 # I want ⩽27
# Desired function:
>>> shorten_to_bytes_width(s, width=27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27 # I want and get ⩽27
实现使用宽度大于或等于去除空白的占位符 [...]
的长度是可以的,即 5.
文本不应超过必要的长度。一些错误的实现可以使用优化,有时会导致过度缩短。
是一个类似的问题,但它与这个问题有很大不同,因为它是关于 textwrap.wrap
,而不是 textwrap.shorten
。只有后一个函数使用 placeholder
([...]
) 这使得这个问题足够独特。
警告:不要依赖此处的任何答案来缩短固定字节数的 JSON 编码字符串。为此,将 text.encode()
替换为 json.dumps(text)
.
这个解决方案效率低下,但它似乎总是能正常工作,而且不会过度缩短。它作为测试任何有效解决方案的规范基线。
它首先假装文本是ASCII字符串来缩短;这可以缩短不足但绝不会过度。然后它会低效地一次缩短一个字符,而且不会超过必要的长度。
import textwrap
_MIN_WIDTH = 5 # == len(textwrap.shorten(string.ascii_letters, len(string.ascii_letters) - 1)) == len('[...]')
def shorten_to_bytes_width(text: str, width: int) -> str:
# Ref:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < _MIN_WIDTH
text = textwrap.shorten(text, width) # After this line, len(text.encode()) >= width
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
致谢:感谢 Sanyash 的改进。
测试
>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
>>> shorten_to_bytes_width(s, 27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27
测试候选答案
任何候选答案都可以通过将其输出与我的 width
或 range(50, -1, -1)
或至少 range(50, 5, -1)
函数的输出进行比较来测试。给定一个 candidate
函数,下面的代码实现了单元测试:
import unittest
class TestShortener(unittest.TestCase):
def test_candidate(self):
text = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
for width in range(50, -1, -1):
with self.subTest(width=width):
self.assertEqual(shorten_to_bytes_width(text, width), candidate(text, width))
理论上 encode
您的字符串就足够了,然后检查它是否符合 "width" 约束。如果是,则可以简单地对字符串进行 returned。否则,您可以从编码字符串中取出前 "width" 个字节(减去占位符所需的字节)。为了确保它像 textwrap.shorten
一样工作,还需要找到剩余字节中的最后一个空格和 return 空格 + 占位符之前的所有内容。如果没有空格,只需要 returned.
占位符
鉴于您提到您确实需要它 byte-amount 约束,如果占位符太大,函数将抛出异常。因为有一个不适合 byte-constrained container/datastructure 的占位符根本没有意义,并且避免了很多可能导致 "maximum byte size" 和 "placeholder byte size" 不一致的边缘情况.
代码可能如下所示:
def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
# Make sure the placeholder satisfies the byte length requirement
encoded_placeholder = placeholder.encode().strip()
if maximum_bytes < len(encoded_placeholder):
raise ValueError('placeholder too large for max width')
# Get the UTF-8 bytes that represent the string and (optionally) normalize the spaces.
if normalize_spaces:
string = " ".join(string.split())
encoded_string = string.encode()
# If the input string is empty simply return an empty string.
if not encoded_string:
return ''
# In case we don't need to shorten anything simply return
if len(encoded_string) <= maximum_bytes:
return string
# We need to shorten the string, so we need to add the placeholder
substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
splitted = substring.rsplit(b' ', 1) # Split at last space-character
if len(splitted) == 2:
return b" ".join([splitted[0], encoded_placeholder]).decode()
else:
return '[...]'
还有一个简单的测试用例:
t = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
for i in range(5, 50):
shortened = shorten_rsplit(t, i)
byte_length = len(shortened.encode())
print(byte_length <= i, i, byte_length, shortened)
哪个returns
True 5 5 [...]
True 6 5 [...]
True 7 5 [...]
True 8 5 [...]
True 9 9 ☺ [...]
True 10 9 ☺ [...]
True 11 9 ☺ [...]
True 12 9 ☺ [...]
True 13 9 ☺ [...]
True 14 9 ☺ [...]
True 15 15 ☺ Ilsa, [...]
True 16 15 ☺ Ilsa, [...]
True 17 15 ☺ Ilsa, [...]
True 18 18 ☺ Ilsa, le [...]
True 19 18 ☺ Ilsa, le [...]
True 20 18 ☺ Ilsa, le [...]
True 21 18 ☺ Ilsa, le [...]
True 22 18 ☺ Ilsa, le [...]
True 23 18 ☺ Ilsa, le [...]
True 24 18 ☺ Ilsa, le [...]
True 25 18 ☺ Ilsa, le [...]
True 26 18 ☺ Ilsa, le [...]
True 27 27 ☺ Ilsa, le méchant [...]
True 28 27 ☺ Ilsa, le méchant [...]
True 29 27 ☺ Ilsa, le méchant [...]
True 30 27 ☺ Ilsa, le méchant [...]
True 31 31 ☺ Ilsa, le méchant ☺ [...]
True 32 31 ☺ Ilsa, le méchant ☺ [...]
True 33 31 ☺ Ilsa, le méchant ☺ [...]
True 34 31 ☺ Ilsa, le méchant ☺ [...]
True 35 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 36 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 37 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 38 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 39 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 40 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 41 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 42 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 43 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 44 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 45 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 46 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 47 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 48 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 49 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
该函数还有一个用于标准化空格的参数。如果您有不同类型的空格(换行符等)或多个连续空格,这可能会有所帮助。虽然会慢一点。
性能
我使用 simple_benchmark
(我编写的库)进行了快速测试,以确保它实际上更快。
对于基准测试,我创建了一个包含随机 unicode 字符的字符串,其中(平均)8 个字符中有一个是空格。我也用字符串长度的一半作为byte-width来拆分。两者都没有特殊原因,但它可能会使基准产生偏差,这就是我想提及它的原因。
基准测试中使用的函数:
def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
encoded_placeholder = placeholder.encode().strip()
if maximum_bytes < len(encoded_placeholder):
raise ValueError('placeholder too large for max width')
if normalize_spaces:
string = " ".join(string.split())
encoded_string = string.encode()
if not encoded_string:
return ''
if len(encoded_string) <= maximum_bytes:
return string
substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
splitted = substring.rsplit(b' ', 1) # Split at last space-character
if len(splitted) == 2:
return b" ".join([splitted[0], encoded_placeholder]).decode()
else:
return '[...]'
import textwrap
_MIN_WIDTH = 5
def shorten_to_bytes_width(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width)
text = textwrap.shorten(text, width)
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
def naive(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width)
text = textwrap.shorten(text, width)
if len(text.encode()) <= width:
return text
current_width = _MIN_WIDTH
index = 0
slice_index = 0
endings = ' '
while True:
new_width = current_width + len(text[index].encode())
if new_width > width:
break
if text[index] in endings:
slice_index = index
index += 1
current_width = new_width
if slice_index:
slice_index += 1 # to include found space
text = text[:slice_index] + '[...]'
assert len(text.encode()) <= width
return text
MAX_BYTES_PER_CHAR = 4
def bytes_to_char_length(input, bytes, start=0, max_length=None):
if bytes <= 0 or (max_length is not None and max_length <= 0):
return 0
if max_length is None:
max_length = min(bytes, len(input) - start)
bytes_too_much = len(input[start:start + max_length].encode()) - bytes
if bytes_too_much <= 0:
return max_length
min_length = max(max_length - bytes_too_much, bytes // MAX_BYTES_PER_CHAR)
max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR
new_start = start + min_length
bytes_left = bytes - len(input[start:new_start].encode())
return min_length + bytes_to_char_length(input, bytes_left, new_start, max_length - min_length)
def shorten_to_bytes(input, bytes, placeholder=' [...]', start=0):
if len(input[start:start + bytes + 1].encode()) <= bytes:
return input
bytes -= len(placeholder.encode())
max_chars = bytes_to_char_length(input, bytes, start)
if max_chars <= 0:
return placeholder.strip() if bytes >= 0 else ''
w = input.rfind(' ', start, start + max_chars + 1)
if w > 0:
return input[start:w] + placeholder
else:
return input[start:start + max_chars] + placeholder
# Benchmark
from simple_benchmark import benchmark, MultiArgument
import random
def get_random_unicode(length): #
get_char = chr
include_ranges = [
(0x0021, 0x0021), (0x0023, 0x0026), (0x0028, 0x007E), (0x00A1, 0x00AC), (0x00AE, 0x00FF),
(0x0100, 0x017F), (0x0180, 0x024F), (0x2C60, 0x2C7F), (0x16A0, 0x16F0), (0x0370, 0x0377),
(0x037A, 0x037E), (0x0384, 0x038A), (0x038C, 0x038C)
]
alphabet = [
get_char(code_point) for current_range in include_ranges
for code_point in range(current_range[0], current_range[1] + 1)
]
# Add more whitespaces
for _ in range(len(alphabet) // 8):
alphabet.append(' ')
return ''.join(random.choice(alphabet) for i in range(length))
r = benchmark(
[shorten_rsplit, shorten_to_bytes, shorten_to_bytes_width, naive, bytes_to_char_length],
{2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 15)},
"string length"
)
我还做了第二个基准测试,不包括 shorten_to_bytes_width
函数,所以我可以对更长的字符串进行基准测试:
r = benchmark(
[shorten_rsplit, shorten_to_bytes, naive],
{2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 20)},
"string length"
)
这是一个尝试直接解决此问题的解决方案,无需使用不同的输入字符串 textwrap.shorten()
进行反复试验。
它使用基于对字符串的最小和最大长度的有根据猜测的递归算法。部分解决方案(基于猜测的最小长度)用于快速减小问题大小。
解决方案分为两部分:
bytes_to_char_length()
计算适合多个字节的字符串中的最大字符数(有关其工作原理的示例,请参见下文)。
shorten_to_bytes()
使用 bytes_to_char_length()
的结果来计算占位符位置。
MAX_BYTES_PER_CHAR = 4
def bytes_to_char_length(input, bytes_left, start=0, max_length=None):
if bytes_left <= 0 or (max_length is not None and max_length <= 0):
return 0
if max_length is None:
max_length = min(bytes_left, len(input) - start)
bytes_too_much = len(input[start:start + max_length].encode()) - bytes_left
if bytes_too_much <= 0:
return max_length
# Conservative estimate for the min_length assuming all chars at the end were
# only 1 Byte.
min_length = max(max_length - bytes_too_much, bytes_left // MAX_BYTES_PER_CHAR)
# Generous estimate for the new max_length assuming all chars at the end of
# max_string were MAX_BYTES_PER_CHAR sized.
max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR
# Now take `min_length` as a partial solution and call the function
# recursively to fill the remaining bytes.
new_start = start + min_length
bytes_left -= len(input[start:new_start].encode())
return min_length + bytes_to_char_length(input, bytes_left, new_start, max_length - min_length)
def shorten_to_bytes(text, byte_width, placeholder='', start=0):
if len(text[start:start + byte_width + 1].encode()) <= byte_width:
return text
byte_width_p = byte_width - len(placeholder.encode())
if byte_width_p <= 0:
p = placeholder.strip()
return p if len(p.encode()) <= byte_width else ''
max_chars = bytes_to_char_length(text, byte_width_p, start)
# Find rightmost whitespace if any
w = text.rfind(' ', start, start + max_chars + 1)
if w > 0:
return text[start:w] + placeholder
else:
return text[start:start + max_chars] + placeholder
bytes_to_char_length()
工作原理的示例
为了便于说明,我们假设字符串中的每个数字都被编码为其以字节为单位的值。所以'1'
、'2'
、'3'
、'4'
分别取1、2、3、4个字节。
对于 bytes_to_char_length('11111', 3)
我们将得到:
max_length
默认设置为 3
。
input[start:start + max_length] = '111'
有3个字节,所以bytes_too_much = 0
- 这就是我们要找的确切尺寸,所以我们完成了。
对于bytes_to_char_length('441111', 10)
:
max_length
设置为 6
input[start:start + max_length] = '441111'
有 12 个字节,所以 bytes_too_much = 2
min_length
设置为 max_length - 2 == 4
。 (最多2个字符占2个字节)。
max_length
减1(至少需要1个字符才能占用2个Bytes)。
bytes_left = 0
、max_length = 1
- 立即递归调用 returns
0
因为没有剩余字节。结果是 min_length + 0 == 4
.
对于bytes_to_char_length('111144', 10)
:
max_length
设置为 6
(和以前一样)
input[start:start + max_length] = '111144'
有 12 个字节,所以 bytes_too_much = 2
min_length
设置为 max_length - 2 == 4
max_length
减1.
new_start = 4
、remaining_bytes = 6
、max_length = 1
- 递归调用:
4 + bytes_to_char_length('111144', 6, start=4, max_length=1)
input[start:start + max_length] = '4'
有 4 个字节,所以 bytes_too_much = -2
- 立即return从returning
max_length == 1
的递归,return 5
作为结果。
形式上它做出以下假设:
- 每个字符在编码字符串中至少占用一个字节。
- 编码后的字符串中每个字符至少占
MAX_BYTES_BY_CHAR
- 对于两个子字符串,如果我将字符串
s
拆分为子字符串 s == s1 + s2
,则 s.encode() == s1.encode() + s2.encode()
性能
- 即使对于长输入字符串,它也应该能顺利运行,因为它避免了复制字符串。
- 根据我的 timeit 测量,对于简单的测试用例,它大约快一个数量级。
我将提出一个简单的解决方案,其中包含一个循环并检查 len(text[index].encode())
等编码字符的长度。还添加了
中提出的改进时间
import textwrap, timeit
_MIN_WIDTH = 5
def A_B_B(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < _MIN_WIDTH
text = textwrap.shorten(text, width) # After this line, len(text.encode()) >= width
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
def naive(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < TEXTWRAP_MIN_WIDTH
# textwrap.shorten does a lot of work like merging several spaces into one,
# so we will use it first
text = textwrap.shorten(text, width)
if len(text.encode()) <= width:
return text
current_width = _MIN_WIDTH # len of placeholder
index = 0
slice_index = 0 # we will do a slice on a last found space if necessary
# (to avoid slicing in a middle of a word, for example)
endings = ' ' # there also can be some more endings like \t \n
while True:
# we will use the fact that if str = str1 + str2 then
# len(str.encode()) = len(str1.encode()) + len(str2.encode())
new_width = current_width + len(text[index].encode()) # taking one more character
if new_width > width:
break
if text[index] in endings:
slice_index = index
index += 1
current_width = new_width
if slice_index: # slice_index = 0 is a special case
# when we dont go further than end of first word
slice_index += 1 # to include found space
text = text[:slice_index] + '[...]'
assert len(text.encode()) <= width
return text
s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
n = 27
print(timeit.timeit(lambda: A_B_B(s, n), number=1000))
print(timeit.timeit(lambda: naive(s, n), number=1000))
时间安排:
0.032570790994213894
0.0206866109801922
我想使用 textwrap.shorten
或类似的函数来缩短字符串。该字符串可能包含非 ASCII 字符。这里的特殊之处在于 最大 width
是针对字符串 bytes
编码的 。这个问题的动机是几个数据库列定义和一些消息总线有一个基于 bytes
的最大长度。
例如:
>>> import textwrap
>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
# Available function that I tried:
>>> textwrap.shorten(s, width=27)
'☺ Ilsa, le méchant ☺ [...]'
>>> len(_.encode())
31 # I want ⩽27
# Desired function:
>>> shorten_to_bytes_width(s, width=27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27 # I want and get ⩽27
实现使用宽度大于或等于去除空白的占位符 [...]
的长度是可以的,即 5.
文本不应超过必要的长度。一些错误的实现可以使用优化,有时会导致过度缩短。
textwrap.wrap
,而不是 textwrap.shorten
。只有后一个函数使用 placeholder
([...]
) 这使得这个问题足够独特。
警告:不要依赖此处的任何答案来缩短固定字节数的 JSON 编码字符串。为此,将 text.encode()
替换为 json.dumps(text)
.
这个解决方案效率低下,但它似乎总是能正常工作,而且不会过度缩短。它作为测试任何有效解决方案的规范基线。
它首先假装文本是ASCII字符串来缩短;这可以缩短不足但绝不会过度。然后它会低效地一次缩短一个字符,而且不会超过必要的长度。
import textwrap
_MIN_WIDTH = 5 # == len(textwrap.shorten(string.ascii_letters, len(string.ascii_letters) - 1)) == len('[...]')
def shorten_to_bytes_width(text: str, width: int) -> str:
# Ref:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < _MIN_WIDTH
text = textwrap.shorten(text, width) # After this line, len(text.encode()) >= width
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
致谢:感谢 Sanyash 的改进。
测试
>>> s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
>>> shorten_to_bytes_width(s, 27)
'☺ Ilsa, le méchant [...]'
>>> len(_.encode())
27
测试候选答案
任何候选答案都可以通过将其输出与我的 width
或 range(50, -1, -1)
或至少 range(50, 5, -1)
函数的输出进行比较来测试。给定一个 candidate
函数,下面的代码实现了单元测试:
import unittest
class TestShortener(unittest.TestCase):
def test_candidate(self):
text = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
for width in range(50, -1, -1):
with self.subTest(width=width):
self.assertEqual(shorten_to_bytes_width(text, width), candidate(text, width))
理论上 encode
您的字符串就足够了,然后检查它是否符合 "width" 约束。如果是,则可以简单地对字符串进行 returned。否则,您可以从编码字符串中取出前 "width" 个字节(减去占位符所需的字节)。为了确保它像 textwrap.shorten
一样工作,还需要找到剩余字节中的最后一个空格和 return 空格 + 占位符之前的所有内容。如果没有空格,只需要 returned.
鉴于您提到您确实需要它 byte-amount 约束,如果占位符太大,函数将抛出异常。因为有一个不适合 byte-constrained container/datastructure 的占位符根本没有意义,并且避免了很多可能导致 "maximum byte size" 和 "placeholder byte size" 不一致的边缘情况.
代码可能如下所示:
def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
# Make sure the placeholder satisfies the byte length requirement
encoded_placeholder = placeholder.encode().strip()
if maximum_bytes < len(encoded_placeholder):
raise ValueError('placeholder too large for max width')
# Get the UTF-8 bytes that represent the string and (optionally) normalize the spaces.
if normalize_spaces:
string = " ".join(string.split())
encoded_string = string.encode()
# If the input string is empty simply return an empty string.
if not encoded_string:
return ''
# In case we don't need to shorten anything simply return
if len(encoded_string) <= maximum_bytes:
return string
# We need to shorten the string, so we need to add the placeholder
substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
splitted = substring.rsplit(b' ', 1) # Split at last space-character
if len(splitted) == 2:
return b" ".join([splitted[0], encoded_placeholder]).decode()
else:
return '[...]'
还有一个简单的测试用例:
t = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
for i in range(5, 50):
shortened = shorten_rsplit(t, i)
byte_length = len(shortened.encode())
print(byte_length <= i, i, byte_length, shortened)
哪个returns
True 5 5 [...]
True 6 5 [...]
True 7 5 [...]
True 8 5 [...]
True 9 9 ☺ [...]
True 10 9 ☺ [...]
True 11 9 ☺ [...]
True 12 9 ☺ [...]
True 13 9 ☺ [...]
True 14 9 ☺ [...]
True 15 15 ☺ Ilsa, [...]
True 16 15 ☺ Ilsa, [...]
True 17 15 ☺ Ilsa, [...]
True 18 18 ☺ Ilsa, le [...]
True 19 18 ☺ Ilsa, le [...]
True 20 18 ☺ Ilsa, le [...]
True 21 18 ☺ Ilsa, le [...]
True 22 18 ☺ Ilsa, le [...]
True 23 18 ☺ Ilsa, le [...]
True 24 18 ☺ Ilsa, le [...]
True 25 18 ☺ Ilsa, le [...]
True 26 18 ☺ Ilsa, le [...]
True 27 27 ☺ Ilsa, le méchant [...]
True 28 27 ☺ Ilsa, le méchant [...]
True 29 27 ☺ Ilsa, le méchant [...]
True 30 27 ☺ Ilsa, le méchant [...]
True 31 31 ☺ Ilsa, le méchant ☺ [...]
True 32 31 ☺ Ilsa, le méchant ☺ [...]
True 33 31 ☺ Ilsa, le méchant ☺ [...]
True 34 31 ☺ Ilsa, le méchant ☺ [...]
True 35 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 36 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 37 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 38 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 39 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 40 35 ☺ Ilsa, le méchant ☺ ☺ [...]
True 41 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 42 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 43 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 44 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 45 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 46 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 47 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 48 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
True 49 41 ☺ Ilsa, le méchant ☺ ☺ gardien ☺
该函数还有一个用于标准化空格的参数。如果您有不同类型的空格(换行符等)或多个连续空格,这可能会有所帮助。虽然会慢一点。
性能
我使用 simple_benchmark
(我编写的库)进行了快速测试,以确保它实际上更快。
对于基准测试,我创建了一个包含随机 unicode 字符的字符串,其中(平均)8 个字符中有一个是空格。我也用字符串长度的一半作为byte-width来拆分。两者都没有特殊原因,但它可能会使基准产生偏差,这就是我想提及它的原因。
基准测试中使用的函数:
def shorten_rsplit(string: str, maximum_bytes: int, normalize_spaces: bool = False, placeholder: str = "[...]") -> str:
encoded_placeholder = placeholder.encode().strip()
if maximum_bytes < len(encoded_placeholder):
raise ValueError('placeholder too large for max width')
if normalize_spaces:
string = " ".join(string.split())
encoded_string = string.encode()
if not encoded_string:
return ''
if len(encoded_string) <= maximum_bytes:
return string
substring = encoded_string[:maximum_bytes - len(encoded_placeholder)]
splitted = substring.rsplit(b' ', 1) # Split at last space-character
if len(splitted) == 2:
return b" ".join([splitted[0], encoded_placeholder]).decode()
else:
return '[...]'
import textwrap
_MIN_WIDTH = 5
def shorten_to_bytes_width(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width)
text = textwrap.shorten(text, width)
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
def naive(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width)
text = textwrap.shorten(text, width)
if len(text.encode()) <= width:
return text
current_width = _MIN_WIDTH
index = 0
slice_index = 0
endings = ' '
while True:
new_width = current_width + len(text[index].encode())
if new_width > width:
break
if text[index] in endings:
slice_index = index
index += 1
current_width = new_width
if slice_index:
slice_index += 1 # to include found space
text = text[:slice_index] + '[...]'
assert len(text.encode()) <= width
return text
MAX_BYTES_PER_CHAR = 4
def bytes_to_char_length(input, bytes, start=0, max_length=None):
if bytes <= 0 or (max_length is not None and max_length <= 0):
return 0
if max_length is None:
max_length = min(bytes, len(input) - start)
bytes_too_much = len(input[start:start + max_length].encode()) - bytes
if bytes_too_much <= 0:
return max_length
min_length = max(max_length - bytes_too_much, bytes // MAX_BYTES_PER_CHAR)
max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR
new_start = start + min_length
bytes_left = bytes - len(input[start:new_start].encode())
return min_length + bytes_to_char_length(input, bytes_left, new_start, max_length - min_length)
def shorten_to_bytes(input, bytes, placeholder=' [...]', start=0):
if len(input[start:start + bytes + 1].encode()) <= bytes:
return input
bytes -= len(placeholder.encode())
max_chars = bytes_to_char_length(input, bytes, start)
if max_chars <= 0:
return placeholder.strip() if bytes >= 0 else ''
w = input.rfind(' ', start, start + max_chars + 1)
if w > 0:
return input[start:w] + placeholder
else:
return input[start:start + max_chars] + placeholder
# Benchmark
from simple_benchmark import benchmark, MultiArgument
import random
def get_random_unicode(length): #
get_char = chr
include_ranges = [
(0x0021, 0x0021), (0x0023, 0x0026), (0x0028, 0x007E), (0x00A1, 0x00AC), (0x00AE, 0x00FF),
(0x0100, 0x017F), (0x0180, 0x024F), (0x2C60, 0x2C7F), (0x16A0, 0x16F0), (0x0370, 0x0377),
(0x037A, 0x037E), (0x0384, 0x038A), (0x038C, 0x038C)
]
alphabet = [
get_char(code_point) for current_range in include_ranges
for code_point in range(current_range[0], current_range[1] + 1)
]
# Add more whitespaces
for _ in range(len(alphabet) // 8):
alphabet.append(' ')
return ''.join(random.choice(alphabet) for i in range(length))
r = benchmark(
[shorten_rsplit, shorten_to_bytes, shorten_to_bytes_width, naive, bytes_to_char_length],
{2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 15)},
"string length"
)
我还做了第二个基准测试,不包括 shorten_to_bytes_width
函数,所以我可以对更长的字符串进行基准测试:
r = benchmark(
[shorten_rsplit, shorten_to_bytes, naive],
{2**exponent: MultiArgument([get_random_unicode(2**exponent), 2**exponent // 2]) for exponent in range(4, 20)},
"string length"
)
这是一个尝试直接解决此问题的解决方案,无需使用不同的输入字符串 textwrap.shorten()
进行反复试验。
它使用基于对字符串的最小和最大长度的有根据猜测的递归算法。部分解决方案(基于猜测的最小长度)用于快速减小问题大小。
解决方案分为两部分:
bytes_to_char_length()
计算适合多个字节的字符串中的最大字符数(有关其工作原理的示例,请参见下文)。shorten_to_bytes()
使用bytes_to_char_length()
的结果来计算占位符位置。
MAX_BYTES_PER_CHAR = 4
def bytes_to_char_length(input, bytes_left, start=0, max_length=None):
if bytes_left <= 0 or (max_length is not None and max_length <= 0):
return 0
if max_length is None:
max_length = min(bytes_left, len(input) - start)
bytes_too_much = len(input[start:start + max_length].encode()) - bytes_left
if bytes_too_much <= 0:
return max_length
# Conservative estimate for the min_length assuming all chars at the end were
# only 1 Byte.
min_length = max(max_length - bytes_too_much, bytes_left // MAX_BYTES_PER_CHAR)
# Generous estimate for the new max_length assuming all chars at the end of
# max_string were MAX_BYTES_PER_CHAR sized.
max_length -= (bytes_too_much + MAX_BYTES_PER_CHAR - 1) // MAX_BYTES_PER_CHAR
# Now take `min_length` as a partial solution and call the function
# recursively to fill the remaining bytes.
new_start = start + min_length
bytes_left -= len(input[start:new_start].encode())
return min_length + bytes_to_char_length(input, bytes_left, new_start, max_length - min_length)
def shorten_to_bytes(text, byte_width, placeholder='', start=0):
if len(text[start:start + byte_width + 1].encode()) <= byte_width:
return text
byte_width_p = byte_width - len(placeholder.encode())
if byte_width_p <= 0:
p = placeholder.strip()
return p if len(p.encode()) <= byte_width else ''
max_chars = bytes_to_char_length(text, byte_width_p, start)
# Find rightmost whitespace if any
w = text.rfind(' ', start, start + max_chars + 1)
if w > 0:
return text[start:w] + placeholder
else:
return text[start:start + max_chars] + placeholder
bytes_to_char_length()
工作原理的示例
为了便于说明,我们假设字符串中的每个数字都被编码为其以字节为单位的值。所以'1'
、'2'
、'3'
、'4'
分别取1、2、3、4个字节。
对于 bytes_to_char_length('11111', 3)
我们将得到:
max_length
默认设置为3
。input[start:start + max_length] = '111'
有3个字节,所以bytes_too_much = 0
- 这就是我们要找的确切尺寸,所以我们完成了。
对于bytes_to_char_length('441111', 10)
:
max_length
设置为6
input[start:start + max_length] = '441111'
有 12 个字节,所以bytes_too_much = 2
min_length
设置为max_length - 2 == 4
。 (最多2个字符占2个字节)。max_length
减1(至少需要1个字符才能占用2个Bytes)。bytes_left = 0
、max_length = 1
- 立即递归调用 returns
0
因为没有剩余字节。结果是min_length + 0 == 4
.
对于bytes_to_char_length('111144', 10)
:
max_length
设置为6
(和以前一样)input[start:start + max_length] = '111144'
有 12 个字节,所以bytes_too_much = 2
min_length
设置为max_length - 2 == 4
max_length
减1.new_start = 4
、remaining_bytes = 6
、max_length = 1
- 递归调用:
4 + bytes_to_char_length('111144', 6, start=4, max_length=1)
input[start:start + max_length] = '4'
有 4 个字节,所以bytes_too_much = -2
- 立即return从returning
max_length == 1
的递归,return5
作为结果。
形式上它做出以下假设:
- 每个字符在编码字符串中至少占用一个字节。
- 编码后的字符串中每个字符至少占
MAX_BYTES_BY_CHAR
- 对于两个子字符串,如果我将字符串
s
拆分为子字符串s == s1 + s2
,则s.encode() == s1.encode() + s2.encode()
性能
- 即使对于长输入字符串,它也应该能顺利运行,因为它避免了复制字符串。
- 根据我的 timeit 测量,对于简单的测试用例,它大约快一个数量级。
我将提出一个简单的解决方案,其中包含一个循环并检查 len(text[index].encode())
等编码字符的长度。还添加了
import textwrap, timeit
_MIN_WIDTH = 5
def A_B_B(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < _MIN_WIDTH
text = textwrap.shorten(text, width) # After this line, len(text.encode()) >= width
while len(text.encode()) > width:
text = textwrap.shorten(text, len(text) - 1)
assert len(text.encode()) <= width
return text
def naive(text: str, width: int) -> str:
width = max(_MIN_WIDTH, width) # This prevents ValueError if width < TEXTWRAP_MIN_WIDTH
# textwrap.shorten does a lot of work like merging several spaces into one,
# so we will use it first
text = textwrap.shorten(text, width)
if len(text.encode()) <= width:
return text
current_width = _MIN_WIDTH # len of placeholder
index = 0
slice_index = 0 # we will do a slice on a last found space if necessary
# (to avoid slicing in a middle of a word, for example)
endings = ' ' # there also can be some more endings like \t \n
while True:
# we will use the fact that if str = str1 + str2 then
# len(str.encode()) = len(str1.encode()) + len(str2.encode())
new_width = current_width + len(text[index].encode()) # taking one more character
if new_width > width:
break
if text[index] in endings:
slice_index = index
index += 1
current_width = new_width
if slice_index: # slice_index = 0 is a special case
# when we dont go further than end of first word
slice_index += 1 # to include found space
text = text[:slice_index] + '[...]'
assert len(text.encode()) <= width
return text
s = '☺ Ilsa, le méchant ☺ ☺ gardien ☺'
n = 27
print(timeit.timeit(lambda: A_B_B(s, n), number=1000))
print(timeit.timeit(lambda: naive(s, n), number=1000))
时间安排:
0.032570790994213894
0.0206866109801922