generate_handwriting.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. import os
  2. import uuid
  3. import random
  4. import math
  5. import glob
  6. import io
  7. import base64
  8. from concurrent.futures import ThreadPoolExecutor, as_completed
  9. from PIL import Image, ImageDraw, ImageFont
  10. from flask import request, jsonify, send_file
  11. from lib import Qiniu
  12. import logging
  13. # 配置日志
  14. logging.basicConfig(level=logging.INFO)
  15. logger = logging.getLogger(__name__)
  16. def get_font_list():
  17. """
  18. 获取"数字"文件夹下所有可用的字体文件列表
  19. 返回:
  20. - 字体文件路径列表,每个元素为字典:{"path": 路径, "name": 字体名称}
  21. """
  22. font_dir = './手写字体'
  23. font_list = []
  24. if os.path.exists(font_dir):
  25. # 查找所有字体文件(支持 .ttf, .TTF, .otf, .OTF)
  26. font_extensions = ['*.ttf', '*.TTF', '*.otf', '*.OTF']
  27. font_files = []
  28. for ext in font_extensions:
  29. font_files.extend(glob.glob(os.path.join(font_dir, ext)))
  30. # 验证每个字体文件是否有效
  31. for font_file in font_files:
  32. try:
  33. # 尝试加载字体以验证是否有效
  34. ImageFont.truetype(font_file, 60)
  35. # 获取字体文件名(不包含路径)
  36. font_name = os.path.basename(font_file)
  37. # 去掉扩展名作为显示名称
  38. display_name = os.path.splitext(font_name)[0]
  39. font_list.append({
  40. "path": font_file,
  41. "name": display_name,
  42. "filename": font_name
  43. })
  44. except Exception as e:
  45. logger.warning(f"跳过无效字体文件 {font_file}: {e}")
  46. continue
  47. # 按名称排序
  48. font_list.sort(key=lambda x: x['name'])
  49. return font_list
  50. def find_font_file():
  51. """
  52. 查找可用的字体文件
  53. 优先从"数字"文件夹随机选择一个字体,如果没有则使用系统默认字体
  54. """
  55. # 首先尝试从"数字"文件夹随机选择字体
  56. font_dir = './数字'
  57. if os.path.exists(font_dir):
  58. # 查找所有字体文件(支持 .ttf, .TTF, .otf, .OTF)
  59. font_extensions = ['*.ttf', '*.TTF', '*.otf', '*.OTF']
  60. font_files = []
  61. for ext in font_extensions:
  62. font_files.extend(glob.glob(os.path.join(font_dir, ext)))
  63. if font_files:
  64. # 随机选择一个字体文件
  65. selected_font = random.choice(font_files)
  66. try:
  67. # 尝试加载字体以验证是否有效
  68. ImageFont.truetype(selected_font, 60)
  69. logger.info(f"从数字文件夹随机选择字体: {selected_font}")
  70. return selected_font
  71. except Exception as e:
  72. logger.warning(f"无法加载字体文件 {selected_font}: {e}")
  73. # 如果选中的字体无效,尝试其他字体
  74. for font_file in font_files:
  75. try:
  76. ImageFont.truetype(font_file, 60)
  77. logger.info(f"使用备用字体: {font_file}")
  78. return font_file
  79. except:
  80. continue
  81. # 如果"数字"文件夹没有可用字体,尝试系统默认字体
  82. font_paths = [
  83. # macOS 系统字体
  84. '/System/Library/Fonts/Supplemental/Arial.ttf',
  85. '/System/Library/Fonts/Helvetica.ttc',
  86. # Windows 系统字体(如果需要在Windows上运行)
  87. 'C:/Windows/Fonts/simhei.ttf',
  88. 'C:/Windows/Fonts/msyh.ttf',
  89. # Linux 系统字体
  90. '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
  91. '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
  92. ]
  93. # 尝试查找系统字体文件
  94. for font_path in font_paths:
  95. if os.path.exists(font_path):
  96. try:
  97. return font_path
  98. except:
  99. continue
  100. # 如果找不到,返回 None,将使用 PIL 默认字体
  101. logger.warning("未找到可用字体文件,将使用 PIL 默认字体")
  102. return None
  103. def add_handwriting_effect(draw, text, x, y, font, font_size, text_color=(0, 0, 0)):
  104. """
  105. 添加手写效果:轻微的位置扰动
  106. 注意:对于中文字符,建议使用手写字体文件而不是字符级别的偏移
  107. """
  108. chars = list(text)
  109. current_x = x
  110. for i, char in enumerate(chars):
  111. # 每个字符的轻微随机偏移(模拟手写的不规则性)
  112. offset_x = random.randint(-1, 1)
  113. offset_y = random.randint(-1, 1)
  114. # 绘制字符
  115. draw.text((current_x + offset_x, y + offset_y), char, font=font, fill=text_color)
  116. # 获取字符宽度
  117. try:
  118. bbox = draw.textbbox((0, 0), char, font=font)
  119. char_width = bbox[2] - bbox[0]
  120. except:
  121. # 如果失败,使用简单估算
  122. # 中文字符通常宽度约为字体大小,英文和数字约为字体大小的0.6倍
  123. if ord(char) > 127: # 中文字符
  124. char_width = font_size
  125. else: # 英文或数字
  126. char_width = int(font_size * 0.6)
  127. current_x += char_width
  128. def generate_handwriting_image(text, font_size=60, width=None, height=None,
  129. background_color=(255, 255, 255),
  130. text_color=(0, 0, 0),
  131. padding=20,
  132. font_path=None,
  133. use_handwriting_effect=True):
  134. """
  135. 生成手写字体图片
  136. 参数:
  137. - text: 要生成的文字
  138. - font_size: 字体大小(默认60)
  139. - width: 图片宽度(None 表示自动计算)
  140. - height: 图片高度(None 表示自动计算)
  141. - background_color: 背景颜色(RGB元组,默认白色;如果传入RGBA元组,则支持透明度)
  142. - text_color: 文字颜色(RGB元组,默认黑色)
  143. - padding: 内边距(默认20像素)
  144. - font_path: 字体文件路径(None 表示使用默认字体或自动查找)
  145. - use_handwriting_effect: 是否使用手写效果(默认True)
  146. 返回:
  147. - PIL Image 对象
  148. """
  149. try:
  150. # 加载字体
  151. if font_path and os.path.exists(font_path):
  152. try:
  153. font = ImageFont.truetype(font_path, font_size)
  154. except:
  155. logger.warning(f"无法加载字体文件 {font_path},使用默认字体")
  156. font = ImageFont.load_default()
  157. else:
  158. # 尝试查找字体文件
  159. found_font_path = find_font_file()
  160. if found_font_path:
  161. try:
  162. font = ImageFont.truetype(found_font_path, font_size)
  163. except:
  164. font = ImageFont.load_default()
  165. else:
  166. font = ImageFont.load_default()
  167. # 将背景颜色转换为RGBA格式(支持透明背景)
  168. if len(background_color) == 3:
  169. # RGB格式,转换为RGBA,alpha设为0(完全透明)
  170. bg_color_rgba = (*background_color, 0)
  171. elif len(background_color) == 4:
  172. # 已经是RGBA格式
  173. bg_color_rgba = background_color
  174. else:
  175. # 默认透明背景
  176. bg_color_rgba = (255, 255, 255, 0)
  177. # 创建临时绘图对象来测量文字尺寸(使用RGBA模式)
  178. temp_img = Image.new('RGBA', (100, 100), bg_color_rgba)
  179. temp_draw = ImageDraw.Draw(temp_img)
  180. # 获取文字边界框
  181. try:
  182. bbox = temp_draw.textbbox((0, 0), text, font=font)
  183. text_width = bbox[2] - bbox[0]
  184. text_height = bbox[3] - bbox[1]
  185. # bbox的偏移量(某些字符可能超出基线)
  186. bbox_offset_x = bbox[0]
  187. bbox_offset_y = bbox[1]
  188. except:
  189. # 如果失败,使用简单估算
  190. text_width = len(text) * font_size * 0.6
  191. text_height = font_size * 1.2
  192. bbox_offset_x = 0
  193. bbox_offset_y = 0
  194. # 计算图片尺寸
  195. if width is None:
  196. width = int(text_width + padding * 2)
  197. if height is None:
  198. height = int(text_height + padding * 2)
  199. # 创建图片(使用RGBA模式支持透明背景)
  200. image = Image.new('RGBA', (width, height), bg_color_rgba)
  201. draw = ImageDraw.Draw(image)
  202. # 计算文字起始位置(完美居中)
  203. # 考虑bbox的偏移量,确保文字在图片正中央
  204. x = (width - text_width) // 2 - bbox_offset_x
  205. y = (height - text_height) // 2 - bbox_offset_y
  206. # 绘制文字
  207. if use_handwriting_effect:
  208. # 使用手写效果(每个字符轻微偏移)
  209. add_handwriting_effect(draw, text, x, y, font, font_size, text_color)
  210. else:
  211. # 直接绘制文字
  212. draw.text((x, y), text, font=font, fill=text_color)
  213. logger.info(f"成功生成文字图片: {text[:20]}...")
  214. return image
  215. except Exception as e:
  216. logger.error(f"生成文字图片失败: {e}")
  217. raise Exception(f"生成文字图片失败: {str(e)}")
  218. def generate_and_upload_handwriting(text, font_size=60, width=None, height=None,
  219. background_color=(255, 255, 255),
  220. text_color=(0, 0, 0),
  221. padding=20,
  222. font_path=None,
  223. use_handwriting_effect=True):
  224. """
  225. 生成手写字体图片并上传到七牛云
  226. 参数:
  227. - text: 要生成的文字
  228. - font_size: 字体大小(默认60)
  229. - width: 图片宽度(None 表示自动计算)
  230. - height: 图片高度(None 表示自动计算)
  231. - background_color: 背景颜色(RGB元组,默认白色)
  232. - text_color: 文字颜色(RGB元组,默认黑色)
  233. - padding: 内边距(默认20像素)
  234. - font_path: 字体文件路径(None 表示使用默认字体或自动查找)
  235. - use_handwriting_effect: 是否使用手写效果(默认True)
  236. 返回:
  237. - 七牛云上的图片URL
  238. """
  239. temp_file = None
  240. try:
  241. # 创建临时目录
  242. temp_dir = "./temp"
  243. if not os.path.exists(temp_dir):
  244. os.makedirs(temp_dir)
  245. # 生成图片
  246. image = generate_handwriting_image(
  247. text=text,
  248. font_size=font_size,
  249. width=width,
  250. height=height,
  251. background_color=background_color,
  252. text_color=text_color,
  253. padding=padding,
  254. font_path=font_path,
  255. use_handwriting_effect=use_handwriting_effect
  256. )
  257. # 保存临时文件
  258. temp_file = os.path.join(temp_dir, f"handwriting_{uuid.uuid4()}.png")
  259. image.save(temp_file, "PNG")
  260. # 上传到七牛云
  261. file_key = f"UpImage/{uuid.uuid4()}.png"
  262. image_url = Qiniu.upload_to_qiniu(temp_file, file_key)
  263. logger.info(f"成功上传文字图片到七牛云: {image_url}")
  264. return image_url
  265. except Exception as e:
  266. logger.error(f"生成并上传文字图片失败: {e}")
  267. raise
  268. finally:
  269. # 清理临时文件
  270. if temp_file and os.path.exists(temp_file):
  271. try:
  272. os.remove(temp_file)
  273. except Exception as e:
  274. logger.warning(f"清理临时文件失败: {e}")
  275. def create_handwriting_route(app):
  276. """
  277. 创建手写字体图片生成接口路由
  278. 参数:
  279. - app: Flask应用实例
  280. """
  281. @app.route('/get_font_list', methods=['GET'])
  282. def get_font_list_api():
  283. """
  284. 获取可用字体列表接口
  285. 返回:
  286. {
  287. "success": true,
  288. "fonts": [
  289. {"path": "字体路径", "name": "字体名称", "filename": "文件名"}
  290. ]
  291. }
  292. """
  293. try:
  294. fonts = get_font_list()
  295. return jsonify({
  296. "success": True,
  297. "fonts": fonts
  298. })
  299. except Exception as e:
  300. logger.error(f"获取字体列表失败: {e}")
  301. return jsonify({
  302. "success": False,
  303. "error": str(e)
  304. }), 500
  305. def _generate_single_font_preview(font, text, font_size, width, height,
  306. background_color, text_color, padding,
  307. use_handwriting_effect):
  308. """
  309. 辅助函数:生成单个字体的预览图片
  310. 用于多线程并行处理
  311. """
  312. try:
  313. # 生成图片
  314. image = generate_handwriting_image(
  315. text=text,
  316. font_size=font_size,
  317. width=width,
  318. height=height,
  319. background_color=background_color,
  320. text_color=text_color,
  321. padding=padding,
  322. font_path=font['path'],
  323. use_handwriting_effect=use_handwriting_effect
  324. )
  325. # 将图片转换为base64
  326. img_io = io.BytesIO()
  327. image.save(img_io, 'PNG')
  328. img_io.seek(0)
  329. img_base64 = base64.b64encode(img_io.read()).decode('utf-8')
  330. return {
  331. "font_path": font['path'],
  332. "font_name": font['name'],
  333. "image_base64": "data:image/png;base64," + img_base64
  334. }
  335. except Exception as e:
  336. logger.warning(f"生成字体 {font['name']} 预览失败: {e}")
  337. return None
  338. @app.route('/batch_generate_preview', methods=['POST'])
  339. def batch_generate_preview():
  340. """
  341. 批量生成字体预览接口(返回多个图片的base64编码)
  342. 请求参数(JSON):
  343. {
  344. "text": "要预览的文字",
  345. "font_size": 60,
  346. "padding": 20,
  347. "background_color": [255, 255, 255, 0],
  348. "text_color": [0, 0, 0],
  349. "use_handwriting_effect": true
  350. }
  351. 返回:
  352. {
  353. "success": true,
  354. "previews": [
  355. {
  356. "font_path": "字体路径",
  357. "font_name": "字体名称",
  358. "image_base64": "base64编码的图片数据"
  359. }
  360. ]
  361. }
  362. """
  363. try:
  364. data = request.get_json()
  365. if not data:
  366. return jsonify({
  367. "success": False,
  368. "error": "请求参数为空"
  369. }), 400
  370. text = data.get('text')
  371. if not text:
  372. return jsonify({
  373. "success": False,
  374. "error": "缺少text参数"
  375. }), 400
  376. # 获取可选参数
  377. font_size = data.get('font_size', 60)
  378. width = data.get('width')
  379. height = data.get('height')
  380. background_color = tuple(data.get('background_color', [255, 255, 255]))
  381. text_color = tuple(data.get('text_color', [0, 0, 0]))
  382. padding = data.get('padding', 20)
  383. use_handwriting_effect = data.get('use_handwriting_effect', True)
  384. # 获取所有字体
  385. fonts = get_font_list()
  386. if not fonts:
  387. return jsonify({
  388. "success": False,
  389. "error": "没有可用的字体"
  390. }), 400
  391. previews = []
  392. # 使用线程池并行处理,最多同时处理10个字体(可根据需要调整)
  393. max_workers = min(10, len(fonts)) # 根据字体数量动态调整线程数
  394. with ThreadPoolExecutor(max_workers=max_workers) as executor:
  395. # 提交所有任务
  396. future_to_font = {
  397. executor.submit(
  398. _generate_single_font_preview,
  399. font, text, font_size, width, height,
  400. background_color, text_color, padding,
  401. use_handwriting_effect
  402. ): font for font in fonts
  403. }
  404. # 收集结果
  405. for future in as_completed(future_to_font):
  406. result = future.result()
  407. if result is not None:
  408. previews.append(result)
  409. return jsonify({
  410. "success": True,
  411. "previews": previews,
  412. "total": len(previews)
  413. })
  414. except Exception as e:
  415. logger.error(f"批量生成预览失败: {e}")
  416. return jsonify({
  417. "success": False,
  418. "error": str(e)
  419. }), 500
  420. @app.route('/generate_handwriting', methods=['POST'])
  421. def generate_handwriting():
  422. """
  423. 生成手写字体图片接口(直接返回图片文件)
  424. 请求参数(JSON):
  425. {
  426. "text": "要生成的文字", // 必需
  427. "font_size": 60, // 可选,字体大小(默认60)
  428. "width": null, // 可选,图片宽度(null表示自动计算)
  429. "height": null, // 可选,图片高度(null表示自动计算)
  430. "background_color": [255, 255, 255] 或 [255, 255, 255, 0], // 可选,背景颜色RGB或RGBA(默认透明背景,alpha=0)
  431. "text_color": [0, 0, 0], // 可选,文字颜色RGB(默认黑色)
  432. "padding": 20, // 可选,内边距(默认20像素)
  433. "font_path": null, // 可选,字体文件路径(null表示使用默认字体)
  434. "use_handwriting_effect": true // 可选,是否使用手写效果(默认true)
  435. }
  436. 返回:
  437. - PNG图片文件(直接返回,不返回JSON)
  438. """
  439. try:
  440. # 获取请求参数
  441. data = request.get_json()
  442. if not data:
  443. return jsonify({
  444. "success": False,
  445. "error": "请求参数为空"
  446. }), 400
  447. text = data.get('text')
  448. if not text:
  449. return jsonify({
  450. "success": False,
  451. "error": "缺少text参数"
  452. }), 400
  453. # 获取可选参数
  454. font_size = data.get('font_size', 60)
  455. width = data.get('width')
  456. height = data.get('height')
  457. background_color = tuple(data.get('background_color', [255, 255, 255]))
  458. text_color = tuple(data.get('text_color', [0, 0, 0]))
  459. padding = data.get('padding', 20)
  460. font_path = data.get('font_path')
  461. use_handwriting_effect = data.get('use_handwriting_effect', True)
  462. # 生成图片
  463. image = generate_handwriting_image(
  464. text=text,
  465. font_size=font_size,
  466. width=width,
  467. height=height,
  468. background_color=background_color,
  469. text_color=text_color,
  470. padding=padding,
  471. font_path=font_path,
  472. use_handwriting_effect=use_handwriting_effect
  473. )
  474. # 将图片保存到内存中的字节流
  475. img_io = io.BytesIO()
  476. image.save(img_io, 'PNG')
  477. img_io.seek(0)
  478. logger.info(f"成功生成文字图片: {text[:20]}...")
  479. # 直接返回图片文件
  480. return send_file(
  481. img_io,
  482. mimetype='image/png',
  483. as_attachment=False,
  484. download_name='handwriting.png'
  485. )
  486. except Exception as e:
  487. logger.error(f"接口处理失败: {e}")
  488. return jsonify({
  489. "success": False,
  490. "error": str(e)
  491. }), 500