用 Pillow 手工合成 16:9 技术封面:渐变、网格、字宽测量与截图画框
一、为什么不用现成的封面模板
给技术文章配封面,最省事的做法是打开在线设计工具,选个模板改文字。但做过几次之后会发现两个问题:一是每次都要重新对一遍字号和留白,风格会慢慢漂;二是几十张封面要改同一个细节(比如统一把主色从橙色换成珊瑚红),只能一张张点。
封面本质上是一张固定尺寸的图,完全可以用代码直接画。1600×900 的画布,左边放标题区,右边放产品截图,中间用一条网格线做视觉分隔——这个结构一旦写成脚本,以后每次只要替换标题文字和截图路径,十秒钟出一张,而且风格绝对一致。
下面这份脚本用 Pillow 实现,不依赖任何设计资源,Windows 自带字体就够。
二、画布与坐标系
Pillow 的坐标系是左上角原点,x 向右、y 向下,和 CSS 一样。先建画布:
from PIL import Image, ImageDraw, ImageFont
W, H = 1600, 900
img = Image.new("RGB", (W, H), "#10131a")
d = ImageDraw.Draw(img)
这里用 RGB 而不是 RGBA。封面最终是 PNG,不需要透明通道;用 RGB 保存出来的文件明显更小,而且不会有色偏。如果后面要把截图贴上去,注意截图是 RGB 就直接贴,是 RGBA 要先 convert("RGB"),否则 paste 到 RGB 画布上透明区域会变成黑色。
三、逐行渐变:简单但要注意性能
Pillow 没有内置的线性渐变,最直接的办法是逐行画线:
for y in range(H):
t = y / H
r = int(16 + 13 * t)
g = int(19 + 17 * t)
b = int(26 + 25 * t)
d.line([(0, y), (W, y)], fill=(r, g, b))
900 次 draw.line,耗时大约十几毫秒,完全可以接受。关键在于颜色增量要小:这里从 #10131a 过渡到 #1d242f,两个色值在全通道上只差 13/17/25,人眼几乎看不出分界线,但整张图会有一种"底部微微透光"的质感。
如果增量给大(比如从纯黑到中灰),逐行画就会出现明显的色带(banding)。遇到这种情况有两个办法:一是把 range(H) 改成 range(H*4) 再按比例画,用更细的步长换取平滑;二是干脆用 numpy 生成整块渐变数组再 Image.fromarray 贴回去,速度更快也更干净。封面这种小图,逐行法够用。
四、网格线是免费的视觉密度
纯色渐变背景容易显得空。加一层 64px 的细网格,成本几乎为零,但视觉密度会立刻上来:
for x in range(0, W, 64):
d.line([(x, 0), (x, H)], fill=(24, 28, 38))
for y in range(0, H, 64):
d.line([(0, y), (W, y)], fill=(24, 28, 38))
网格色 #181c26 只比背景稍亮一点点,属于"不仔细看看不见"的程度。这个度很关键:网格太亮会抢标题的注意力,太暗又等于没画。经验值是和背景的亮度差控制在 8~12 之间。
五、字体:msyh.ttc 与 msyhbd.ttc
Windows 自带的中文字体里,微软雅黑是最安全的选择,两个文件分别是常规体和粗体:
FONT_BODY = r"C:\Windows\Fonts\msyh.ttc"
FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
def font(path, size):
try:
return ImageFont.truetype(path, size)
except Exception:
return ImageFont.truetype(FONT_BODY, size)
.ttc 是字体集合文件,一个文件里可能包含多个字重。Pillow 的 truetype 支持传 index 参数选择具体那一个,不传默认取第 0 个。如果发现粗体没生效,可以先确认文件是否真的存在——有些精简版系统会删掉 msyhbd.ttc。
包一层 try/except 做 fallback 是必要的。脚本一旦在别的机器上跑(比如 CI 或者 Linux 服务器),字体路径必然不同,有 fallback 至少不会直接崩,最多是字重不对。
六、用 textlength 做自动排布的胶囊标签
封面底部通常放几个技能标签,比如「原生 JS」「SVG 进度环」。如果每个标签用固定宽度,中文和英文混排时就会出现有的撑满有的留白。
正确做法是先量字宽再画框:
tags = ["原生 JS", "SVG 进度环", "Web Audio", "无后端"]
x = 92
for t in tags:
fs = font(FONT_BODY, 24)
w = d.textlength(t, font=fs)
d.rounded_rectangle([x, 640, x + w + 40, 690], radius=12,
outline=(58, 66, 84), width=2)
d.text((x + 20, 652), t, font=fs, fill=(150, 162, 182))
x += w + 56
textlength 返回的是浮点数,精确到亚像素。框的宽度取 w + 40,等于左右各 20px 内边距;下一个标签的起点是 x + w + 56,比框宽多 16px 当间距。这样无论标签内容多长,间距都是恒定的。
注意 textlength 必须传和 draw.text 完全相同的 font 对象。用不同 size 实例去量、去画,宽度就对不上——这是最容易踩的坑,因为代码看起来完全合理。
七、截图卡片:缩放、描边与假阴影
右侧贴产品截图,关键是三件事:等比缩放、加边框、加阴影。
shot = Image.open(SHOT).convert("RGB")
target_w = 640
ratio = target_w / shot.width
shot = shot.resize((target_w, int(shot.height * ratio)), Image.LANCZOS)
sx, sy = W - target_w - 90, (H - shot.height) // 2
shadow = Image.new("RGB", (shot.width + 40, shot.height + 40), (8, 9, 13))
img.paste(shadow, (sx - 20, sy - 16))
img.paste(shot, (sx, sy))
d.rectangle([sx, sy, sx + shot.width, sy + shot.height],
outline=(60, 66, 82), width=2)
缩放用 LANCZOS,这是 Pillow 里质量最好的重采样算法,缩小时不会有明显锯齿。NEAREST 虽然快,但缩出来的文字边缘会糊成一团。
阴影这里是最土但最稳的做法:新建一块比截图大 40px 的深色矩形,先贴到截图位置偏左上 20/16 像素处,再把截图盖上去。这样露出来的部分就是"阴影"。真正的模糊阴影要 ImageFilter.GaussianBlur,但在这张深色封面上,硬边阴影反而更干净,而且省掉一次全图模糊的开销。
最后用 d.rectangle 沿着截图边缘描一圈 2px 的亮边,作用是让截图和背景分离——深色截图贴在深色背景上,没有这圈边框会整个糊在一起。
八、几个收尾细节
- 文件体积。同样的图,PNG 保存时加
optimize=True能小 10%~20%。封面通常 100~300KB,都在可接受范围。如果要求更小,可以转成调色板模式convert("P", palette=Image.ADAPTIVE, colors=256),但渐变会出现轻微色带,慎用。 - 尺寸比例。很多平台要求 16:9,实际验收时容差很小。1600×900 是标准 16:9,不要用 1920×1080 的近似值去凑,也不要后期裁切,直接按目标尺寸画。
- 文字别贴边。左边距给到 90px,右边截图留 90px,上下也留足。留白不够的封面在小尺寸缩略图里会显得非常挤。
- 中文字距。Pillow 不支持
letter-spacing。真要拉开字距,只能一个字一个字画,每次x += char_width + spacing。大标题其实不需要拉字距,靠字号和字重区分层级就够了。
九、完整脚本
from PIL import Image, ImageDraw, ImageFont
import os
W, H = 1600, 900
OUT = "./assets"
SHOT = os.path.join(OUT, "screenshot.png")
DEST = os.path.join(OUT, "cover.png")
FONT_BODY = r"C:\Windows\Fonts\msyh.ttc"
FONT_BOLD = r"C:\Windows\Fonts\msyhbd.ttc"
def font(path, size):
try:
return ImageFont.truetype(path, size)
except Exception:
return ImageFont.truetype(FONT_BODY, size)
img = Image.new("RGB", (W, H), "#10131a")
d = ImageDraw.Draw(img)
for y in range(H):
t = y / H
d.line([(0, y), (W, y)], fill=(
int(16 + 13 * t), int(19 + 17 * t), int(26 + 25 * t)))
for x in range(0, W, 64):
d.line([(x, 0), (x, H)], fill=(24, 28, 38))
for y in range(0, H, 64):
d.line([(0, y), (W, y)], fill=(24, 28, 38))
f_title = font(FONT_BOLD, 78)
d.text((90, 258), "番茄钟", font=f_title, fill=(255, 255, 255))
d.text((90, 356), "专注计时器", font=f_title, fill=(255, 95, 86))
shot = Image.open(SHOT).convert("RGB")
shot = shot.resize((640, int(shot.height * 640 / shot.width)), Image.LANCZOS)
sx, sy = W - 640 - 90, (H - shot.height) // 2
img.paste(Image.new("RGB", (shot.width + 40, shot.height + 40), (8, 9, 13)),
(sx - 20, sy - 16))
img.paste(shot, (sx, sy))
d.rectangle([sx, sy, sx + shot.width, sy + shot.height],
outline=(60, 66, 82), width=2)
img.save(DEST, "PNG", optimize=True)
十、小结
这套脚本一共不到 100 行,覆盖了封面需要的大部分元素:渐变背景、网格、主副标题、标签胶囊、截图画框。它真正解决的其实不是"画图"这件事,而是把"风格一致性"从人的注意力里拿出来,交给代码保证。以后换标题只改两个字符串,换配色只改三个色值,改完立刻重跑,不用再对着设计工具微调。
如果副标题需要自适应长度(比如从文章标题自动生成),可以用 textlength 做一个简单的缩放:从 34px 开始量,超过可用宽度就降到 32、30,直到装得下为止。这一步加上之后,封面就彻底变成"传参出图",可以挂到文章发布流程里自动跑。
- 点赞
- 收藏
- 关注作者
评论(0)