对字符串使用 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

测试候选答案

任何候选答案都可以通过将其输出与我的 widthrange(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() 进行反复试验。

它使用基于对字符串的最小和最大长度的有根据猜测的递归算法。部分解决方案(基于猜测的最小长度)用于快速减小问题大小。

解决方案分为两部分:

  1. bytes_to_char_length() 计算适合多个字节的字符串中的最大字符数(有关其工作原理的示例,请参见下文)。
  2. 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) 我们将得到:

  1. max_length 默认设置为 3
  2. input[start:start + max_length] = '111'有3个字节,所以bytes_too_much = 0
  3. 这就是我们要找的确切尺寸,所以我们完成了。

对于bytes_to_char_length('441111', 10)

  1. max_length 设置为 6
  2. input[start:start + max_length] = '441111' 有 12 个字节,所以 bytes_too_much = 2
  3. min_length 设置为 max_length - 2 == 4。 (最多2个字符占2个字节)。
  4. max_length减1(至少需要1个字符才能占用2个Bytes)。
  5. bytes_left = 0max_length = 1
  6. 立即递归调用 returns 0 因为没有剩余字节。结果是 min_length + 0 == 4.

对于bytes_to_char_length('111144', 10)

  1. max_length 设置为 6(和以前一样)
  2. input[start:start + max_length] = '111144' 有 12 个字节,所以 bytes_too_much = 2
  3. min_length 设置为 max_length - 2 == 4
  4. max_length减1.
  5. new_start = 4remaining_bytes = 6max_length = 1
  6. 递归调用:4 + bytes_to_char_length('111144', 6, start=4, max_length=1)
  7. input[start:start + max_length] = '4' 有 4 个字节,所以 bytes_too_much = -2
  8. 立即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