问题来源
最近这几天因为新型冠状病毒疫情的缘故,不得不宅在家里,学业荒废,心中烦闷浮躁。想要静下心来,整理过去,思考当下,展望未来。整理过去包括整理几年来的手机照片、备忘录、浏览器书签、收藏等等,发现整理并不是一个简单的事情,不但费时费力,还需要技巧和灵感,未来可能专门写一篇关于数字时代的个人整理术的文章,这里不再啰嗦。
我有一个习惯,看到喜欢的句子,或有了特别的感悟,愿意记在手机的备忘录里,想必很多人都会这样。但我收藏的句子主体还是以图片的形式存在了相册里,这让句子们很分散,不方便翻看和分享,这尤其触到了我强迫症的敏感神经。于是我希望有一个类似网易云音乐推出的歌词生成图片分享的工具,来帮我整理我的摘抄。
前人工作
在百度使用多种关键词组合搜索无果后,在GitHub上轻松找到了网易云音乐歌曲歌词分享图片生成脚本
:NeteaseLyric。这个脚本很不错,可以爬取指定歌曲的指定行号范围的歌词,并生成高仿的网易云歌词分享图片。当然它也可以自定义封面和歌词以生成图片,而且还进行了命令行的封装。
看起来没有什么要做的了,但实际上,这份代码有一点不太方便:歌词一行超过长度后会从图片右侧溢出,所以需要用户提前估计好一行多少个字。 那么接下来的改进工作就包括以下两点:
- 裁剪掉不需要的功能,让代码更简短
- 对超长文本自动换行,让效果更整齐
实现过程
代码工程已经传到了我的GitHub仓库:Words2Card。我们先裁剪掉不需要的功能,留下如下代码。这里最核心的技术是使用Python
的PIL
库,将文字转换为图片。
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 | & | 22 | ’ | 6 | |
( | 9 | ) | 8 | * | 14 | + | 18 | , | 10 | - | 29 | . | 10 | / | 15 |
0 | 19 | 1 | 19 | 2 | 19 | 3 | 19 | 4 | 19 | 5 | 18 | 6 | 18 | 7 | 18 |
8 | 18 | 9 | 19 | : | 10 | < | 19 | > | 19 | ? | 12 | @ | 25 | A | 19 |
B | 18 | C | 19 | D | 21 | E | 19 | F | 17 | G | 20 | H | 22 | I | 8 |
J | 14 | K | 19 | L | 17 | M | 28 | N | 23 | O | 22 | P | 19 | Q | 22 |
R | 20 | S | 17 | T | 18 | U | 22 | V | 18 | W | 29 | X | 18 | Y | 18 |
Z | 18 | [ | 9 | \ | 15 | ] | 9 | ^ | 19 | _ | 15 | ` | 17 | a | 16 |
b | 16 | c | 14 | d | 17 | e | 16 | f | 12 | g | 17 | h | 16 | i | 7 |
j | 7 | k | 15 | l | 7 | m | 26 | n | 16 | o | 17 | p | 16 | q | 17 |
r | 11 | s | 13 | t | 11 | u | 17 | v | 15 | w | 25 | x | 15 | y | 15 |
z | 13 | ~ | 19 | · | 10 | ‘ | 6 | ’ | 6 | … | 30 | 、 | 30 | 。 | 30 |
《 | 15 | 》 | 15 | 【 | 15 | 】 | 15 | ! | 30 | ( | 15 | ) | 15 | , | 30 |
: | 30 | ; | 30 | ? | 30 | ¥ | 30 |
接下来我们编写自动换行的算法。我采用的策略是,在不超出可用宽度的前提下,最接近该宽度的长度后面插入换行符。(当然可以想到的另一种策略是,在可用宽度左右波动,每次选取最接近的长度。)这里不超出可用宽度的最大长度等价于在刚好超出宽度后回退一个字符:
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/
随机选择一幅jpg
或png
图片。工程的目录结构如下:
│ 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()
最后放一张效果图: