← 返回首页

墨水屏上的中文排版:词感知换行与 PIL '1' 模式的坑

2026-09-09

服务器端用 Pillow 把画面渲染成点阵,听起来只要"把字画上去"就完了。实际上中文排版比我预想的麻烦得多,尤其是换行和宽度计算这两块,我前后改了三版才觉得可以接受。

第一版:字符级换行,把词拆散了

第一版我用的是最直接的做法:一个字一个字往后加,加到这个字的宽度超过行宽就换行。英文没问题,中文就出问题了——"納財"这种双字词会被拆到两行,"待办事项"变成"待办事"加"項"。看板的横条上本来字就少,拆开之后读起来非常别扭,一眼就知道是机器排的。

根子在于中文没有空格,字符级换行根本不知道哪些字应该在一起。而我不是要做正经的断行算法,只需要词不被拆开就够了。

第二版:两步法,先组词再按 \n 分段

改成了两步。第一步在数据填充阶段就把内容按词组装成行,用 \n 预插换行——因为源数据本身是我自己整理的字符串列表,哪些字属于一个词我是知道的。第二步在渲染阶段按 \n 分段,每一段内部再做按宽度的换行。这样第一步保证了词不被拆,第二步保证了长段不溢出。

关键在于两步的宽度计算必须用同一套度量,否则第一步算"这行放得下"、第二步又断开了,就白做了。

字宽必须真实测量,不能估

我原来用 len(text) * size 估宽度,中文看起来"差不多"。但中英文混排的时候误差会累积得很大——同一句话里数字、英文字母和中文的宽度完全不一样,字号也不一定等于字宽,len 还会把全角标点算成一个单位。估出来的宽度和实际差几十像素,画出来就是有的行缩进对不齐,有的行末尾留一大块空白。

正确做法是用 ImageFont.truetype(...).getlength() 在真实的字体文件上量。注意必须用它实际加载的那个字体文件,不能用系统里另一个看起来一样的中文字体代替——字形宽度、标点全半角处理都可能不同。我用的是服务器上固定的一份中文字体文件,路径写死在配置里,不依赖系统装了哪些字库。

from PIL import ImageFont

FONT = ImageFont.truetype("/path/to/CJK-Regular.ttf", size=24)

def text_width(s):
    return FONT.getlength(s)

def wrap_by_width(text, max_px):
    lines, cur = [], ""
    for ch in text:
        if text_width(cur + ch) <= max_px:
            cur += ch
        else:
            if cur:
                lines.append(cur)
            cur = ch
    if cur:
        lines.append(cur)
    return lines

词感知换行的写法

第一部分按词组装,注意分隔符本身不参与宽度判断,只用来标记断点:

def group_words(words, max_px):
    """words: [(text, is_separator), ...],按宽度把词拼成行,词内不拆"""
    lines, cur = [], ""
    for text, is_sep in words:
        if not is_sep and cur and text_width(cur + text) > max_px:
            lines.append(cur.rstrip())
            cur = text
        else:
            cur += text
            if not is_sep and text_width(cur) > max_px:
                # 单个词本身就超宽,退回字符级断行,避免溢出
                parts = wrap_by_width(cur, max_px)
                lines.extend(parts[:-1])
                cur = parts[-1]
    if cur:
        lines.append(cur)
    return lines

def render(text, max_px):
    out = []
    for seg in text.split("\n"):
        out.extend(wrap_by_width(seg, max_px))
    return out

wrap 宽度的默认值陷阱

这个坑最隐蔽。我的模板里有几个字段没显式指定换行宽度,我以为它会用容器宽度,实际它用了 200px 的默认值。结果只有那几个字段的长文本被莫名其妙地折成两行、按 200px 折的,和周围的排版完全不协调。找了很久才发现是默认值。

结论很简单:所有涉及换行的调用都显式传宽度,模板里不留隐式默认。哪怕默认值正好是对的,也要写出来,一是为了别人读代码时知道边界在哪,二是防止以后再改模板时不小心改掉。

越界绘制静默失败

最后一个坑:在 '1' 模式的 Image 上,rectangle 或者竖线的坐标超出图像高度时,Pillow 不会报错,也不会裁剪,它是静默不绘制的。表现就是某条分割线突然消失,或者某个背景块只在上面一半出现。我一开始以为是坐标算错了,加了日志打出来坐标确实超出了几百像素。

所以所有绘制前都要自己把坐标夹紧到图像范围内:上下界 clamp 到 0 和高度(或宽度)之间,宽度为负的情况也要处理成 0,不然会画出一个方向反了的矩形。这个检查我封装成了一个 safe_rect(),所有绘制都走它,从此再没出现过线消失的问题。

现在的流程

整理下来就是:源数据按词分组 → 用真实字体量宽度、按词组行、超宽单词退回字符级断行 → 显式传换行宽度 → 绘制坐标全部 clamp。这套流程跑在服务器上,改一次排版就是改几行 Python、重启一次服务,不用动固件,比第一版在单片机上排版舒服太多。