法春聯(lián):從字體加載到排版合成)
簡(jiǎn)介面向想在春節(jié)前后用Python程序創(chuàng)作年味內(nèi)容的初學(xué)者這份資源演示了如何借助操作系統(tǒng)自帶的毛筆字體自動(dòng)生成春聯(lián)。作者在Windows的C:\Windows\Fonts字體目錄中定位到華文隸書(shū)STLITI.TTF腳本據(jù)此完成字體加載、春聯(lián)文字繪制與圖片輸出這種方案無(wú)需額外安裝字體適合想接觸PIL/Pillow圖像處理、系統(tǒng)字體調(diào)用以及簡(jiǎn)單自動(dòng)化出圖的后端開(kāi)發(fā)者。壓縮包總共只有2個(gè)文件1個(gè)py主腳本負(fù)責(zé)繪制春聯(lián)文字1張jpeg背景圖提供年味底紋整包153KB結(jié)構(gòu)清晰、便于直接閱讀與二次修改。腳本保留了字體路徑、字號(hào)、輸出尺寸等核心設(shè)置改一改文字內(nèi)容就能得到不同春聯(lián)背景圖也可按需替換對(duì)理解中文字體在Python繪圖中的調(diào)用邏輯尤其有幫助也適合擴(kuò)展成批量生成春聯(lián)的小工具。目前已有1074人學(xué)習(xí)下載是春節(jié)小工具、個(gè)性賀卡或教學(xué)演示中的一個(gè)輕量范例。1. 用 Python 寫(xiě)春聯(lián)先解決“字從哪來(lái)”春節(jié)前想給家里寫(xiě)副春聯(lián)手邊沒(méi)有毛筆又不想用在線模板里千篇一律的楷體最簡(jiǎn)單的方式是讓 Python 直接調(diào)用系統(tǒng)字體庫(kù)把“毛筆字”寫(xiě)到圖片上。操作系統(tǒng)自帶的矢量字庫(kù)里有一部分是可以當(dāng)毛筆字庫(kù)用的比如 Windows 的華文隸書(shū)文件名叫 STLITI.TTF在 C:\Windows\Fonts 目錄下就能找到。用 Pillow 加載它配合背景圖和坐標(biāo)計(jì)算就能生成一副像模像樣的春聯(lián)圖片。這種思路適合 python 入門(mén)階段的讀者練手也適合后端工程師快速實(shí)現(xiàn)文字合成圖片的功能核心就兩個(gè)點(diǎn)字體加載和排版坐標(biāo)。下面從字體文件開(kāi)始拆。2. 字體加載從 STLITI.TTF 到 Pillow 的 Font 對(duì)象2.1 為什么系統(tǒng)矢量字庫(kù)可以直接作為毛筆字庫(kù)TrueType 和 OpenType 字體保存的是字形輪廓的矢量數(shù)據(jù)縮放時(shí)由渲染引擎重新計(jì)算邊界所以放大到春聯(lián)尺寸也不會(huì)出現(xiàn)明顯的鋸齒。華文隸書(shū)屬于 TrueType 字體筆畫(huà)帶有明顯的波磔和蠶頭燕尾特征用在春聯(lián)場(chǎng)景里比黑體和宋體更有手寫(xiě)感。不同系統(tǒng)的字體路徑不一樣Windows 集中在 C:\Windows\FontsmacOS 在 /System/Library/Fonts 和 /Library/FontsLinux 一般在 /usr/share/fonts。如果不確定系統(tǒng)里有哪些中文字體在 Linux 上執(zhí)行 fc-list :langzh 可以直接列出Windows 上打開(kāi)字體目錄按“隸書(shū)”篩選更快。2.2 test4.py 里的字體加載寫(xiě)法與參數(shù)含義這個(gè)項(xiàng)目里真正起作用的腳本是 test4.py它開(kāi)頭做的事情就是找到字體文件并加載。標(biāo)準(zhǔn)寫(xiě)法如下from PIL import Image, ImageDraw, ImageFont # Windows 下直接指向系統(tǒng)字體目錄注意文件名要和資源管理器里看到的一致 font_path rC:\Windows\Fonts\STLITI.TTF # size120 表示字體的邏輯尺寸單位是像素 font ImageFont.truetype(font_path, size120) # 驗(yàn)證字體是否加載成功getname 返回 (家族名, 樣式名) print(font.getname()) # 期望輸出 (STLITI, Regular) print(font.getmetrics()) # 返回 (ascent, descent)用于垂直定位在運(yùn)行這段代碼之前需要先確認(rèn)當(dāng)前 python 環(huán)境里安裝了 Pillow。命令是 pip install pillow。如果你剛完成 python 安裝還沒(méi)有做過(guò)虛擬環(huán)境配置建議直接用系統(tǒng)解釋器安裝避免出現(xiàn) python 環(huán)境配置混亂的問(wèn)題在 pycharm 配置 python 環(huán)境時(shí)要保證解釋器路徑和 pip 所屬環(huán)境一致否則會(huì)報(bào) ModuleNotFoundError: No module named PIL。這段代碼里ImageFont.truetype的第一個(gè)參數(shù)是字體文件路徑第二個(gè)參數(shù)是字體大小。要注意size并不是最終渲染的字號(hào)上限它只決定了字體輪廓被縮放到的標(biāo)稱(chēng)尺寸getmetrics()返回的 ascent 和 descent 加起來(lái)就是一行文字從基線到頂部的總高度比getbbox的結(jié)果更適合用于行高計(jì)算。很多人第一次寫(xiě)時(shí)會(huì)把 font.getname() 打印出來(lái)的結(jié)果當(dāng)成字體文件名其實(shí)那只是字體的內(nèi)部元數(shù)據(jù)和磁盤(pán)文件名沒(méi)有直接關(guān)系。2.3 字體加載失敗的常見(jiàn)原因與排查表字體加載看似簡(jiǎn)單實(shí)際報(bào)錯(cuò)大多集中在路徑和格式上。以 Windows 為例C:\Windows\Fonts 里有大量字體文件但某些字體在資源管理器里顯示為“華文隸書(shū)”實(shí)際文件名可能是 STLITI.TTF 或 STLITI.TTC前者是 TrueType后者是 TrueType Collection。Pillow 支持 TTC但加載 TTC 時(shí)需要通過(guò) index 參數(shù)指定是第幾個(gè)字體例如ImageFont.truetype(STLITI.TTC, 120, index0)。下表是幾個(gè)最常見(jiàn)的異常場(chǎng)景。異常現(xiàn)象直接原因處理方式OSError: cannot open resource路徑寫(xiě)錯(cuò)或文件被占用檢查實(shí)際文件名用 os.path.exists 先確認(rèn)OSError: invalid font header文件不是合法 TTF/OTF/TTC換字體文件或確認(rèn)下載的文件沒(méi)有損壞UnicodeEncodeError字體不含某些字或符號(hào)換字庫(kù)或把文字轉(zhuǎn)為繁體再渲染每次運(yùn)行路徑都變用相對(duì)路徑或臨時(shí)目錄把字體復(fù)制到項(xiàng)目 fonts 目錄用file定位注意Pillow 在 Linux 下對(duì)字體路徑大小寫(xiě)敏感在 Windows 下不敏感。把字體文件放進(jìn)項(xiàng)目目錄用絕對(duì)路徑定位能避免換機(jī)器后路徑失效。如果出現(xiàn) cannot open resource常見(jiàn)做法是先寫(xiě)一段檢測(cè)邏輯import os font_path rC:\Windows\Fonts\STLITI.TTF if not os.path.exists(font_path): raise FileNotFoundError(f字體不存在: {font_path})這段代碼的作用是提前暴露路徑問(wèn)題而不是等到 ImageFont.truetype 內(nèi)部拋 OSError。參數(shù)說(shuō)明只需注意 os.path.exists 接受字符串路徑如果將來(lái)要做跨平臺(tái)建議用 pathlib.Path(font_path).exists()返回布爾值語(yǔ)義更清晰。3. 排版坐標(biāo)文本測(cè)量、anchor 錨點(diǎn)與背景合成3.1 文本測(cè)量getbbox 與 textlength 的差異寫(xiě)春聯(lián)時(shí)最常做的一件事是讓文字居中。Pillow 的 ImageDraw 提供了兩種測(cè)量工具很多 python 教程里還在講舊版的 textsize實(shí)際上它已經(jīng)棄用現(xiàn)在建議用 textbbox 和 textlength。下面這段代碼演示兩者的差別。from PIL import Image, ImageDraw, ImageFont font_path rC:\Windows\Fonts\STLITI.TTF font ImageFont.truetype(font_path, size120) img Image.new(RGB, (600, 300), white) draw ImageDraw.Draw(img) text 迎新春 # 測(cè)量實(shí)際繪制內(nèi)容的包圍盒返回 (left, top, right, bottom) bbox draw.textbbox((0, 0), text, fontfont) print(bbox:, bbox) # textlength 只返回水平方向的長(zhǎng)度 width draw.textlength(text, fontfont) print(textlength:, width) # 水平居中用 textlength 的寬度 x (img.width - width) / 2 # 垂直居中用 bbox 的高度 y (img.height - (bbox[3] - bbox[1])) / 2 draw.text((x, y), text, fontfont, fillblack) img.save(center_test.png)textlength 返回的是浮點(diǎn)數(shù)考慮到了字距、字重和字體渲染的細(xì)微差異textbbox 返回四元組是精確定位文字矩形區(qū)域的基礎(chǔ)。如果你的需求僅僅是水平居中用 textlength 就夠了如果要同時(shí)控制文字在背景中的上下位置必須用 textbbox 的高度。textbbox 的 left 和 top 對(duì)某些字體不是 0因?yàn)樽煮w的內(nèi)含邊距不同直接取 textbbox[2] 作為寬度容易偏大通常用right - left計(jì)算。3.2 背景圖合成bg.jpeg 的打開(kāi)與居中項(xiàng)目里附帶了一張 bg.jpeg說(shuō)明春聯(lián)不一定要純色背景可以基于真實(shí)紙張或紅紙紋理生成。常見(jiàn)做法是先把背景圖轉(zhuǎn)成 RGBA再在上面疊加文字層最后用 alpha_composite 合成。from PIL import Image, ImageDraw, ImageFont font ImageFont.truetype(rC:\Windows\Fonts\STLITI.TTF, size120) bg Image.open(bg.jpeg).convert(RGBA) # 文字層單獨(dú)一張透明畫(huà)布尺寸和背景圖一致 canvas Image.new(RGBA, bg.size, (255, 255, 255, 0)) draw ImageDraw.Draw(canvas) # anchormm 表示 (x, y) 是文字矩形的中心點(diǎn) x bg.width // 2 y bg.height // 2 draw.text((x, y), 迎新春, fontfont, fill(255, 0, 0, 255), anchormm) result Image.alpha_composite(bg, canvas) result.convert(RGB).save(spring_couplet.png)這里先說(shuō)明convert(RGBA)的意義背景圖如果是 JPG本身沒(méi)有 alpha 通道轉(zhuǎn)成 RGBA 后統(tǒng)一像素格式。anchormm讓文字的幾何中心對(duì)準(zhǔn) (x, y)省去了自己計(jì)算 left/top 的步驟。參數(shù) fill 里的第四個(gè)值是透明度255 表示完全不透明如果要做半透明效果可以改成 128但春聯(lián)場(chǎng)景通常不需要。需要留意的是 alpha_composite 要求兩張圖的尺寸完全一致如果背景圖是旋轉(zhuǎn)過(guò)的文字也會(huì)跟著旋轉(zhuǎn)。test4.py 里通常不會(huì)單獨(dú)旋轉(zhuǎn)文字而是通過(guò)調(diào)整背景圖的寬高比來(lái)適配橫排或豎排。3.3 畫(huà)布尺寸變化時(shí)如何縮放字體同一個(gè)腳本如果被用來(lái)生成不同尺寸的春聯(lián)字體不能寫(xiě)死。我一般會(huì)用一個(gè)循環(huán)遞減的方式從基準(zhǔn)字號(hào)開(kāi)始逐步縮小到能放下文字為止。def fit_font_size(draw, text, font_path, max_width, max_height, base_size120): size base_size font ImageFont.truetype(font_path, size) while size 10: bbox draw.textbbox((0, 0), text, fontfont) w bbox[2] - bbox[0] h bbox[3] - bbox[1] if w max_width and h max_height: return font, size size - 2 font ImageFont.truetype(font_path, size) return font, size循環(huán)遞減會(huì)多次調(diào)用 truetype這個(gè)操作在字體文件較大時(shí)有一點(diǎn)開(kāi)銷(xiāo)但春聯(lián)文字一般不超過(guò) 10 個(gè)字影響可以忽略。如果追求效率可以先按比例估算字號(hào)estimated_size int(base_size * min(max_width / w, max_height / h))然后再微調(diào)。參數(shù)上 max_width 和 max_height 是文字允許占用的最大矩形size 步長(zhǎng)設(shè)為 2 是為了避免字號(hào)變化太慢如果把步長(zhǎng)改成 1結(jié)果更精細(xì)但循環(huán)次數(shù)會(huì)增加一倍。注意draw.textbbox的坐標(biāo)是基于 (0, 0) 原點(diǎn)計(jì)算的如果你的畫(huà)布本身有邊距要在判斷時(shí)把 margin 減掉。這一段也可以和 2.2 的 getmetrics 對(duì)照看getmetrics 給的是字體整體度量textbbox 給的是具體文本的包圍盒兩者不能互相替代。4. 上下聯(lián)批量生成參數(shù)表、豎排實(shí)現(xiàn)與導(dǎo)出4.1 上下聯(lián)和橫批的參數(shù)表在實(shí)際寫(xiě)春聯(lián)腳本時(shí)最好把參數(shù)集中放置方便以后換成別的字體或背景。把 test4.py 里的散落參數(shù)整理成一張表對(duì)應(yīng)關(guān)系如下。參數(shù)名含義示例值left_text第一句上聯(lián)內(nèi)容天增歲月人增壽right_text第二句下聯(lián)內(nèi)容春滿(mǎn)乾坤福滿(mǎn)門(mén)horizontal_text橫批文本萬(wàn)象更新font_path毛筆字庫(kù)路徑C:\Windows\Fonts\STLITI.TTFbg_path背景圖片路徑bg.jpegsidebar_font_size對(duì)聯(lián)正文字號(hào)90header_font_size橫批字號(hào)110margin畫(huà)布邊距60這里的字號(hào)建議按背景圖高度比例計(jì)算例如int(bg.height * 0.15)而不是寫(xiě)死這樣換一張豎版背景圖后對(duì)聯(lián)不會(huì)溢出。上聯(lián)和下聯(lián)文字較長(zhǎng)時(shí)需要把字號(hào)調(diào)小否則會(huì)超出畫(huà)布邊界橫批文字短字號(hào)可以稍大。參數(shù)表里的 margin 僅用于純色背景的邊界控制如果背景是整張圖還需要配合 3.3 的 fit_font_size。4.2 自動(dòng)排版一副完整春聯(lián)的代碼一副完整春聯(lián)包括上聯(lián)、下聯(lián)和橫批。橫批一般放在頂部中間兩個(gè)豎條分別放在畫(huà)布左右兩側(cè)。這里涉及到豎排問(wèn)題Pillow 沒(méi)有內(nèi)置豎排模式常見(jiàn)做法是把文字拆成單個(gè)字后再渲染或用換行符實(shí)現(xiàn)縱向排列。下面這段代碼用換行符實(shí)現(xiàn)豎排。from PIL import Image, ImageDraw, ImageFont FONT_PATH rC:\Windows\Fonts\STLITI.TTF BG_PATH bg.jpeg def vertical_text(text: str) - str: # 每個(gè)字之間插入換行讓 draw.text 自動(dòng)換行 return \n.join(text) def render_task(left, right, horizontal, outputcouplets.png): bg Image.open(BG_PATH).convert(RGBA) W, H bg.size font_h ImageFont.truetype(FONT_PATH, int(H * 0.12)) font_v ImageFont.truetype(FONT_PATH, int(H * 0.09)) draw ImageDraw.Draw(bg) # 橫批anchormm 表示以該坐標(biāo)為中心 draw.text((W // 2, int(H * 0.12)), horizontal, fontfont_h, fill(255, 0, 0, 255), anchormm) # 第一個(gè)文本豎排放在畫(huà)布左側(cè)第二個(gè)文本放在右側(cè) draw.text((int(W * 0.22), int(H * 0.5)), vertical_text(left), fontfont_v, fill(0, 0, 0, 255), anchormm, spacing12) draw.text((int(W * 0.78), int(H * 0.5)), vertical_text(right), fontfont_v, fill(0, 0, 0, 255), anchormm, spacing12) bg.convert(RGB).save(output) print(saved:, output) render_task(天增歲月人增壽, 春滿(mǎn)乾坤福滿(mǎn)門(mén), 萬(wàn)象更新)這段代碼把spacing12傳給了 draw.text其實(shí)只有在使用 multiline_text 時(shí) spacing 才會(huì)按預(yù)期生效。更嚴(yán)謹(jǐn)?shù)膶?xiě)法是調(diào)用draw.multiline_text它需要傳入 xy、text、font、fill、anchor、spacing 等參數(shù)。如果把換行符交給 draw.textPillow 實(shí)際上也會(huì)處理但文檔上不建議這么做。我在實(shí)際項(xiàng)目里會(huì)統(tǒng)一用 multiline_textdraw.multiline_text((x, y), vertical_text(left), fontfont_v, fill(0, 0, 0, 255), anchormm, spacing12, aligncenter)對(duì)比一下draw.text 適合單行文本multiline_text 適合帶換行符的文本spacing 參數(shù)控制行間距。對(duì)于春聯(lián)來(lái)說(shuō)最常見(jiàn)的豎排間距是 8 到 16 像素具體數(shù)值取決于字體大小。4.3 輸出格式與文件命名渲染結(jié)果通常保存為 PNG 而不是 JPG因?yàn)榇郝?lián)圖案中包含大塊紅色和黑色文字JPG 的壓縮會(huì)產(chǎn)生色塊噪點(diǎn)尤其是文字邊緣。保存時(shí)要注意格式差異。# PNG 可以直接保存 RGBA bg.save(couplets.png) # JPG 必須先轉(zhuǎn)成 RGB bg.convert(RGB).save(couplets.jpg, quality95)PNG 保存時(shí)不傳 quality 參數(shù)因?yàn)闊o(wú)損壓縮JPG 的 quality 一般取 90 到 95 就能在體積和清晰度之間取得平衡。文件命名可以用內(nèi)容片段加時(shí)間戳避免多副春聯(lián)互相覆蓋。import datetime def make_output_name(left, right): stamp datetime.datetime.now().strftime(%Y%m%d_%H%M%S) return fcouplets_{stamp}.png這種命名方式在批量生成場(chǎng)景下很有用比如生成數(shù)十副不同內(nèi)容的春聯(lián)時(shí)直接用 left 和 right 拼接文件名更直觀但要注意文字里可能包含/或\Windows 下不允許出現(xiàn)在文件名中需要先替換掉。5. 進(jìn)階驗(yàn)證字體加載結(jié)果并把腳本做成命令行工具5.1 快速驗(yàn)證字體是否真的加載成功有時(shí)候腳本能跑通但生成的字體卻不是華文隸書(shū)而是系統(tǒng)自動(dòng)回退的默認(rèn)字體。避免這個(gè)問(wèn)題的方法是打印字體元數(shù)據(jù)并和預(yù)期對(duì)比。在 test4.py 的調(diào)試階段可以加一個(gè)檢查函數(shù)。from PIL import Image, ImageDraw, ImageFont def check_font(font_path, sample迎新春): font ImageFont.truetype(font_path, 60) print(font name:, font.getname()) tmp Image.new(RGB, (200, 100), white) d ImageDraw.Draw(tmp) bbox d.textbbox((0, 0), sample, fontfont) print(bbox:, bbox) return bbox如果打印出的 font name 是 (STLITI, Regular)說(shuō)明加載的是目標(biāo)字體如果變成 (Arial, Regular) 或 (simhei, Regular)那多半是路徑失效后 Pillow 走了默認(rèn)回退邏輯。bbox 的值也能幫助你判斷文字是否偏小比如 60 號(hào)字體的 bbox 寬度遠(yuǎn)小于 60說(shuō)明字符串實(shí)際沒(méi)有占滿(mǎn)可能是字體對(duì)當(dāng)前文案缺少字形渲染時(shí)被替換成了空字符。5.2 把春聯(lián)腳本封裝成命令行小工具更進(jìn)一步的做法是在 test4.py 上包一層 argparse讓參數(shù)可以從命令行傳入。這樣就不用每次改代碼。import argparse from render import render_task parser argparse.ArgumentParser(descriptionRender spring festival couplets) parser.add_argument(--left, requiredTrue, help上聯(lián)文本) parser.add_argument(--right, requiredTrue, help下聯(lián)文本) parser.add_argument(--horizontal, requiredTrue, help橫批文本) parser.add_argument(--font, defaultrC:\Windows\Fonts\STLITI.TTF) parser.add_argument(--bg, defaultbg.jpeg) parser.add_argument(--output, defaultcouplets.png) args parser.parse_args() render_task(args.left, args.right, args.horizontal, args.font, args.bg, args.output)這里需要把 render_task 的參數(shù)列表擴(kuò)展成 font_path、bg_path、output 這幾個(gè)參數(shù)主流程可以復(fù)用第 4 章的渲染邏輯。命令行方式的好處是適合批處理寫(xiě)一個(gè) for 循環(huán)讀取春聯(lián)文案文件逐行調(diào)用這個(gè)腳本就能一次性生成多副不同內(nèi)容的春聯(lián)。需要特別注意的是Windows 控制臺(tái)默認(rèn)編碼可能不是 UTF-8如果文案里有生僻字建議在腳本開(kāi)頭設(shè)置sys.stdout.reconfigure(encodingutf-8)避免輸出信息時(shí)亂碼。實(shí)際使用時(shí)把渲染函數(shù)放到 render.pytest4.py 只做參數(shù)解析和調(diào)用。執(zhí)行下面這條命令會(huì)基于 bg.jpeg 生成一張帶橫批的春聯(lián)圖python test4.py --left 天增歲月人增壽 --right 春滿(mǎn)乾坤福滿(mǎn)門(mén) --horizontal 萬(wàn)象更新 --font C:\Windows\Fonts\STLITI.TTF --bg bg.jpeg --output new_year.png命令里的 --font 和 --bg 都可以替換成絕對(duì)路徑argparse 會(huì)讓路徑中的空格無(wú)需額外轉(zhuǎn)義。如果第一次運(yùn)行時(shí)字體路徑報(bào)錯(cuò)先檢查文件是否真的存在于 C:\Windows\Fonts 下再把路徑改成項(xiàng)目目錄內(nèi)的副本這一步跨系統(tǒng)遷移時(shí)最容易踩但排查成本最低。本文還有配套的精品資源點(diǎn)擊獲取