fontfinder.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. #Copyright ReportLab Europe Ltd. 2000-2019
  2. #see license.txt for license details
  3. __version__='3.4.22'
  4. #modification of users/robin/ttflist.py.
  5. __doc__="""This provides some general-purpose tools for finding fonts.
  6. The FontFinder object can search for font files. It aims to build
  7. a catalogue of fonts which our framework can work with. It may be useful
  8. if you are building GUIs or design-time interfaces and want to present users
  9. with a choice of fonts.
  10. There are 3 steps to using it
  11. 1. create FontFinder and set options and directories
  12. 2. search
  13. 3. query
  14. >>> import fontfinder
  15. >>> ff = fontfinder.FontFinder()
  16. >>> ff.addDirectories([dir1, dir2, dir3])
  17. >>> ff.search()
  18. >>> ff.getFamilyNames() #or whichever queries you want...
  19. Because the disk search takes some time to find and parse hundreds of fonts,
  20. it can use a cache to store a file with all fonts found. The cache file name
  21. For each font found, it creates a structure with
  22. - the short font name
  23. - the long font name
  24. - the principal file (.pfb for type 1 fonts), and the metrics file if appropriate
  25. - the time modified (unix time stamp)
  26. - a type code ('ttf')
  27. - the family name
  28. - bold and italic attributes
  29. One common use is to display families in a dialog for end users;
  30. then select regular, bold and italic variants of the font. To get
  31. the initial list, use getFamilyNames; these will be in alpha order.
  32. >>> ff.getFamilyNames()
  33. ['Bitstream Vera Sans', 'Century Schoolbook L', 'Dingbats', 'LettErrorRobot',
  34. 'MS Gothic', 'MS Mincho', 'Nimbus Mono L', 'Nimbus Roman No9 L',
  35. 'Nimbus Sans L', 'Vera', 'Standard Symbols L',
  36. 'URW Bookman L', 'URW Chancery L', 'URW Gothic L', 'URW Palladio L']
  37. One can then obtain a specific font as follows
  38. >>> f = ff.getFont('Bitstream Vera Sans', bold=False, italic=True)
  39. >>> f.fullName
  40. 'Bitstream Vera Sans'
  41. >>> f.fileName
  42. 'C:\\code\\reportlab\\fonts\\Vera.ttf'
  43. >>>
  44. It can also produce an XML report of fonts found by family, for the benefit
  45. of non-Python applications.
  46. Future plans might include using this to auto-register fonts; and making it
  47. update itself smartly on repeated instantiation.
  48. """
  49. import sys, os, tempfile
  50. from reportlab.lib.utils import pickle, asNative as _asNative
  51. from xml.sax.saxutils import quoteattr
  52. from reportlab.lib.utils import asBytes
  53. try:
  54. from time import process_time as clock
  55. except ImportError:
  56. from time import clock
  57. try:
  58. from hashlib import md5
  59. except ImportError:
  60. from md5 import md5
  61. def asNative(s):
  62. try:
  63. return _asNative(s)
  64. except:
  65. return _asNative(s,enc='latin-1')
  66. EXTENSIONS = ['.ttf','.ttc','.otf','.pfb','.pfa']
  67. # PDF font flags (see PDF Reference Guide table 5.19)
  68. FF_FIXED = 1 << 1-1
  69. FF_SERIF = 1 << 2-1
  70. FF_SYMBOLIC = 1 << 3-1
  71. FF_SCRIPT = 1 << 4-1
  72. FF_NONSYMBOLIC = 1 << 6-1
  73. FF_ITALIC = 1 << 7-1
  74. FF_ALLCAP = 1 << 17-1
  75. FF_SMALLCAP = 1 << 18-1
  76. FF_FORCEBOLD = 1 << 19-1
  77. class FontDescriptor:
  78. """This is a short descriptive record about a font.
  79. typeCode should be a file extension e.g. ['ttf','ttc','otf','pfb','pfa']
  80. """
  81. def __init__(self):
  82. self.name = None
  83. self.fullName = None
  84. self.familyName = None
  85. self.styleName = None
  86. self.isBold = False #true if it's somehow bold
  87. self.isItalic = False #true if it's italic or oblique or somehow slanty
  88. self.isFixedPitch = False
  89. self.isSymbolic = False #false for Dingbats, Symbols etc.
  90. self.typeCode = None #normally the extension minus the dot
  91. self.fileName = None #full path to where we found it.
  92. self.metricsFileName = None #defined only for type='type1pc', or 'type1mac'
  93. self.timeModified = 0
  94. def __repr__(self):
  95. return "FontDescriptor(%s)" % self.name
  96. def getTag(self):
  97. "Return an XML tag representation"
  98. attrs = []
  99. for k, v in self.__dict__.items():
  100. if k not in ['timeModified']:
  101. if v:
  102. attrs.append('%s=%s' % (k, quoteattr(str(v))))
  103. return '<font ' + ' '.join(attrs) + '/>'
  104. from reportlab.lib.utils import rl_isdir, rl_isfile, rl_listdir, rl_getmtime
  105. class FontFinder:
  106. def __init__(self, dirs=[], useCache=True, validate=False, recur=False, fsEncoding=None, verbose=0):
  107. self.useCache = useCache
  108. self.validate = validate
  109. if fsEncoding is None:
  110. fsEncoding = sys.getfilesystemencoding()
  111. self._fsEncoding = fsEncoding or 'utf8'
  112. self._dirs = set()
  113. self._recur = recur
  114. self.addDirectories(dirs)
  115. self._fonts = []
  116. self._skippedFiles = [] #list of filenames we did not handle
  117. self._badFiles = [] #list of filenames we rejected
  118. self._fontsByName = {}
  119. self._fontsByFamily = {}
  120. self._fontsByFamilyBoldItalic = {} #indexed by bold, italic
  121. self.verbose = verbose
  122. def addDirectory(self, dirName, recur=None):
  123. #aesthetics - if there are 2 copies of a font, should the first or last
  124. #be picked up? might need reversing
  125. if rl_isdir(dirName):
  126. self._dirs.add(dirName)
  127. if recur if recur is not None else self._recur:
  128. for r,D,F in os.walk(dirName):
  129. for d in D:
  130. self._dirs.add(os.path.join(r,d))
  131. def addDirectories(self, dirNames,recur=None):
  132. for dirName in dirNames:
  133. self.addDirectory(dirName,recur=recur)
  134. def getFamilyNames(self):
  135. "Returns a list of the distinct font families found"
  136. if not self._fontsByFamily:
  137. fonts = self._fonts
  138. for font in fonts:
  139. fam = font.familyName
  140. if fam is None: continue
  141. if fam in self._fontsByFamily:
  142. self._fontsByFamily[fam].append(font)
  143. else:
  144. self._fontsByFamily[fam] = [font]
  145. fsEncoding = self._fsEncoding
  146. names = list(asBytes(_,enc=fsEncoding) for _ in self._fontsByFamily.keys())
  147. names.sort()
  148. return names
  149. def getFontsInFamily(self, familyName):
  150. "Return list of all font objects with this family name"
  151. return self._fontsByFamily.get(familyName,[])
  152. def getFamilyXmlReport(self):
  153. """Reports on all families found as XML.
  154. """
  155. lines = []
  156. lines.append('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>')
  157. lines.append("<font_families>")
  158. for dirName in self._dirs:
  159. lines.append(" <directory name=%s/>" % quoteattr(asNative(dirName)))
  160. for familyName in self.getFamilyNames():
  161. if familyName: #skip null case
  162. lines.append(' <family name=%s>' % quoteattr(asNative(familyName)))
  163. for font in self.getFontsInFamily(familyName):
  164. lines.append(' ' + font.getTag())
  165. lines.append(' </family>')
  166. lines.append("</font_families>")
  167. return '\n'.join(lines)
  168. def getFontsWithAttributes(self, **kwds):
  169. """This is a general lightweight search."""
  170. selected = []
  171. for font in self._fonts:
  172. OK = True
  173. for k, v in kwds.items():
  174. if getattr(font, k, None) != v:
  175. OK = False
  176. if OK:
  177. selected.append(font)
  178. return selected
  179. def getFont(self, familyName, bold=False, italic=False):
  180. """Try to find a font matching the spec"""
  181. for font in self._fonts:
  182. if font.familyName == familyName:
  183. if font.isBold == bold:
  184. if font.isItalic == italic:
  185. return font
  186. raise KeyError("Cannot find font %s with bold=%s, italic=%s" % (familyName, bold, italic))
  187. def _getCacheFileName(self):
  188. """Base this on the directories...same set of directories
  189. should give same cache"""
  190. fsEncoding = self._fsEncoding
  191. hash = md5(b''.join(asBytes(_,enc=fsEncoding) for _ in sorted(self._dirs))).hexdigest()
  192. from reportlab.lib.utils import get_rl_tempfile
  193. fn = get_rl_tempfile('fonts_%s.dat' % hash)
  194. return fn
  195. def save(self, fileName):
  196. f = open(fileName, 'wb')
  197. pickle.dump(self, f)
  198. f.close()
  199. def load(self, fileName):
  200. f = open(fileName, 'rb')
  201. finder2 = pickle.load(f)
  202. f.close()
  203. self.__dict__.update(finder2.__dict__)
  204. def search(self):
  205. if self.verbose:
  206. started = clock()
  207. if not self._dirs:
  208. raise ValueError("Font search path is empty! Please specify search directories using addDirectory or addDirectories")
  209. if self.useCache:
  210. cfn = self._getCacheFileName()
  211. if rl_isfile(cfn):
  212. try:
  213. self.load(cfn)
  214. if self.verbose>=3:
  215. print("loaded cached file with %d fonts (%s)" % (len(self._fonts), cfn))
  216. return
  217. except:
  218. pass #pickle load failed. Ho hum, maybe it's an old pickle. Better rebuild it.
  219. from stat import ST_MTIME
  220. for dirName in self._dirs:
  221. try:
  222. fileNames = rl_listdir(dirName)
  223. except:
  224. continue
  225. for fileName in fileNames:
  226. root, ext = os.path.splitext(fileName)
  227. if ext.lower() in EXTENSIONS:
  228. #it's a font
  229. f = FontDescriptor()
  230. f.fileName = fileName = os.path.normpath(os.path.join(dirName, fileName))
  231. try:
  232. f.timeModified = rl_getmtime(fileName)
  233. except:
  234. self._skippedFiles.append(fileName)
  235. continue
  236. ext = ext.lower()
  237. if ext[0] == '.':
  238. ext = ext[1:]
  239. f.typeCode = ext #strip the dot
  240. #what to do depends on type. We only accept .pfb if we
  241. #have .afm to go with it, and don't handle .otf now.
  242. if ext in ('otf', 'pfa'):
  243. self._skippedFiles.append(fileName)
  244. elif ext in ('ttf','ttc'):
  245. #parsing should check it for us
  246. from reportlab.pdfbase.ttfonts import TTFontFile, TTFError
  247. try:
  248. font = TTFontFile(fileName,validate=self.validate)
  249. except TTFError:
  250. self._badFiles.append(fileName)
  251. continue
  252. f.name = font.name
  253. f.fullName = font.fullName
  254. f.styleName = font.styleName
  255. f.familyName = font.familyName
  256. f.isBold = (FF_FORCEBOLD == FF_FORCEBOLD & font.flags)
  257. f.isItalic = (FF_ITALIC == FF_ITALIC & font.flags)
  258. elif ext == 'pfb':
  259. # type 1; we need an AFM file or have to skip.
  260. if rl_isfile(os.path.join(dirName, root + '.afm')):
  261. f.metricsFileName = os.path.normpath(os.path.join(dirName, root + '.afm'))
  262. elif rl_isfile(os.path.join(dirName, root + '.AFM')):
  263. f.metricsFileName = os.path.normpath(os.path.join(dirName, root + '.AFM'))
  264. else:
  265. self._skippedFiles.append(fileName)
  266. continue
  267. from reportlab.pdfbase.pdfmetrics import parseAFMFile
  268. (info, glyphs) = parseAFMFile(f.metricsFileName)
  269. f.name = info['FontName']
  270. f.fullName = info.get('FullName', f.name)
  271. f.familyName = info.get('FamilyName', None)
  272. f.isItalic = (float(info.get('ItalicAngle', 0)) > 0.0)
  273. #if the weight has the word bold, deem it bold
  274. f.isBold = ('bold' in info.get('Weight','').lower())
  275. self._fonts.append(f)
  276. if self.useCache:
  277. self.save(cfn)
  278. if self.verbose:
  279. finished = clock()
  280. print("found %d fonts; skipped %d; bad %d. Took %0.2f seconds" % (
  281. len(self._fonts), len(self._skippedFiles), len(self._badFiles),
  282. finished - started
  283. ))
  284. def test():
  285. #windows-centric test maybe
  286. from reportlab import rl_config
  287. ff = FontFinder(verbose=rl_config.verbose)
  288. ff.useCache = True
  289. ff.validate = True
  290. import reportlab
  291. ff.addDirectory('C:\\windows\\fonts')
  292. rlFontDir = os.path.join(os.path.dirname(reportlab.__file__), 'fonts')
  293. ff.addDirectory(rlFontDir)
  294. ff.search()
  295. print('cache file name...')
  296. print(ff._getCacheFileName())
  297. print('families...')
  298. for familyName in ff.getFamilyNames():
  299. print('\t%s' % familyName)
  300. print()
  301. outw = sys.stdout.write
  302. outw('fonts called Vera:')
  303. for font in ff.getFontsInFamily('Bitstream Vera Sans'):
  304. outw(' %s' % font.name)
  305. print()
  306. outw('Bold fonts\n\t')
  307. for font in ff.getFontsWithAttributes(isBold=True, isItalic=False):
  308. outw(font.fullName+' ')
  309. print()
  310. print('family report')
  311. print(ff.getFamilyXmlReport())
  312. if __name__=='__main__':
  313. test()