内容运营自动化 配图折行别把词切两半

流水线每天自动出一张配图,脚本打印 SAVED:... size=41213,退出码 0,看起来一切正常。但图发出去之后,读者第一眼看到的可能是这样:

css 复制代码
┌──────────────┐
│ 响应头 Content-Ty │
│ pe                │
└──────────────┘

Content-Type 被劈成两半。这类故障没有异常、没有非零退出码、没有日志告警------只有人眼看图才能发现。图是整条发布链路里唯一「人直接看见」的产物,也恰好是唯一没有自动判据的环节。

这篇把配图脚本的折行逻辑拆开,量一遍它的容量边界,给出三种改法和实测对照。

折行的单位是像素,不是字符

gen_article_figure.py 用微软雅黑 26px 画方框文字,方框宽 260px,左右各留 15px 内边距,所以每行的可用宽度是 230px:

python 复制代码
box_w = 260
lines = wrap_text(label, font_box, box_w - 30)   # 230px

230px 能放几个字,取决于字是什么字。同一字号下用 ImageDraw.textlength 量出来的实际宽度(微软雅黑 26px):

字符 宽度 px
汉字 / 全角标点 26.0
0 15.0
a 14.0
_ 12.0
- 11.0
空格 8.0

所以一行能放 8 个汉字,或 15 个 ASCII 字母。「标签控制在 8 个字以内」这条经验,只在纯中文时成立。

混排不是简单相加

汉字和 ASCII 混在一行时,容量不是「8 减几个」。实测每加 2 个 ASCII 字符,剩余汉字位就少 1 个:

行首 ASCII 字符数 还能放几个汉字
0 8
2 7
4 6
6 5
8 4

这条曲线解释了为什么 work_list 接口 这类标签特别容易坏:它看起来只有 9 个字符,实际宽度是 9 个 ASCII(约 117px)加 2 个汉字(52px),再加上中间那个空格 8px,一共 177px,一行放得下;但换成 work_list 接口与分页 就超了,折行点落在哪完全由像素决定,不由语义决定。

逐字贪心折行的三个后果

现在的实现是逐字符累加、超宽就断:

python 复制代码
def wrap_text(text, font, max_w):
    out = []
    for para in text.split("\n"):
        cur = ""
        for ch in para:
            test = cur + ch
            if d_textwidth(test, font) <= max_w or not cur:
                cur = test
            else:
                out.append(cur)
                cur = ch
        out.append(cur)
    return out

逐字符判断宽度本身没错,问题是它把「字符」当成了「不可分的单位」,于是三个副作用同时存在。

拿 28 条真实标签(一部分是历史事故原串,一部分是技术措辞)跑一遍:

缺陷 命中数 命中率 实例
英文词被切断 3 / 28 10.7% cookie 明文与 DPAPI → cookie 明文与 DP / API
标点落到行首 1 / 28 3.6% 八个汉字开头后面,接标点说明 → 第二行以 , 开头
开括号留在行尾 1 / 28 3.6% 八个汉字在这里(括号说明) → 第一行以 ( 结尾
末行孤字 3 / 28 10.7% SigV4 四级密钥派生 → SigV4 四级密钥派 / 生

孤字(widow)最容易被忽略,因为它不破坏语义,只是难看:一个「生」字孤零零悬在第二行,居中排布下还会把整段视觉重心带偏。

三种改法,都不增加行数

改法一:英文词当原子 。先用正则把连续的 [A-Za-z0-9_.\-/:#+] 抓成 token,宽度够就整块放,放不下就整块换行,只有 token 本身超过 230px 时才退化为逐字切。

改法二:CJK 禁则 。行首禁标点(,。、;:?!)】》」』 等),行尾禁开括号((【《「『)。触发时把上一行末字挤下来陪它------这一挤只是移动断点,不会多出一行。

改法三:孤字回吐。折完之后检查末行,如果只有 1 个字且总行数大于 1,从上一行借一个字过来:

python 复制代码
# 孤字回吐:末行只有 1 字且总行数 >1 → 从前一行借一字
if len(lines) > 1 and len(lines[-1]) == 1 and len(lines[-2]) > 2:
    lines[-1] = lines[-2][-1] + lines[-1]
    lines[-2] = lines[-2][:-1]

三条合起来的实现:

python 复制代码
def wrap_improved(text, max_w=230):
    out = []
    for para in text.split("\n"):
        atoms = []
        for kind, tok in _tokens(para):
            if kind == "token" and w(tok) <= max_w:
                atoms.append(tok)              # 整词原子
            elif kind == "token":
                atoms.extend(_split_token(tok, max_w))   # 超宽才退化
            else:
                atoms.append(tok)
        lines, cur = [], ""
        for a in atoms:
            if w(cur + a) <= max_w or not cur:
                cur += a
            elif a[0] in LINE_START_FORBID and len(cur) > 1:
                lines.append(cur[:-1]); cur = cur[-1] + a   # 行首禁标点
            elif cur[-1] in LINE_END_FORBID:
                lines.append(cur[:-1]); cur = cur[-1] + a   # 行尾禁开括号
            else:
                lines.append(cur); cur = a
        if cur:
            lines.append(cur)
        if len(lines) > 1 and len(lines[-1]) == 1 and len(lines[-2]) > 2:
            lines[-1] = lines[-2][-1] + lines[-1]
            lines[-2] = lines[-2][:-1]
        out.extend(lines)
    return out

同一批 28 条标签的对照结果:

指标 逐字贪心 加三种约束
总行数 41 41
切词 3 1
行首标点 1 0
行尾开括号 1 0
孤字 3 0

行数一行没多 ,因为禁则和回吐都只是把断点挪位置,不改变总量。剩下那 1 例切词是 puppeteer-in-electron------21 个 ASCII 字符,按 15 字符一行的容量怎么切都要断,这不是算法问题,只能缩字号或者换措辞(写成 puppeteer 桥接 Electron)。

框高是另一笔账

改完折行还有个坑:方框高度按显式换行符个数算,不按折出来的行数算。

python 复制代码
bh = 90 + (max(len(l.split(chr(10))) for l, _ in boxes) - 1) * 24

文本输入通道与读回校验的三个不同口径 是 19 个汉字,没写任何换行符,bh 取 90px;但它实际折成 3 行,行高 34px,文字高 102px------溢出 12px,第三行压到边框上。改成按折后行数算:

python 复制代码
n_lines = max(len(wrap_improved(l, 230)) for l, _ in boxes)
bh = max(90 + (explicit - 1) * 24, n_lines * 34 + 24)

3 行时需要 3 × 34 + 24 = 126px,比 90px 多 36px。这个值必须在画框之前算,不能等画完再看。

落地时守住四条

跑通之后我把规则收成四条,写进配图脚本的调用约定:

  1. 纯中文标签压到 8 字以内,混排按上表折算------2 个英文词减 1 个汉字位。
  2. 英文标识符整词给出 ,宁可拆成两个框,不写 puppeteer-in-electron 这种超容量的长 token。
  3. 显式换行由人指定 ,别把折行交给算法猜。想强调的断点自己敲 \n,算法只负责兜底。
  4. 出图后看一眼尺寸 。SAVED 打印成功不代表图好看,像素级的溢出现在没有任何自动判据------这是唯一还得靠眼睛的环节。

配图这条链路已经踩过不少坑:ImageX 上传时 Python OpenSSL 握手被重置、CDN 域名手抄错一个字符、--out 里的反斜杠被 shell 吃掉。折行是里面唯一「不报错但一定被看见」的那个。它值得单独修一次,因为读者判断一张技术配图专业与否,第一眼看的不是内容对不对,是有没有把字切歪。

相关推荐
Lank_M27 分钟前
HEIC转JPG统一转sRGB,P3高饱和色就回不来了
前端
补码缩写28 分钟前
sharp默认转JPG颜色没错,ICC和EXIF却全没了
前端
用户77833661321128 分钟前
SerpBase AI Overview 引用卡片:React 组件实战
前端
林小帅29 分钟前
【笔记】Prisma v7 + SQLite TS2305 及连锁 TS7006 问题
前端·javascript·后端
HUANGZHENXUAN12339 分钟前
最大子矩阵和:从 O(n⁴) 到 O(n³) 的列压缩 + Kadane
c语言·前端
黄连木笛1 小时前
不改一行 Postman 源码,把它汉化了:Electron preload 注入 + fetch 拦截实战
javascript·electron
叼烟扛炮2 小时前
[LangChain] 学习笔记(03):打破失忆症——对话机制与上下文管理
java·前端·javascript·笔记·学习·langchain
IT_陈寒4 小时前
SpringBoot实战技巧总结
前端·人工智能·后端
TeamDev4 小时前
使用 JxBrowser 在桌面应用中集成 Spring
java·前端·后端·spring·混合应用·jxbrowser·浏览器控件