Python文字转图片 | 诗词歌词格言生成配图卡片

问题来源

最近这几天因为新型冠状病毒疫情的缘故,不得不宅在家里,学业荒废,心中烦闷浮躁。想要静下心来,整理过去,思考当下,展望未来。整理过去包括整理几年来的手机照片、备忘录、浏览器书签、收藏等等,发现整理并不是一个简单的事情,不但费时费力,还需要技巧和灵感,未来可能专门写一篇关于数字时代的个人整理术的文章,这里不再啰嗦。

我有一个习惯,看到喜欢的句子,或有了特别的感悟,愿意记在手机的备忘录里,想必很多人都会这样。但我收藏的句子主体还是以图片的形式存在了相册里,这让句子们很分散,不方便翻看和分享,这尤其触到了我强迫症的敏感神经。于是我希望有一个类似网易云音乐推出的歌词生成图片分享的工具,来帮我整理我的摘抄。

前人工作

在百度使用多种关键词组合搜索无果后,在GitHub上轻松找到了网易云音乐歌曲歌词分享图片生成脚本NeteaseLyric。这个脚本很不错,可以爬取指定歌曲的指定行号范围的歌词,并生成高仿的网易云歌词分享图片。当然它也可以自定义封面和歌词以生成图片,而且还进行了命令行的封装。

看起来没有什么要做的了,但实际上,这份代码有一点不太方便:歌词一行超过长度后会从图片右侧溢出,所以需要用户提前估计好一行多少个字。 那么接下来的改进工作就包括以下两点:

  • 裁剪掉不需要的功能,让代码更简短
  • 对超长文本自动换行,让效果更整齐

实现过程

代码工程已经传到了我的GitHub仓库:Words2Card。我们先裁剪掉不需要的功能,留下如下代码。这里最核心的技术是使用PythonPIL库,将文字转换为图片。

from PIL import Image, ImageDraw, ImageFont
import json, os, random, time, requests
from io import BytesIO

class Img():

	def __init__(self, save_dir=None):
		self.save_dir = save_dir
		self.font_family = 'static/STHeiti_Light.ttc'
		self.font_size = 30 # 字体大小
		self.line_space = 30 # 行间隔大小
		self.share_img_width = 640
		self.padding = 50
		self.song_name_space = 50
		self.banner_space = 60
		self.text_color = '#767676'
		self.netease_banner = u'来自我的摘抄'
		self.netease_banner_color = '#D3D7D9'
		self.netease_banner_size = 20
		self.netease_icon = 'static/netease_icon.png'
		self.icon_width = 25
		if self.save_dir is not None:
			try:
				os.mkdir(self.save_dir)
			except:
				pass

	def save(self, name, lrc, img_url):
		lyric_font = ImageFont.truetype(self.font_family, self.font_size)
		banner_font = ImageFont.truetype(self.font_family, self.netease_banner_size)

		padding = self.padding
		w = self.share_img_width

		album_img = None
		if img_url.startswith('http'):
			raw_img = requests.get(img_url)
			album_img = Image.open(BytesIO(raw_img.content))
		else:
			album_img = Image.open(img_url)
		
		iw, ih = album_img.size
		album_h = ih *  w // iw

		lyric_w, lyric_h = ImageDraw.Draw(Image.new(mode='RGB', size=(1, 1))).textsize(lrc,
			font=lyric_font, spacing=self.line_space)

		h = album_h + padding + lyric_h + self.song_name_space + \
			self.font_size + self.banner_space + self.netease_banner_size + padding

		resized_album = album_img.resize((w, album_h), resample=3)
		icon = Image.open(self.netease_icon).resize((self.icon_width, self.icon_width), resample=3)

		out_img = Image.new(mode='RGB', size=(w, h), color=(255, 255, 255))
		draw = ImageDraw.Draw(out_img)

		# 添加封面
		out_img.paste(resized_album, (0, 0))
		
		# 添加文字
		draw.text((padding, album_h + padding), lrc, font=lyric_font, 
			fill=self.text_color, spacing=self.line_space)
		
		# Python中字符串类型分为byte string 和 unicode string两种,'——'为中文标点byte string,需转换为unicode string
		y_song_name = album_h + padding + lyric_h + self.song_name_space
		# song_name = unicode('—— 「', "utf-8") + name + unicode('」', "utf-8")
		song_name = u'—— 「' + name + u'」'
		sw, sh = draw.textsize(song_name, font=lyric_font)
		draw.text((w - padding - sw, y_song_name), song_name, font=lyric_font, fill=self.text_color)
		
		# 添加网易标签
		y_netease_banner = h - padding - self.netease_banner_size
		out_img.paste(icon, (padding, y_netease_banner - 2))
		draw.text((padding + self.icon_width + 5, y_netease_banner),
			self.netease_banner, font=banner_font, fill=self.netease_banner_color)
		
		img_save_path = ''
		if self.save_dir is not None:
			img_save_path = self.save_dir
		out_img.save(img_save_path + '/' + name + str(int(time.time())) + '.png')

调用方法:

Img("[输出路径]").save("[歌曲名称]", "[歌词内容]", "[封面图片路径]")

我们可以看到如下函数可以返回句子lrc在指定字体字号下输出成图片的大小:

lyric_w, lyric_h = ImageDraw.Draw(Image.new(mode='RGB', size=(1, 1))).textsize(lrc, 
	font=lyric_font, spacing=self.line_space)

我们可以计算出总共可用的宽度:

self.share_img_width - self.padding * 2 = 540

那么使句子长度超出540像素的字符都应该归到下一行,但问题是,在当前字体STHeiti_Light下并不是每个字符宽度都等于字号。实验结果表明,中文是等宽字体,每个汉字都占用字号大小个像素的宽度。除此之外,中英文的标点符号所占宽度都不同,英文字母和数字所占宽度也不同。如果出现了混合以上符号的文本,则不能朴素分割,就需要加权分割了。为了适应这类字体的特点,我们需要动态生成一个存储特殊字符(英文字母+中英文标点符号+数字)宽度的字典:

self.chars_width = {}
self.chars = [
	'。', ',', '、', ':', '?', '(', ')', '【', '】', '《', '》', '’', '‘', '“', '”', '!', '~', '—', '…', ';',
 	'.', ',', '\\', ':', '?', '(', ')', '[', ']', '<', '>', '\'', '\"', '!', '-', '_', '+', '-', '*', '/',
 	'&', '%', '^', '$', '¥', '#', '@', '`', '·', ' '
]
for i in range(ord('0'), ord('9') + 1):
	self.chars.append(chr(i))
for i in range(ord('a'), ord('z') + 1):
	self.chars.append(chr(i))
for i in range(ord('A'), ord('Z') + 1):
	self.chars.append(chr(i))
for char in chars:
	self.chars_width[char], _ = ImageDraw.Draw(Image.new(mode='RGB', size=(1, 1))).textsize(
		char, font=ImageFont.truetype(self.font_family, self.font_size), spacing=self.font_size)

我们可以输出一下self.chars_width观察一下结果,下面是对字典中的关键词排序后的结果:

字符宽度字符宽度字符宽度字符宽度字符宽度字符宽度字符宽度字符宽度
6!9"11#19$19%25&226
(9)8*14+18,10-29.10/15
019119219319419518618718
818919:10<19>19?12@25A19
B18C19D21E19F17G20H22I8
J14K19L17M28N23O22P19Q22
R20S17T18U22V18W29X18Y18
Z18[9\15]9^19_15`17a16
b16c14d17e16f12g17h16i7
j7k15l7m26n16o17p16q17
r11s13t11u17v15w25x15y15
z13~19·1066303030
1515151530151530
30303030

接下来我们编写自动换行的算法。我采用的策略是,在不超出可用宽度的前提下,最接近该宽度的长度后面插入换行符。(当然可以想到的另一种策略是,在可用宽度左右波动,每次选取最接近的长度。)这里不超出可用宽度的最大长度等价于在刚好超出宽度后回退一个字符:

lrc_revised = ""
for line in lrc.split('\n'):
	line_list = list(line)
	x = k = delta_x = 0
	for index in range(len(line)):
		delta_x = self.chars_width.get(line[index], self.font_size)
		x += delta_x
		if x > w - padding * 2:
			if line[index] in self.chars[:50]:
				continue
			line_list.insert(index + k, '\n')
			x = delta_x
			k += 1
	lrc_revised += ''.join(line_list) + '\n'
lrc = lrc_revised

我们使用x维护随着句子增长积累的像素宽度,在每一行中,如果字符是特殊字符,那么查表增加self.chars_width[line[index]],否则认为是汉字,增加一个字号宽度self.font_size。当前宽度如果超出了可用宽度,在当前字符前面插入换行符line_list.insert(index + k, '\n')。这里的k是统计插入位置前新增的换行符的个数,以修正每次的插入位置。同时x要还原,并不是归零,而是当前字符的宽度(因为当前字符已经被划在换行符后面了)。

这里为了美观和习惯,当行开头的字符为标点符号时,我们把它放在上一行末尾,这只需要我们检验到当前字符在self.chars的前50当中时,睁一只眼闭一只眼,认为长度没超,continue一下即可。当然你也可以改进这里,认为左引号、左括号、做书名号等不应该放在行末尾。

在一行处理完毕后,加入到新的句子lrc_revised中。注意,这里不需要对文本的长度进行修改,因为原程序的策略是根据文本和封面长度动态调整输出图片的长度。这样很方便,也不需要对封面图片进行裁剪,只需要将宽度与目标宽度对其,然后按原比例放缩长度即可。

我又在程序上层加入了json数据批量转换的功能。json格式要求一个author关键字标识作者或标题,一个quotes关键字标识引用内容,一个image关键字标识图片地址。注意这里的图片地址既可以是本地的相对地址,也可以是网络连接地址。数据文件举例如下:

[{
	"author": "森见登美彦",
	"quotes": "在世界蔓延滋生的‘烦恼’大致可分为两种:一是无关紧要的事,二是无能为力的事。两者同样都只是折磨自己。如果是努力就能解决的事,与其烦恼不如好好努力;若是努力也无法解决的事,那么付出再多也只是白费力气。",
	"image": ""
}]

author缺省时默认为无名氏image缺省时默认在./image/随机选择一幅jpgpng图片。工程的目录结构如下:

│  data.txt  # json数据文件
│  words2card.py  # 核心程序
│
├─image   # 储存供选择的封面图片
│      1.jpg
│      2.jpg
│      3.png
│
├─output  # 输出制作的卡片的位置
│      森见登美彦1581075253.png
│
└─static  # 制作卡片时使用的静态资源
        netease_icon.png
        STHeiti_Light.ttc

words2card.py源代码如下:

from PIL import Image, ImageDraw, ImageFont
import json, os, random, time, glob, requests
from io import BytesIO

class Img():

	def __init__(self, save_dir=None):
		self.save_dir = save_dir
		self.font_family = 'static/STHeiti_Light.ttc'
		self.font_size = 30 # 字体大小
		self.line_space = 30 # 行间隔大小
		self.share_img_width = 640
		self.padding = 50
		self.song_name_space = 50
		self.banner_space = 60
		self.text_color = '#767676'
		self.netease_banner = u'来自我的摘抄'
		self.netease_banner_color = '#D3D7D9'
		self.netease_banner_size = 20
		self.netease_icon = 'static/netease_icon.png'
		self.icon_width = 25
		if self.save_dir is not None:
			try:
				os.mkdir(self.save_dir)
			except:
				pass
		self.chars_width = {}
		self.chars = [
			'。', ',', '、', ':', '?', '(', ')', '【', '】', '《', '》', '’', '‘', '“', '”', '!', '~', '—', '…', ';',
		 	'.', ',', '\\', ':', '?', '(', ')', '[', ']', '<', '>', '\'', '\"', '!', '-', '_', '+', '-', '*', '/',
		 	'&', '%', '^', '$', '¥', '#', '@', '`', '·', ' '
		]
		for i in range(ord('0'), ord('9') + 1):
			self.chars.append(chr(i))
		for i in range(ord('a'), ord('z') + 1):
			self.chars.append(chr(i))
		for i in range(ord('A'), ord('Z') + 1):
			self.chars.append(chr(i))

		for char in self.chars:
			self.chars_width[char], _ = ImageDraw.Draw(Image.new(mode='RGB', size=(1, 1))).textsize(
				char, font=ImageFont.truetype(self.font_family, self.font_size), spacing=self.font_size)

	def save(self, name, lrc, img_url):
		lyric_font = ImageFont.truetype(self.font_family, self.font_size)
		banner_font = ImageFont.truetype(self.font_family, self.netease_banner_size)

		padding = self.padding
		w = self.share_img_width

		album_img = None
		if img_url.startswith('http'):
			raw_img = requests.get(img_url)
			album_img = Image.open(BytesIO(raw_img.content))
		else:
			album_img = Image.open(img_url)
		
		iw, ih = album_img.size
		album_h = ih *  w // iw

		lrc_revised = ""
		for line in lrc.split('\n'):
			line_list = list(line)
			x = k = delta_x = 0
			for index in range(len(line)):
				delta_x = self.chars_width.get(line[index], self.font_size)
				x += delta_x
				if x > w - padding * 2:
					if line[index] in self.chars[:50]:
						continue
					line_list.insert(index + k, '\n')
					x = delta_x
					k += 1
			lrc_revised += ''.join(line_list) + '\n'
		lrc = lrc_revised

		lyric_w, lyric_h = ImageDraw.Draw(Image.new(mode='RGB', size=(1, 1))).textsize(lrc, font=lyric_font, spacing=self.line_space)

		h = album_h + padding + lyric_h + self.song_name_space + \
			self.font_size + self.banner_space + self.netease_banner_size + padding

		resized_album = album_img.resize((w, album_h), resample=3)
		icon = Image.open(self.netease_icon).resize((self.icon_width, self.icon_width), resample=3)

		out_img = Image.new(mode='RGB', size=(w, h), color=(255, 255, 255))
		draw = ImageDraw.Draw(out_img)

		# 添加封面
		out_img.paste(resized_album, (0, 0))
		
		# 添加文字
		draw.text((padding, album_h + padding), lrc, font=lyric_font, fill=self.text_color, spacing=self.line_space)
		
		# Python中字符串类型分为byte string 和 unicode string两种,'——'为中文标点byte string,需转换为unicode string
		y_song_name = album_h + padding + lyric_h + self.song_name_space
		# song_name = unicode('—— 「', "utf-8") + name + unicode('」', "utf-8")
		song_name = u'—— 「' + name + u'」'
		sw, sh = draw.textsize(song_name, font=lyric_font)
		draw.text((w - padding - sw, y_song_name), song_name, font=lyric_font, fill=self.text_color)
		
		# 添加网易标签
		y_netease_banner = h - padding - self.netease_banner_size
		out_img.paste(icon, (padding, y_netease_banner - 2))
		draw.text((padding + self.icon_width + 5, y_netease_banner), self.netease_banner, font=banner_font, fill=self.netease_banner_color)
		
		img_save_path = ''
		if self.save_dir is not None:
			img_save_path = self.save_dir
		out_img.save(img_save_path + '/' + name + str(int(time.time())) + '.png')

def main():
	generater = Img("output")
	images = glob.glob("image/*.jpg") + glob.glob("image/*.png")
	with open('data.txt', 'r', encoding='utf-8') as reader:
		data = json.loads(reader.read())
		for item in data:
			image = random.choice(images)
			if item['image']:
				image = item['image']
				if not item['image'].startswith("http"):
					image = "image/" + image
			author = item['author'] if item['author'] else "无名氏"
			print("Generating %s's words ... " % author, end="")
			generater.save(author, item['quotes'], image)
			print("done!")

if __name__ == '__main__':
	main()

最后放一张效果图:
格言卡片效果图

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值