font_tool.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. # -*- coding: utf-8 -*-
  2. """
  3. Created on 2024-10-31
  4. ---------
  5. @summary: 解析图片文本
  6. ---------
  7. @author: Dzr
  8. """
  9. import io
  10. import pathlib
  11. import random
  12. import re
  13. import string
  14. from pathlib import Path
  15. from urllib.request import urlretrieve
  16. import numpy as np
  17. from PIL import Image, ImageOps
  18. from ddddocr import DdddOcr
  19. from ddddocr import base64_to_image, get_img_base64
  20. from fontTools.misc.transform import Offset
  21. from fontTools.pens.freetypePen import FreeTypePen # pip install freetype-py
  22. from fontTools.ttLib import TTFont
  23. _root = Path(__file__).parent
  24. _cache_dir = _root.joinpath('cache')
  25. _cache_dir.mkdir(exist_ok=True)
  26. _font_dir = _cache_dir.joinpath('font')
  27. _font_dir.mkdir(exist_ok=True)
  28. _image_dir = _cache_dir.joinpath('image')
  29. _image_dir.mkdir(exist_ok=True)
  30. def get_random(length=4):
  31. return ''.join(random.sample(string.ascii_letters + string.digits, length))
  32. def parse_font_url(html):
  33. result = re.search(r"'icomoon';src:url\('(.*?)'\)", html, re.S)
  34. if result is None:
  35. raise ValueError(f'字体库 url "{result}" ')
  36. return result.group(1)
  37. def create_file(filename):
  38. file = _font_dir.joinpath(filename)
  39. file.touch(exist_ok=True)
  40. return file
  41. def download_font(html, font_type='ttf', to_local=False):
  42. filename = f'{get_random(6)}.{font_type}'
  43. tmp = create_file(filename)
  44. url = parse_font_url(html)
  45. urlretrieve(url, filename=tmp)
  46. if not to_local:
  47. file_bytes = tmp.read_bytes()
  48. tmp.unlink(missing_ok=True)
  49. tmp = file_bytes
  50. return tmp
  51. def image_to_bytes(image, filetype='JPEG'):
  52. byte_stream = io.BytesIO()
  53. image.save(byte_stream, format=filetype)
  54. byte_array = byte_stream.getvalue()
  55. return byte_array
  56. def open_image(image_path):
  57. if isinstance(image_path, bytes):
  58. img = Image.open(io.BytesIO(image_path))
  59. elif isinstance(image_path, tuple):
  60. img = Image.open(io.BytesIO(image_path[1]))
  61. elif isinstance(image_path, str):
  62. img = base64_to_image(get_img_base64(image_path))
  63. else:
  64. assert isinstance(image_path, pathlib.PurePath)
  65. img = Image.open(image_path)
  66. return img
  67. def rgb_image_is_pure_white(image_path):
  68. image = open_image(image_path)
  69. if image.mode != 'RGB':
  70. image = image.convert('RGB')
  71. # 获取图片的宽度和高度
  72. width, height = image.size
  73. # 遍历图片中的所有像素
  74. for y in range(height):
  75. for x in range(width):
  76. # 获取当前像素的RGB值
  77. r, g, b = image.getpixel((x, y))
  78. # 判断像素是否为白色(RGB值都为255)
  79. if not (r == 255 and g == 255 and b == 255):
  80. return False
  81. return True
  82. def grey_image_is_pure_white(image_path):
  83. img = open_image(image_path)
  84. # 确保图像是灰度图像
  85. if img.mode != 'L':
  86. img = img.convert('L')
  87. # 获取图像数据
  88. pixels = list(img.getdata())
  89. # 检查所有像素值是否都等于最大灰度值
  90. return all(pixel == 255 for pixel in pixels)
  91. def is_pure_white(image_path, mode='L'):
  92. if mode == 'L':
  93. return grey_image_is_pure_white(image_path)
  94. elif mode == 'RGB':
  95. return rgb_image_is_pure_white(image_path)
  96. else:
  97. raise AssertionError(f'{mode} is not supported')
  98. class ImageToText:
  99. def __init__(self, file, cache=False, ocr=False, callback=None, image_scale=5, auto_delete=True):
  100. """
  101. @param file: 字体文件
  102. @param cache: 缓存字体图片到本地磁盘
  103. @param ocr: 图片识别启用Ocr
  104. @param image_scale: 图片缩放倍数
  105. @param callback: 图片文本识别处理的回调函数
  106. @param auto_delete: 自动清除字体图片
  107. """
  108. if not isinstance(file, (bytes, str, pathlib.PurePath)):
  109. raise TypeError("未知文件类型")
  110. if isinstance(file, bytes):
  111. self._font = TTFont(io.BytesIO(file))
  112. elif isinstance(file, str):
  113. self._font = TTFont(file)
  114. else:
  115. assert isinstance(file, pathlib.PurePath)
  116. self._font = TTFont(file)
  117. # 字体图片映射关系
  118. self._font_maps = {}
  119. self._image_scale = image_scale
  120. # 缓存
  121. self._cache_images = {}
  122. self._to_local = cache
  123. self._auto_delete = False if cache is True else auto_delete
  124. # Ocr
  125. self._callback = None
  126. self._enable_ocr = ocr
  127. if ocr is True:
  128. if callback is not None and callable(callback):
  129. self._callback = callback
  130. else:
  131. ddddocr = DdddOcr(beta=False, old=True, show_ad=False)
  132. def _classification(files):
  133. if isinstance(files, tuple):
  134. img = files[1]
  135. else:
  136. img = files
  137. return ddddocr.classification(img)
  138. self._callback = _classification
  139. def to_xml(self):
  140. filename = self._font.reader.file.name
  141. font_f = Path(filename).with_suffix('.xml')
  142. self._font.saveXML(font_f)
  143. @property
  144. def font_maps(self):
  145. return self._font_maps
  146. def parse_font(self):
  147. self._font_encode()
  148. if self._enable_ocr:
  149. self._font_draw()
  150. self._font_ocr()
  151. def _font_encode(self):
  152. for unicode, name in self._font.getBestCmap().items():
  153. code = f'&#{str(hex(unicode))[1:]}' # 0x100c4 => &#x100c4
  154. glyph = {'name': name, 'code': hex(unicode), 'zh': ''}
  155. self._font_maps[code] = glyph
  156. # print(code, glyph)
  157. def _font_draw(self):
  158. glyph_set = self._font.getGlyphSet()
  159. for code, glyph_dict in self._font_maps.items():
  160. # print(code, glyph_dict)
  161. glyph = glyph_set[glyph_dict['name']] # 获取字形
  162. pen = FreeTypePen(None) # 创建变换笔(FreeTypePen)实例,绘制字形
  163. glyph.draw(pen) # 绘制字形
  164. # 获取字形的宽度,以及从字体文件的 OS/2 表中获取推荐的上升高度和下降高度,确定图像的高度
  165. width, ascender, descender = (
  166. glyph.width,
  167. self._font['OS/2'].usWinAscent,
  168. -self._font['OS/2'].usWinDescent,
  169. )
  170. height = ascender - descender
  171. # 创建图像并转换为数组
  172. single_font_image = pen.array(
  173. width=width,
  174. height=height,
  175. transform=Offset(0, -descender),
  176. contain=False,
  177. evenOdd=False,
  178. )
  179. # 转换为灰度图像数组
  180. single_font_image = np.array(single_font_image) * 255
  181. # 反转颜色(使得黑色变为白色,白色变为黑色)
  182. single_font_image = 255 - single_font_image
  183. # 创建 PIL 图像对象
  184. single_font_image = Image.fromarray(single_font_image)
  185. # 转换为灰度模式
  186. single_font_image = single_font_image.convert("L")
  187. # 图片添加边框
  188. single_font_image = ImageOps.expand(single_font_image, border=6, fill=255)
  189. # 计算新的宽度和高度
  190. new_width = single_font_image.width // self._image_scale
  191. new_height = single_font_image.height // self._image_scale
  192. # 调整图片大小
  193. single_font_image = single_font_image.resize(
  194. (new_width, new_height),
  195. resample=Image.Resampling.LANCZOS
  196. )
  197. image_name = f'{glyph_dict["code"]}.jpg'
  198. if not self._to_local:
  199. image_bytes = image_to_bytes(single_font_image)
  200. self._cache_images[code] = (image_name, image_bytes, 'jpg')
  201. else:
  202. single_font_image.save(_image_dir.joinpath(image_name)) # 保存图像,灰度图
  203. def _extract_text(self, files):
  204. text = ''
  205. if not is_pure_white(files, mode='L'):
  206. text = self._callback(files)
  207. return text
  208. def _font_ocr(self):
  209. for code, glyph_dict in dict(self._font_maps).items():
  210. if not self._to_local:
  211. files = self._cache_images[code]
  212. text = self._extract_text(files)
  213. else:
  214. files = _image_dir.joinpath(f'{glyph_dict["code"]}.jpg')
  215. text = self._extract_text(files)
  216. self._font_maps[code]['zh'] = text
  217. def __contains__(self, key):
  218. return key in self._font_maps
  219. def __getitem__(self, key):
  220. if key in self._font_maps:
  221. return self._font_maps[key]
  222. else:
  223. raise KeyError(key)
  224. def get(self, key, default=None):
  225. try:
  226. return self.__getitem__(key)
  227. except KeyError:
  228. return default
  229. def __enter__(self):
  230. return self
  231. def __exit__(self, exc_type, exc_val, exc_tb):
  232. self.__del__()
  233. return
  234. def _del(self, missing_ok=False):
  235. if self._auto_delete:
  236. for img_f in _image_dir.iterdir():
  237. img_f.unlink(missing_ok=True)
  238. for font_f in _font_dir.iterdir():
  239. font_f.unlink(missing_ok=True)
  240. try:
  241. # _image_dir.rmdir()
  242. # _font_dir.rmdir()
  243. _cache_dir.rmdir()
  244. except OSError as e:
  245. if not missing_ok:
  246. raise e
  247. def __del__(self):
  248. self._del(missing_ok=True)
  249. FontTranslator = ImageToText
  250. def parse_font(font_file, *, ocr=False, ocr_extract=None, **kwargs):
  251. ocr = True if ocr_extract is not None and callable(ocr_extract) else ocr
  252. translator = ImageToText(font_file, ocr=ocr, callback=ocr_extract, **kwargs)
  253. translator.parse_font()
  254. return translator