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