服务器端用 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、重启一次服务,不用动固件,比第一版在单片机上排版舒服太多。