You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			440 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			440 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
"""Module for reading and writing AFM (Adobe Font Metrics) files.
 | 
						|
 | 
						|
Note that this has been designed to read in AFM files generated by Fontographer
 | 
						|
and has not been tested on many other files. In particular, it does not
 | 
						|
implement the whole Adobe AFM specification [#f1]_ but, it should read most
 | 
						|
"common" AFM files.
 | 
						|
 | 
						|
Here is an example of using `afmLib` to read, modify and write an AFM file:
 | 
						|
 | 
						|
	>>> from fontTools.afmLib import AFM
 | 
						|
	>>> f = AFM("Tests/afmLib/data/TestAFM.afm")
 | 
						|
	>>>
 | 
						|
	>>> # Accessing a pair gets you the kern value
 | 
						|
	>>> f[("V","A")]
 | 
						|
	-60
 | 
						|
	>>>
 | 
						|
	>>> # Accessing a glyph name gets you metrics
 | 
						|
	>>> f["A"]
 | 
						|
	(65, 668, (8, -25, 660, 666))
 | 
						|
	>>> # (charnum, width, bounding box)
 | 
						|
	>>>
 | 
						|
	>>> # Accessing an attribute gets you metadata
 | 
						|
	>>> f.FontName
 | 
						|
	'TestFont-Regular'
 | 
						|
	>>> f.FamilyName
 | 
						|
	'TestFont'
 | 
						|
	>>> f.Weight
 | 
						|
	'Regular'
 | 
						|
	>>> f.XHeight
 | 
						|
	500
 | 
						|
	>>> f.Ascender
 | 
						|
	750
 | 
						|
	>>>
 | 
						|
	>>> # Attributes and items can also be set
 | 
						|
	>>> f[("A","V")] = -150 # Tighten kerning
 | 
						|
	>>> f.FontName = "TestFont Squished"
 | 
						|
	>>>
 | 
						|
	>>> # And the font written out again (remove the # in front)
 | 
						|
	>>> #f.write("testfont-squished.afm")
 | 
						|
 | 
						|
.. rubric:: Footnotes
 | 
						|
 | 
						|
.. [#f1] `Adobe Technote 5004 <https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5004.AFM_Spec.pdf>`_,
 | 
						|
   Adobe Font Metrics File Format Specification.
 | 
						|
 | 
						|
"""
 | 
						|
 | 
						|
import re
 | 
						|
 | 
						|
# every single line starts with a "word"
 | 
						|
identifierRE = re.compile(r"^([A-Za-z]+).*")
 | 
						|
 | 
						|
# regular expression to parse char lines
 | 
						|
charRE = re.compile(
 | 
						|
    r"(-?\d+)"  # charnum
 | 
						|
    r"\s*;\s*WX\s+"  # ; WX
 | 
						|
    r"(-?\d+)"  # width
 | 
						|
    r"\s*;\s*N\s+"  # ; N
 | 
						|
    r"([.A-Za-z0-9_]+)"  # charname
 | 
						|
    r"\s*;\s*B\s+"  # ; B
 | 
						|
    r"(-?\d+)"  # left
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # bottom
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # right
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # top
 | 
						|
    r"\s*;\s*"  # ;
 | 
						|
)
 | 
						|
 | 
						|
# regular expression to parse kerning lines
 | 
						|
kernRE = re.compile(
 | 
						|
    r"([.A-Za-z0-9_]+)"  # leftchar
 | 
						|
    r"\s+"
 | 
						|
    r"([.A-Za-z0-9_]+)"  # rightchar
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # value
 | 
						|
    r"\s*"
 | 
						|
)
 | 
						|
 | 
						|
# regular expressions to parse composite info lines of the form:
 | 
						|
# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
 | 
						|
compositeRE = re.compile(
 | 
						|
    r"([.A-Za-z0-9_]+)"  # char name
 | 
						|
    r"\s+"
 | 
						|
    r"(\d+)"  # number of parts
 | 
						|
    r"\s*;\s*"
 | 
						|
)
 | 
						|
componentRE = re.compile(
 | 
						|
    r"PCC\s+"  # PPC
 | 
						|
    r"([.A-Za-z0-9_]+)"  # base char name
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # x offset
 | 
						|
    r"\s+"
 | 
						|
    r"(-?\d+)"  # y offset
 | 
						|
    r"\s*;\s*"
 | 
						|
)
 | 
						|
 | 
						|
preferredAttributeOrder = [
 | 
						|
    "FontName",
 | 
						|
    "FullName",
 | 
						|
    "FamilyName",
 | 
						|
    "Weight",
 | 
						|
    "ItalicAngle",
 | 
						|
    "IsFixedPitch",
 | 
						|
    "FontBBox",
 | 
						|
    "UnderlinePosition",
 | 
						|
    "UnderlineThickness",
 | 
						|
    "Version",
 | 
						|
    "Notice",
 | 
						|
    "EncodingScheme",
 | 
						|
    "CapHeight",
 | 
						|
    "XHeight",
 | 
						|
    "Ascender",
 | 
						|
    "Descender",
 | 
						|
]
 | 
						|
 | 
						|
 | 
						|
class error(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
class AFM(object):
 | 
						|
    _attrs = None
 | 
						|
 | 
						|
    _keywords = [
 | 
						|
        "StartFontMetrics",
 | 
						|
        "EndFontMetrics",
 | 
						|
        "StartCharMetrics",
 | 
						|
        "EndCharMetrics",
 | 
						|
        "StartKernData",
 | 
						|
        "StartKernPairs",
 | 
						|
        "EndKernPairs",
 | 
						|
        "EndKernData",
 | 
						|
        "StartComposites",
 | 
						|
        "EndComposites",
 | 
						|
    ]
 | 
						|
 | 
						|
    def __init__(self, path=None):
 | 
						|
        """AFM file reader.
 | 
						|
 | 
						|
        Instantiating an object with a path name will cause the file to be opened,
 | 
						|
        read, and parsed. Alternatively the path can be left unspecified, and a
 | 
						|
        file can be parsed later with the :meth:`read` method."""
 | 
						|
        self._attrs = {}
 | 
						|
        self._chars = {}
 | 
						|
        self._kerning = {}
 | 
						|
        self._index = {}
 | 
						|
        self._comments = []
 | 
						|
        self._composites = {}
 | 
						|
        if path is not None:
 | 
						|
            self.read(path)
 | 
						|
 | 
						|
    def read(self, path):
 | 
						|
        """Opens, reads and parses a file."""
 | 
						|
        lines = readlines(path)
 | 
						|
        for line in lines:
 | 
						|
            if not line.strip():
 | 
						|
                continue
 | 
						|
            m = identifierRE.match(line)
 | 
						|
            if m is None:
 | 
						|
                raise error("syntax error in AFM file: " + repr(line))
 | 
						|
 | 
						|
            pos = m.regs[1][1]
 | 
						|
            word = line[:pos]
 | 
						|
            rest = line[pos:].strip()
 | 
						|
            if word in self._keywords:
 | 
						|
                continue
 | 
						|
            if word == "C":
 | 
						|
                self.parsechar(rest)
 | 
						|
            elif word == "KPX":
 | 
						|
                self.parsekernpair(rest)
 | 
						|
            elif word == "CC":
 | 
						|
                self.parsecomposite(rest)
 | 
						|
            else:
 | 
						|
                self.parseattr(word, rest)
 | 
						|
 | 
						|
    def parsechar(self, rest):
 | 
						|
        m = charRE.match(rest)
 | 
						|
        if m is None:
 | 
						|
            raise error("syntax error in AFM file: " + repr(rest))
 | 
						|
        things = []
 | 
						|
        for fr, to in m.regs[1:]:
 | 
						|
            things.append(rest[fr:to])
 | 
						|
        charname = things[2]
 | 
						|
        del things[2]
 | 
						|
        charnum, width, l, b, r, t = (int(thing) for thing in things)
 | 
						|
        self._chars[charname] = charnum, width, (l, b, r, t)
 | 
						|
 | 
						|
    def parsekernpair(self, rest):
 | 
						|
        m = kernRE.match(rest)
 | 
						|
        if m is None:
 | 
						|
            raise error("syntax error in AFM file: " + repr(rest))
 | 
						|
        things = []
 | 
						|
        for fr, to in m.regs[1:]:
 | 
						|
            things.append(rest[fr:to])
 | 
						|
        leftchar, rightchar, value = things
 | 
						|
        value = int(value)
 | 
						|
        self._kerning[(leftchar, rightchar)] = value
 | 
						|
 | 
						|
    def parseattr(self, word, rest):
 | 
						|
        if word == "FontBBox":
 | 
						|
            l, b, r, t = [int(thing) for thing in rest.split()]
 | 
						|
            self._attrs[word] = l, b, r, t
 | 
						|
        elif word == "Comment":
 | 
						|
            self._comments.append(rest)
 | 
						|
        else:
 | 
						|
            try:
 | 
						|
                value = int(rest)
 | 
						|
            except (ValueError, OverflowError):
 | 
						|
                self._attrs[word] = rest
 | 
						|
            else:
 | 
						|
                self._attrs[word] = value
 | 
						|
 | 
						|
    def parsecomposite(self, rest):
 | 
						|
        m = compositeRE.match(rest)
 | 
						|
        if m is None:
 | 
						|
            raise error("syntax error in AFM file: " + repr(rest))
 | 
						|
        charname = m.group(1)
 | 
						|
        ncomponents = int(m.group(2))
 | 
						|
        rest = rest[m.regs[0][1] :]
 | 
						|
        components = []
 | 
						|
        while True:
 | 
						|
            m = componentRE.match(rest)
 | 
						|
            if m is None:
 | 
						|
                raise error("syntax error in AFM file: " + repr(rest))
 | 
						|
            basechar = m.group(1)
 | 
						|
            xoffset = int(m.group(2))
 | 
						|
            yoffset = int(m.group(3))
 | 
						|
            components.append((basechar, xoffset, yoffset))
 | 
						|
            rest = rest[m.regs[0][1] :]
 | 
						|
            if not rest:
 | 
						|
                break
 | 
						|
        assert len(components) == ncomponents
 | 
						|
        self._composites[charname] = components
 | 
						|
 | 
						|
    def write(self, path, sep="\r"):
 | 
						|
        """Writes out an AFM font to the given path."""
 | 
						|
        import time
 | 
						|
 | 
						|
        lines = [
 | 
						|
            "StartFontMetrics 2.0",
 | 
						|
            "Comment Generated by afmLib; at %s"
 | 
						|
            % (time.strftime("%m/%d/%Y %H:%M:%S", time.localtime(time.time()))),
 | 
						|
        ]
 | 
						|
 | 
						|
        # write comments, assuming (possibly wrongly!) they should
 | 
						|
        # all appear at the top
 | 
						|
        for comment in self._comments:
 | 
						|
            lines.append("Comment " + comment)
 | 
						|
 | 
						|
        # write attributes, first the ones we know about, in
 | 
						|
        # a preferred order
 | 
						|
        attrs = self._attrs
 | 
						|
        for attr in preferredAttributeOrder:
 | 
						|
            if attr in attrs:
 | 
						|
                value = attrs[attr]
 | 
						|
                if attr == "FontBBox":
 | 
						|
                    value = "%s %s %s %s" % value
 | 
						|
                lines.append(attr + " " + str(value))
 | 
						|
        # then write the attributes we don't know about,
 | 
						|
        # in alphabetical order
 | 
						|
        items = sorted(attrs.items())
 | 
						|
        for attr, value in items:
 | 
						|
            if attr in preferredAttributeOrder:
 | 
						|
                continue
 | 
						|
            lines.append(attr + " " + str(value))
 | 
						|
 | 
						|
        # write char metrics
 | 
						|
        lines.append("StartCharMetrics " + repr(len(self._chars)))
 | 
						|
        items = [
 | 
						|
            (charnum, (charname, width, box))
 | 
						|
            for charname, (charnum, width, box) in self._chars.items()
 | 
						|
        ]
 | 
						|
 | 
						|
        def myKey(a):
 | 
						|
            """Custom key function to make sure unencoded chars (-1)
 | 
						|
            end up at the end of the list after sorting."""
 | 
						|
            if a[0] == -1:
 | 
						|
                a = (0xFFFF,) + a[1:]  # 0xffff is an arbitrary large number
 | 
						|
            return a
 | 
						|
 | 
						|
        items.sort(key=myKey)
 | 
						|
 | 
						|
        for charnum, (charname, width, (l, b, r, t)) in items:
 | 
						|
            lines.append(
 | 
						|
                "C %d ; WX %d ; N %s ; B %d %d %d %d ;"
 | 
						|
                % (charnum, width, charname, l, b, r, t)
 | 
						|
            )
 | 
						|
        lines.append("EndCharMetrics")
 | 
						|
 | 
						|
        # write kerning info
 | 
						|
        lines.append("StartKernData")
 | 
						|
        lines.append("StartKernPairs " + repr(len(self._kerning)))
 | 
						|
        items = sorted(self._kerning.items())
 | 
						|
        for (leftchar, rightchar), value in items:
 | 
						|
            lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
 | 
						|
        lines.append("EndKernPairs")
 | 
						|
        lines.append("EndKernData")
 | 
						|
 | 
						|
        if self._composites:
 | 
						|
            composites = sorted(self._composites.items())
 | 
						|
            lines.append("StartComposites %s" % len(self._composites))
 | 
						|
            for charname, components in composites:
 | 
						|
                line = "CC %s %s ;" % (charname, len(components))
 | 
						|
                for basechar, xoffset, yoffset in components:
 | 
						|
                    line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
 | 
						|
                lines.append(line)
 | 
						|
            lines.append("EndComposites")
 | 
						|
 | 
						|
        lines.append("EndFontMetrics")
 | 
						|
 | 
						|
        writelines(path, lines, sep)
 | 
						|
 | 
						|
    def has_kernpair(self, pair):
 | 
						|
        """Returns `True` if the given glyph pair (specified as a tuple) exists
 | 
						|
        in the kerning dictionary."""
 | 
						|
        return pair in self._kerning
 | 
						|
 | 
						|
    def kernpairs(self):
 | 
						|
        """Returns a list of all kern pairs in the kerning dictionary."""
 | 
						|
        return list(self._kerning.keys())
 | 
						|
 | 
						|
    def has_char(self, char):
 | 
						|
        """Returns `True` if the given glyph exists in the font."""
 | 
						|
        return char in self._chars
 | 
						|
 | 
						|
    def chars(self):
 | 
						|
        """Returns a list of all glyph names in the font."""
 | 
						|
        return list(self._chars.keys())
 | 
						|
 | 
						|
    def comments(self):
 | 
						|
        """Returns all comments from the file."""
 | 
						|
        return self._comments
 | 
						|
 | 
						|
    def addComment(self, comment):
 | 
						|
        """Adds a new comment to the file."""
 | 
						|
        self._comments.append(comment)
 | 
						|
 | 
						|
    def addComposite(self, glyphName, components):
 | 
						|
        """Specifies that the glyph `glyphName` is made up of the given components.
 | 
						|
        The components list should be of the following form::
 | 
						|
 | 
						|
                [
 | 
						|
                        (glyphname, xOffset, yOffset),
 | 
						|
                        ...
 | 
						|
                ]
 | 
						|
 | 
						|
        """
 | 
						|
        self._composites[glyphName] = components
 | 
						|
 | 
						|
    def __getattr__(self, attr):
 | 
						|
        if attr in self._attrs:
 | 
						|
            return self._attrs[attr]
 | 
						|
        else:
 | 
						|
            raise AttributeError(attr)
 | 
						|
 | 
						|
    def __setattr__(self, attr, value):
 | 
						|
        # all attrs *not* starting with "_" are consider to be AFM keywords
 | 
						|
        if attr[:1] == "_":
 | 
						|
            self.__dict__[attr] = value
 | 
						|
        else:
 | 
						|
            self._attrs[attr] = value
 | 
						|
 | 
						|
    def __delattr__(self, attr):
 | 
						|
        # all attrs *not* starting with "_" are consider to be AFM keywords
 | 
						|
        if attr[:1] == "_":
 | 
						|
            try:
 | 
						|
                del self.__dict__[attr]
 | 
						|
            except KeyError:
 | 
						|
                raise AttributeError(attr)
 | 
						|
        else:
 | 
						|
            try:
 | 
						|
                del self._attrs[attr]
 | 
						|
            except KeyError:
 | 
						|
                raise AttributeError(attr)
 | 
						|
 | 
						|
    def __getitem__(self, key):
 | 
						|
        if isinstance(key, tuple):
 | 
						|
            # key is a tuple, return the kernpair
 | 
						|
            return self._kerning[key]
 | 
						|
        else:
 | 
						|
            # return the metrics instead
 | 
						|
            return self._chars[key]
 | 
						|
 | 
						|
    def __setitem__(self, key, value):
 | 
						|
        if isinstance(key, tuple):
 | 
						|
            # key is a tuple, set kernpair
 | 
						|
            self._kerning[key] = value
 | 
						|
        else:
 | 
						|
            # set char metrics
 | 
						|
            self._chars[key] = value
 | 
						|
 | 
						|
    def __delitem__(self, key):
 | 
						|
        if isinstance(key, tuple):
 | 
						|
            # key is a tuple, del kernpair
 | 
						|
            del self._kerning[key]
 | 
						|
        else:
 | 
						|
            # del char metrics
 | 
						|
            del self._chars[key]
 | 
						|
 | 
						|
    def __repr__(self):
 | 
						|
        if hasattr(self, "FullName"):
 | 
						|
            return "<AFM object for %s>" % self.FullName
 | 
						|
        else:
 | 
						|
            return "<AFM object at %x>" % id(self)
 | 
						|
 | 
						|
 | 
						|
def readlines(path):
 | 
						|
    with open(path, "r", encoding="ascii") as f:
 | 
						|
        data = f.read()
 | 
						|
    return data.splitlines()
 | 
						|
 | 
						|
 | 
						|
def writelines(path, lines, sep="\r"):
 | 
						|
    with open(path, "w", encoding="ascii", newline=sep) as f:
 | 
						|
        f.write("\n".join(lines) + "\n")
 | 
						|
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    import EasyDialogs
 | 
						|
 | 
						|
    path = EasyDialogs.AskFileForOpen()
 | 
						|
    if path:
 | 
						|
        afm = AFM(path)
 | 
						|
        char = "A"
 | 
						|
        if afm.has_char(char):
 | 
						|
            print(afm[char])  # print charnum, width and boundingbox
 | 
						|
        pair = ("A", "V")
 | 
						|
        if afm.has_kernpair(pair):
 | 
						|
            print(afm[pair])  # print kerning value for pair
 | 
						|
        print(afm.Version)  # various other afm entries have become attributes
 | 
						|
        print(afm.Weight)
 | 
						|
        # afm.comments() returns a list of all Comment lines found in the AFM
 | 
						|
        print(afm.comments())
 | 
						|
        # print afm.chars()
 | 
						|
        # print afm.kernpairs()
 | 
						|
        print(afm)
 | 
						|
        afm.write(path + ".muck")
 |