You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Pillow(PIL)中实现按单词而非字符的文本换行功能

问题描述

我正在使用Python的PIL库生成海报,需求为海报宽度固定,高度根据内容动态调整,实现代码如下:

from PIL import Image, ImageDraw, ImageFont

def break_fix(text, width, font, draw):
    """
    Fix line breaks in text.
    """
    if not text:
        return
    lo = 0
    hi = len(text)
    while lo < hi:
        mid = (lo + hi + 1) // 2
        t = text[:mid]
        w, h = draw.textsize(t, font=font)
        if w <= width:
            lo = mid
        else:
            hi = mid - 1
    t = text[:lo]
    w, h = draw.textsize(t, font=font)
    yield t, w, h
    yield from break_fix(text[lo:], width, font, draw)

def fit_text(img, text, color, font, x_start_offset=0, x_end_offset=0, center=False):
    """
    Fit text into container after applying line breaks. Returns the total 
    height taken up by the text, which can be used to create containers of 
    dynamic heights.
    """
    width = img.size[0] - x_start_offset - x_end_offset
    draw = ImageDraw.Draw(img)
    pieces = list(break_fix(text, width, font, draw))
    height = sum(p[2] for p in pieces)
    y = (img.size[1] - height) // 2
    h_taken_by_text = 0
    for t, w, h in pieces:
        if center:
            x = (img.size[0] - w) // 2
        else:
            x = x_start_offset
        draw.text((x, y), t, font=font, fill=color)
        new_width, new_height = draw.textsize(t, font=font)
        y += h
        h_taken_by_text += new_height
    return h_taken_by_text

def generate_text_section(width, text, color, font, x_start_offset, x_end_offset, v_spacing):
    """
    Generates an image for a text section.
    """
    # Calculate height using "fake" canvas
    img = Image.new('RGB', (width, 1), color='white')
    calc_height = fit_text(
        img, text.upper(), color, font, x_start_offset, x_end_offset, False
    )

    # Create real canvas and fit text
    img = Image.new('RGB', (width, calc_height + v_spacing), color='white')
    fit_text(
        img, text.upper(), color, font, x_start_offset, x_end_offset, False
    )

    return img

test_img_1 = generate_text_section(
    width=400,
    text='But I must explain to you',
    color='black',
    font=ImageFont.truetype('fonts/RobotoMono-Bold.ttf', 14),
    x_start_offset=30,
    x_end_offset=30,
    v_spacing=30
)
test_img_1.show()
test_img_1.save('1.png')

test_img_2 = generate_text_section(
    width=400,
    text='But I must explain to you how all this mistaken idea of denouncing',
    color='black',
    font=ImageFont.truetype('fonts/RobotoMono-Bold.ttf', 14),
    x_start_offset=30,
    x_end_offset=30,
    v_spacing=30
)
test_img_2.show()
test_img_2.save('2.png')

代码运行逻辑为:先调用fit_text()方法计算文本溢出换行后所需总高度,再用该高度创建可容纳文本的画布,目前运行效果基本符合预期,生成的两张示例图如下:
图片1
和
图片2
但现有实现是按字符进行溢出截断,第二张图中单词MISTAKEN被拆分到两行。我期望可以按单词边界换行,或至少在换行前添加连字符"-",请问该如何实现?


解决方案

你只需要修改核心的break_fix换行逻辑即可实现需求,以下提供两种可选实现方案,无需修改其他代码:

方案1:按单词边界换行(不拆分单词)

该方案优先在空格位置换行,保证所有单词完整展示,不会被拆分。仅当单个单词本身长度超过行宽时才会强制截断。

def break_fix(text, width, font, draw):
    """
    按单词边界换行,不拆分单词
    """
    if not text.strip():
        return
    lo = 0
    hi = len(text)
    # 二分查找当前行最大可容纳的字符长度
    while lo < hi:
        mid = (lo + hi + 1) // 2
        t = text[:mid]
        w, h = draw.textsize(t, font=font)
        if w <= width:
            lo = mid
        else:
            hi = mid - 1
    # 向前查找最近的空格作为实际截断点
    last_space_idx = text.rfind(' ', 0, lo)
    if last_space_idx != -1:
        lo = last_space_idx + 1
    line_text = text[:lo].rstrip()
    # 处理单个单词超过行宽的极端情况
    if not line_text:
        lo = 1
        line_text = text[:lo]
    w, h = draw.textsize(line_text, font=font)
    yield line_text, w, h
    # 递归处理剩余文本,去掉行首多余空格
    yield from break_fix(text[lo:].lstrip(), width, font, draw)

方案2:超长单词拆分时添加连字符

如果允许拆分长单词,可选择该方案,拆分时会在行尾自动添加连字符标识单词未结束。

def break_fix(text, width, font, draw):
    """
    优先按单词边界换行,超长单词拆分时添加连字符
    """
    if not text.strip():
        return
    lo = 0
    hi = len(text)
    # 提前计算连字符宽度预留空间
    hyphen_w, _ = draw.textsize('-', font=font)
    # 二分查找当前行最大可容纳的字符长度
    while lo < hi:
        mid = (lo + hi + 1) // 2
        t = text[:mid]
        w, h = draw.textsize(t, font=font)
        # 预留连字符位置
        available_width = width if ' ' in t[:mid] else width - hyphen_w
        if w <= available_width:
            lo = mid
        else:
            hi = mid - 1
    # 向前查找最近的空格作为实际截断点
    last_space_idx = text.rfind(' ', 0, lo)
    add_hyphen = False
    if last_space_idx != -1:
        lo = last_space_idx + 1
        line_text = text[:lo].rstrip()
    else:
        # 无空格说明是单个超长单词,添加连字符
        lo -= 1
        line_text = text[:lo] + '-'
        add_hyphen = True
    w, h = draw.textsize(line_text, font=font)
    yield line_text, w, h
    # 处理剩余文本
    next_text = text[lo:].lstrip() if not add_hyphen else text[lo:]
    yield from break_fix(next_text, width, font, draw)

注意事项

如果你使用的是Pillow 9.1.0及以上版本,draw.textsize方法已被官方废弃,可替换为draw.textbbox计算文本尺寸,示例如下:

# 替换原w, h = draw.textsize(t, font=font)
bbox = draw.textbbox((0, 0), t, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]

内容的提问来源于stack exchange,提问作者darkhorse

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.25 19:06:04