xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ufoLib/glifLib.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""
2glifLib.py -- Generic module for reading and writing the .glif format.
3
4More info about the .glif format (GLyphInterchangeFormat) can be found here:
5
6	http://unifiedfontobject.org
7
8The main class in this module is GlyphSet. It manages a set of .glif files
9in a folder. It offers two ways to read glyph data, and one way to write
10glyph data. See the class doc string for details.
11"""
12
13from __future__ import annotations
14
15import logging
16import enum
17from warnings import warn
18from collections import OrderedDict
19import fs
20import fs.base
21import fs.errors
22import fs.osfs
23import fs.path
24from fontTools.misc.textTools import tobytes
25from fontTools.misc import plistlib
26from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen
27from fontTools.ufoLib.errors import GlifLibError
28from fontTools.ufoLib.filenames import userNameToFileName
29from fontTools.ufoLib.validators import (
30    genericTypeValidator,
31    colorValidator,
32    guidelinesValidator,
33    anchorsValidator,
34    identifierValidator,
35    imageValidator,
36    glyphLibValidator,
37)
38from fontTools.misc import etree
39from fontTools.ufoLib import _UFOBaseIO, UFOFormatVersion
40from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin
41
42
43__all__ = [
44    "GlyphSet",
45    "GlifLibError",
46    "readGlyphFromString",
47    "writeGlyphToString",
48    "glyphNameToFileName",
49]
50
51logger = logging.getLogger(__name__)
52
53
54# ---------
55# Constants
56# ---------
57
58CONTENTS_FILENAME = "contents.plist"
59LAYERINFO_FILENAME = "layerinfo.plist"
60
61
62class GLIFFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum):
63    FORMAT_1_0 = (1, 0)
64    FORMAT_2_0 = (2, 0)
65
66    @classmethod
67    def default(cls, ufoFormatVersion=None):
68        if ufoFormatVersion is not None:
69            return max(cls.supported_versions(ufoFormatVersion))
70        return super().default()
71
72    @classmethod
73    def supported_versions(cls, ufoFormatVersion=None):
74        if ufoFormatVersion is None:
75            # if ufo format unspecified, return all the supported GLIF formats
76            return super().supported_versions()
77        # else only return the GLIF formats supported by the given UFO format
78        versions = {cls.FORMAT_1_0}
79        if ufoFormatVersion >= UFOFormatVersion.FORMAT_3_0:
80            versions.add(cls.FORMAT_2_0)
81        return frozenset(versions)
82
83
84# workaround for py3.11, see https://github.com/fonttools/fonttools/pull/2655
85GLIFFormatVersion.__str__ = _VersionTupleEnumMixin.__str__
86
87
88# ------------
89# Simple Glyph
90# ------------
91
92
93class Glyph:
94    """
95    Minimal glyph object. It has no glyph attributes until either
96    the draw() or the drawPoints() method has been called.
97    """
98
99    def __init__(self, glyphName, glyphSet):
100        self.glyphName = glyphName
101        self.glyphSet = glyphSet
102
103    def draw(self, pen, outputImpliedClosingLine=False):
104        """
105        Draw this glyph onto a *FontTools* Pen.
106        """
107        pointPen = PointToSegmentPen(
108            pen, outputImpliedClosingLine=outputImpliedClosingLine
109        )
110        self.drawPoints(pointPen)
111
112    def drawPoints(self, pointPen):
113        """
114        Draw this glyph onto a PointPen.
115        """
116        self.glyphSet.readGlyph(self.glyphName, self, pointPen)
117
118
119# ---------
120# Glyph Set
121# ---------
122
123
124class GlyphSet(_UFOBaseIO):
125    """
126    GlyphSet manages a set of .glif files inside one directory.
127
128    GlyphSet's constructor takes a path to an existing directory as it's
129    first argument. Reading glyph data can either be done through the
130    readGlyph() method, or by using GlyphSet's dictionary interface, where
131    the keys are glyph names and the values are (very) simple glyph objects.
132
133    To write a glyph to the glyph set, you use the writeGlyph() method.
134    The simple glyph objects returned through the dict interface do not
135    support writing, they are just a convenient way to get at the glyph data.
136    """
137
138    glyphClass = Glyph
139
140    def __init__(
141        self,
142        path,
143        glyphNameToFileNameFunc=None,
144        ufoFormatVersion=None,
145        validateRead=True,
146        validateWrite=True,
147        expectContentsFile=False,
148    ):
149        """
150        'path' should be a path (string) to an existing local directory, or
151        an instance of fs.base.FS class.
152
153        The optional 'glyphNameToFileNameFunc' argument must be a callback
154        function that takes two arguments: a glyph name and a list of all
155        existing filenames (if any exist). It should return a file name
156        (including the .glif extension). The glyphNameToFileName function
157        is called whenever a file name is created for a given glyph name.
158
159        ``validateRead`` will validate read operations. Its default is ``True``.
160        ``validateWrite`` will validate write operations. Its default is ``True``.
161        ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
162        not found on the glyph set file system. This should be set to ``True`` if you
163        are reading an existing UFO and ``False`` if you create a fresh	glyph set.
164        """
165        try:
166            ufoFormatVersion = UFOFormatVersion(ufoFormatVersion)
167        except ValueError as e:
168            from fontTools.ufoLib.errors import UnsupportedUFOFormat
169
170            raise UnsupportedUFOFormat(
171                f"Unsupported UFO format: {ufoFormatVersion!r}"
172            ) from e
173
174        if hasattr(path, "__fspath__"):  # support os.PathLike objects
175            path = path.__fspath__()
176
177        if isinstance(path, str):
178            try:
179                filesystem = fs.osfs.OSFS(path)
180            except fs.errors.CreateFailed:
181                raise GlifLibError("No glyphs directory '%s'" % path)
182            self._shouldClose = True
183        elif isinstance(path, fs.base.FS):
184            filesystem = path
185            try:
186                filesystem.check()
187            except fs.errors.FilesystemClosed:
188                raise GlifLibError("the filesystem '%s' is closed" % filesystem)
189            self._shouldClose = False
190        else:
191            raise TypeError(
192                "Expected a path string or fs object, found %s" % type(path).__name__
193            )
194        try:
195            path = filesystem.getsyspath("/")
196        except fs.errors.NoSysPath:
197            # network or in-memory FS may not map to the local one
198            path = str(filesystem)
199        # 'dirName' is kept for backward compatibility only, but it's DEPRECATED
200        # as it's not guaranteed that it maps to an existing OSFS directory.
201        # Client could use the FS api via the `self.fs` attribute instead.
202        self.dirName = fs.path.parts(path)[-1]
203        self.fs = filesystem
204        # if glyphSet contains no 'contents.plist', we consider it empty
205        self._havePreviousFile = filesystem.exists(CONTENTS_FILENAME)
206        if expectContentsFile and not self._havePreviousFile:
207            raise GlifLibError(f"{CONTENTS_FILENAME} is missing.")
208        # attribute kept for backward compatibility
209        self.ufoFormatVersion = ufoFormatVersion.major
210        self.ufoFormatVersionTuple = ufoFormatVersion
211        if glyphNameToFileNameFunc is None:
212            glyphNameToFileNameFunc = glyphNameToFileName
213        self.glyphNameToFileName = glyphNameToFileNameFunc
214        self._validateRead = validateRead
215        self._validateWrite = validateWrite
216        self._existingFileNames: set[str] | None = None
217        self._reverseContents = None
218
219        self.rebuildContents()
220
221    def rebuildContents(self, validateRead=None):
222        """
223        Rebuild the contents dict by loading contents.plist.
224
225        ``validateRead`` will validate the data, by default it is set to the
226        class's ``validateRead`` value, can be overridden.
227        """
228        if validateRead is None:
229            validateRead = self._validateRead
230        contents = self._getPlist(CONTENTS_FILENAME, {})
231        # validate the contents
232        if validateRead:
233            invalidFormat = False
234            if not isinstance(contents, dict):
235                invalidFormat = True
236            else:
237                for name, fileName in contents.items():
238                    if not isinstance(name, str):
239                        invalidFormat = True
240                    if not isinstance(fileName, str):
241                        invalidFormat = True
242                    elif not self.fs.exists(fileName):
243                        raise GlifLibError(
244                            "%s references a file that does not exist: %s"
245                            % (CONTENTS_FILENAME, fileName)
246                        )
247            if invalidFormat:
248                raise GlifLibError("%s is not properly formatted" % CONTENTS_FILENAME)
249        self.contents = contents
250        self._existingFileNames = None
251        self._reverseContents = None
252
253    def getReverseContents(self):
254        """
255        Return a reversed dict of self.contents, mapping file names to
256        glyph names. This is primarily an aid for custom glyph name to file
257        name schemes that want to make sure they don't generate duplicate
258        file names. The file names are converted to lowercase so we can
259        reliably check for duplicates that only differ in case, which is
260        important for case-insensitive file systems.
261        """
262        if self._reverseContents is None:
263            d = {}
264            for k, v in self.contents.items():
265                d[v.lower()] = k
266            self._reverseContents = d
267        return self._reverseContents
268
269    def writeContents(self):
270        """
271        Write the contents.plist file out to disk. Call this method when
272        you're done writing glyphs.
273        """
274        self._writePlist(CONTENTS_FILENAME, self.contents)
275
276    # layer info
277
278    def readLayerInfo(self, info, validateRead=None):
279        """
280        ``validateRead`` will validate the data, by default it is set to the
281        class's ``validateRead`` value, can be overridden.
282        """
283        if validateRead is None:
284            validateRead = self._validateRead
285        infoDict = self._getPlist(LAYERINFO_FILENAME, {})
286        if validateRead:
287            if not isinstance(infoDict, dict):
288                raise GlifLibError("layerinfo.plist is not properly formatted.")
289            infoDict = validateLayerInfoVersion3Data(infoDict)
290        # populate the object
291        for attr, value in infoDict.items():
292            try:
293                setattr(info, attr, value)
294            except AttributeError:
295                raise GlifLibError(
296                    "The supplied layer info object does not support setting a necessary attribute (%s)."
297                    % attr
298                )
299
300    def writeLayerInfo(self, info, validateWrite=None):
301        """
302        ``validateWrite`` will validate the data, by default it is set to the
303        class's ``validateWrite`` value, can be overridden.
304        """
305        if validateWrite is None:
306            validateWrite = self._validateWrite
307        if self.ufoFormatVersionTuple.major < 3:
308            raise GlifLibError(
309                "layerinfo.plist is not allowed in UFO %d."
310                % self.ufoFormatVersionTuple.major
311            )
312        # gather data
313        infoData = {}
314        for attr in layerInfoVersion3ValueData.keys():
315            if hasattr(info, attr):
316                try:
317                    value = getattr(info, attr)
318                except AttributeError:
319                    raise GlifLibError(
320                        "The supplied info object does not support getting a necessary attribute (%s)."
321                        % attr
322                    )
323                if value is None or (attr == "lib" and not value):
324                    continue
325                infoData[attr] = value
326        if infoData:
327            # validate
328            if validateWrite:
329                infoData = validateLayerInfoVersion3Data(infoData)
330            # write file
331            self._writePlist(LAYERINFO_FILENAME, infoData)
332        elif self._havePreviousFile and self.fs.exists(LAYERINFO_FILENAME):
333            # data empty, remove existing file
334            self.fs.remove(LAYERINFO_FILENAME)
335
336    def getGLIF(self, glyphName):
337        """
338        Get the raw GLIF text for a given glyph name. This only works
339        for GLIF files that are already on disk.
340
341        This method is useful in situations when the raw XML needs to be
342        read from a glyph set for a particular glyph before fully parsing
343        it into an object structure via the readGlyph method.
344
345        Raises KeyError if 'glyphName' is not in contents.plist, or
346        GlifLibError if the file associated with can't be found.
347        """
348        fileName = self.contents[glyphName]
349        try:
350            return self.fs.readbytes(fileName)
351        except fs.errors.ResourceNotFound:
352            raise GlifLibError(
353                "The file '%s' associated with glyph '%s' in contents.plist "
354                "does not exist on %s" % (fileName, glyphName, self.fs)
355            )
356
357    def getGLIFModificationTime(self, glyphName):
358        """
359        Returns the modification time for the GLIF file with 'glyphName', as
360        a floating point number giving the number of seconds since the epoch.
361        Return None if the associated file does not exist or the underlying
362        filesystem does not support getting modified times.
363        Raises KeyError if the glyphName is not in contents.plist.
364        """
365        fileName = self.contents[glyphName]
366        return self.getFileModificationTime(fileName)
367
368    # reading/writing API
369
370    def readGlyph(self, glyphName, glyphObject=None, pointPen=None, validate=None):
371        """
372        Read a .glif file for 'glyphName' from the glyph set. The
373        'glyphObject' argument can be any kind of object (even None);
374        the readGlyph() method will attempt to set the following
375        attributes on it:
376
377        width
378                the advance width of the glyph
379        height
380                the advance height of the glyph
381        unicodes
382                a list of unicode values for this glyph
383        note
384                a string
385        lib
386                a dictionary containing custom data
387        image
388                a dictionary containing image data
389        guidelines
390                a list of guideline data dictionaries
391        anchors
392                a list of anchor data dictionaries
393
394        All attributes are optional, in two ways:
395
396        1) An attribute *won't* be set if the .glif file doesn't
397           contain data for it. 'glyphObject' will have to deal
398           with default values itself.
399        2) If setting the attribute fails with an AttributeError
400           (for example if the 'glyphObject' attribute is read-
401           only), readGlyph() will not propagate that exception,
402           but ignore that attribute.
403
404        To retrieve outline information, you need to pass an object
405        conforming to the PointPen protocol as the 'pointPen' argument.
406        This argument may be None if you don't need the outline data.
407
408        readGlyph() will raise KeyError if the glyph is not present in
409        the glyph set.
410
411        ``validate`` will validate the data, by default it is set to the
412        class's ``validateRead`` value, can be overridden.
413        """
414        if validate is None:
415            validate = self._validateRead
416        text = self.getGLIF(glyphName)
417        try:
418            tree = _glifTreeFromString(text)
419            formatVersions = GLIFFormatVersion.supported_versions(
420                self.ufoFormatVersionTuple
421            )
422            _readGlyphFromTree(
423                tree,
424                glyphObject,
425                pointPen,
426                formatVersions=formatVersions,
427                validate=validate,
428            )
429        except GlifLibError as glifLibError:
430            # Re-raise with a note that gives extra context, describing where
431            # the error occurred.
432            fileName = self.contents[glyphName]
433            try:
434                glifLocation = f"'{self.fs.getsyspath(fileName)}'"
435            except fs.errors.NoSysPath:
436                # Network or in-memory FS may not map to a local path, so use
437                # the best string representation we have.
438                glifLocation = f"'{fileName}' from '{str(self.fs)}'"
439
440            glifLibError._add_note(
441                f"The issue is in glyph '{glyphName}', located in {glifLocation}."
442            )
443            raise
444
445    def writeGlyph(
446        self,
447        glyphName,
448        glyphObject=None,
449        drawPointsFunc=None,
450        formatVersion=None,
451        validate=None,
452    ):
453        """
454        Write a .glif file for 'glyphName' to the glyph set. The
455        'glyphObject' argument can be any kind of object (even None);
456        the writeGlyph() method will attempt to get the following
457        attributes from it:
458
459        width
460                the advance width of the glyph
461        height
462                the advance height of the glyph
463        unicodes
464                a list of unicode values for this glyph
465        note
466                a string
467        lib
468                a dictionary containing custom data
469        image
470                a dictionary containing image data
471        guidelines
472                a list of guideline data dictionaries
473        anchors
474                a list of anchor data dictionaries
475
476        All attributes are optional: if 'glyphObject' doesn't
477        have the attribute, it will simply be skipped.
478
479        To write outline data to the .glif file, writeGlyph() needs
480        a function (any callable object actually) that will take one
481        argument: an object that conforms to the PointPen protocol.
482        The function will be called by writeGlyph(); it has to call the
483        proper PointPen methods to transfer the outline to the .glif file.
484
485        The GLIF format version will be chosen based on the ufoFormatVersion
486        passed during the creation of this object. If a particular format
487        version is desired, it can be passed with the formatVersion argument.
488        The formatVersion argument accepts either a tuple of integers for
489        (major, minor), or a single integer for the major digit only (with
490        minor digit implied as 0).
491
492        An UnsupportedGLIFFormat exception is raised if the requested GLIF
493        formatVersion is not supported.
494
495        ``validate`` will validate the data, by default it is set to the
496        class's ``validateWrite`` value, can be overridden.
497        """
498        if formatVersion is None:
499            formatVersion = GLIFFormatVersion.default(self.ufoFormatVersionTuple)
500        else:
501            try:
502                formatVersion = GLIFFormatVersion(formatVersion)
503            except ValueError as e:
504                from fontTools.ufoLib.errors import UnsupportedGLIFFormat
505
506                raise UnsupportedGLIFFormat(
507                    f"Unsupported GLIF format version: {formatVersion!r}"
508                ) from e
509        if formatVersion not in GLIFFormatVersion.supported_versions(
510            self.ufoFormatVersionTuple
511        ):
512            from fontTools.ufoLib.errors import UnsupportedGLIFFormat
513
514            raise UnsupportedGLIFFormat(
515                f"Unsupported GLIF format version ({formatVersion!s}) "
516                f"for UFO format version {self.ufoFormatVersionTuple!s}."
517            )
518        if validate is None:
519            validate = self._validateWrite
520        fileName = self.contents.get(glyphName)
521        if fileName is None:
522            if self._existingFileNames is None:
523                self._existingFileNames = {
524                    fileName.lower() for fileName in self.contents.values()
525                }
526            fileName = self.glyphNameToFileName(glyphName, self._existingFileNames)
527            self.contents[glyphName] = fileName
528            self._existingFileNames.add(fileName.lower())
529            if self._reverseContents is not None:
530                self._reverseContents[fileName.lower()] = glyphName
531        data = _writeGlyphToBytes(
532            glyphName,
533            glyphObject,
534            drawPointsFunc,
535            formatVersion=formatVersion,
536            validate=validate,
537        )
538        if (
539            self._havePreviousFile
540            and self.fs.exists(fileName)
541            and data == self.fs.readbytes(fileName)
542        ):
543            return
544        self.fs.writebytes(fileName, data)
545
546    def deleteGlyph(self, glyphName):
547        """Permanently delete the glyph from the glyph set on disk. Will
548        raise KeyError if the glyph is not present in the glyph set.
549        """
550        fileName = self.contents[glyphName]
551        self.fs.remove(fileName)
552        if self._existingFileNames is not None:
553            self._existingFileNames.remove(fileName.lower())
554        if self._reverseContents is not None:
555            del self._reverseContents[fileName.lower()]
556        del self.contents[glyphName]
557
558    # dict-like support
559
560    def keys(self):
561        return list(self.contents.keys())
562
563    def has_key(self, glyphName):
564        return glyphName in self.contents
565
566    __contains__ = has_key
567
568    def __len__(self):
569        return len(self.contents)
570
571    def __getitem__(self, glyphName):
572        if glyphName not in self.contents:
573            raise KeyError(glyphName)
574        return self.glyphClass(glyphName, self)
575
576    # quickly fetch unicode values
577
578    def getUnicodes(self, glyphNames=None):
579        """
580        Return a dictionary that maps glyph names to lists containing
581        the unicode value[s] for that glyph, if any. This parses the .glif
582        files partially, so it is a lot faster than parsing all files completely.
583        By default this checks all glyphs, but a subset can be passed with glyphNames.
584        """
585        unicodes = {}
586        if glyphNames is None:
587            glyphNames = self.contents.keys()
588        for glyphName in glyphNames:
589            text = self.getGLIF(glyphName)
590            unicodes[glyphName] = _fetchUnicodes(text)
591        return unicodes
592
593    def getComponentReferences(self, glyphNames=None):
594        """
595        Return a dictionary that maps glyph names to lists containing the
596        base glyph name of components in the glyph. This parses the .glif
597        files partially, so it is a lot faster than parsing all files completely.
598        By default this checks all glyphs, but a subset can be passed with glyphNames.
599        """
600        components = {}
601        if glyphNames is None:
602            glyphNames = self.contents.keys()
603        for glyphName in glyphNames:
604            text = self.getGLIF(glyphName)
605            components[glyphName] = _fetchComponentBases(text)
606        return components
607
608    def getImageReferences(self, glyphNames=None):
609        """
610        Return a dictionary that maps glyph names to the file name of the image
611        referenced by the glyph. This parses the .glif files partially, so it is a
612        lot faster than parsing all files completely.
613        By default this checks all glyphs, but a subset can be passed with glyphNames.
614        """
615        images = {}
616        if glyphNames is None:
617            glyphNames = self.contents.keys()
618        for glyphName in glyphNames:
619            text = self.getGLIF(glyphName)
620            images[glyphName] = _fetchImageFileName(text)
621        return images
622
623    def close(self):
624        if self._shouldClose:
625            self.fs.close()
626
627    def __enter__(self):
628        return self
629
630    def __exit__(self, exc_type, exc_value, exc_tb):
631        self.close()
632
633
634# -----------------------
635# Glyph Name to File Name
636# -----------------------
637
638
639def glyphNameToFileName(glyphName, existingFileNames):
640    """
641    Wrapper around the userNameToFileName function in filenames.py
642
643    Note that existingFileNames should be a set for large glyphsets
644    or performance will suffer.
645    """
646    if existingFileNames is None:
647        existingFileNames = set()
648    return userNameToFileName(glyphName, existing=existingFileNames, suffix=".glif")
649
650
651# -----------------------
652# GLIF To and From String
653# -----------------------
654
655
656def readGlyphFromString(
657    aString,
658    glyphObject=None,
659    pointPen=None,
660    formatVersions=None,
661    validate=True,
662):
663    """
664    Read .glif data from a string into a glyph object.
665
666    The 'glyphObject' argument can be any kind of object (even None);
667    the readGlyphFromString() method will attempt to set the following
668    attributes on it:
669
670    width
671            the advance width of the glyph
672    height
673            the advance height of the glyph
674    unicodes
675            a list of unicode values for this glyph
676    note
677            a string
678    lib
679            a dictionary containing custom data
680    image
681            a dictionary containing image data
682    guidelines
683            a list of guideline data dictionaries
684    anchors
685            a list of anchor data dictionaries
686
687    All attributes are optional, in two ways:
688
689    1) An attribute *won't* be set if the .glif file doesn't
690       contain data for it. 'glyphObject' will have to deal
691       with default values itself.
692    2) If setting the attribute fails with an AttributeError
693       (for example if the 'glyphObject' attribute is read-
694       only), readGlyphFromString() will not propagate that
695       exception, but ignore that attribute.
696
697    To retrieve outline information, you need to pass an object
698    conforming to the PointPen protocol as the 'pointPen' argument.
699    This argument may be None if you don't need the outline data.
700
701    The formatVersions optional argument define the GLIF format versions
702    that are allowed to be read.
703    The type is Optional[Iterable[Tuple[int, int], int]]. It can contain
704    either integers (for the major versions to be allowed, with minor
705    digits defaulting to 0), or tuples of integers to specify both
706    (major, minor) versions.
707    By default when formatVersions is None all the GLIF format versions
708    currently defined are allowed to be read.
709
710    ``validate`` will validate the read data. It is set to ``True`` by default.
711    """
712    tree = _glifTreeFromString(aString)
713
714    if formatVersions is None:
715        validFormatVersions = GLIFFormatVersion.supported_versions()
716    else:
717        validFormatVersions, invalidFormatVersions = set(), set()
718        for v in formatVersions:
719            try:
720                formatVersion = GLIFFormatVersion(v)
721            except ValueError:
722                invalidFormatVersions.add(v)
723            else:
724                validFormatVersions.add(formatVersion)
725        if not validFormatVersions:
726            raise ValueError(
727                "None of the requested GLIF formatVersions are supported: "
728                f"{formatVersions!r}"
729            )
730
731    _readGlyphFromTree(
732        tree,
733        glyphObject,
734        pointPen,
735        formatVersions=validFormatVersions,
736        validate=validate,
737    )
738
739
740def _writeGlyphToBytes(
741    glyphName,
742    glyphObject=None,
743    drawPointsFunc=None,
744    writer=None,
745    formatVersion=None,
746    validate=True,
747):
748    """Return .glif data for a glyph as a UTF-8 encoded bytes string."""
749    try:
750        formatVersion = GLIFFormatVersion(formatVersion)
751    except ValueError:
752        from fontTools.ufoLib.errors import UnsupportedGLIFFormat
753
754        raise UnsupportedGLIFFormat(
755            "Unsupported GLIF format version: {formatVersion!r}"
756        )
757    # start
758    if validate and not isinstance(glyphName, str):
759        raise GlifLibError("The glyph name is not properly formatted.")
760    if validate and len(glyphName) == 0:
761        raise GlifLibError("The glyph name is empty.")
762    glyphAttrs = OrderedDict(
763        [("name", glyphName), ("format", repr(formatVersion.major))]
764    )
765    if formatVersion.minor != 0:
766        glyphAttrs["formatMinor"] = repr(formatVersion.minor)
767    root = etree.Element("glyph", glyphAttrs)
768    identifiers = set()
769    # advance
770    _writeAdvance(glyphObject, root, validate)
771    # unicodes
772    if getattr(glyphObject, "unicodes", None):
773        _writeUnicodes(glyphObject, root, validate)
774    # note
775    if getattr(glyphObject, "note", None):
776        _writeNote(glyphObject, root, validate)
777    # image
778    if formatVersion.major >= 2 and getattr(glyphObject, "image", None):
779        _writeImage(glyphObject, root, validate)
780    # guidelines
781    if formatVersion.major >= 2 and getattr(glyphObject, "guidelines", None):
782        _writeGuidelines(glyphObject, root, identifiers, validate)
783    # anchors
784    anchors = getattr(glyphObject, "anchors", None)
785    if formatVersion.major >= 2 and anchors:
786        _writeAnchors(glyphObject, root, identifiers, validate)
787    # outline
788    if drawPointsFunc is not None:
789        outline = etree.SubElement(root, "outline")
790        pen = GLIFPointPen(outline, identifiers=identifiers, validate=validate)
791        drawPointsFunc(pen)
792        if formatVersion.major == 1 and anchors:
793            _writeAnchorsFormat1(pen, anchors, validate)
794        # prevent lxml from writing self-closing tags
795        if not len(outline):
796            outline.text = "\n  "
797    # lib
798    if getattr(glyphObject, "lib", None):
799        _writeLib(glyphObject, root, validate)
800    # return the text
801    data = etree.tostring(
802        root, encoding="UTF-8", xml_declaration=True, pretty_print=True
803    )
804    return data
805
806
807def writeGlyphToString(
808    glyphName,
809    glyphObject=None,
810    drawPointsFunc=None,
811    formatVersion=None,
812    validate=True,
813):
814    """
815    Return .glif data for a glyph as a string. The XML declaration's
816    encoding is always set to "UTF-8".
817    The 'glyphObject' argument can be any kind of object (even None);
818    the writeGlyphToString() method will attempt to get the following
819    attributes from it:
820
821    width
822            the advance width of the glyph
823    height
824            the advance height of the glyph
825    unicodes
826            a list of unicode values for this glyph
827    note
828            a string
829    lib
830            a dictionary containing custom data
831    image
832            a dictionary containing image data
833    guidelines
834            a list of guideline data dictionaries
835    anchors
836            a list of anchor data dictionaries
837
838    All attributes are optional: if 'glyphObject' doesn't
839    have the attribute, it will simply be skipped.
840
841    To write outline data to the .glif file, writeGlyphToString() needs
842    a function (any callable object actually) that will take one
843    argument: an object that conforms to the PointPen protocol.
844    The function will be called by writeGlyphToString(); it has to call the
845    proper PointPen methods to transfer the outline to the .glif file.
846
847    The GLIF format version can be specified with the formatVersion argument.
848    This accepts either a tuple of integers for (major, minor), or a single
849    integer for the major digit only (with minor digit implied as 0).
850    By default when formatVesion is None the latest GLIF format version will
851    be used; currently it's 2.0, which is equivalent to formatVersion=(2, 0).
852
853    An UnsupportedGLIFFormat exception is raised if the requested UFO
854    formatVersion is not supported.
855
856    ``validate`` will validate the written data. It is set to ``True`` by default.
857    """
858    data = _writeGlyphToBytes(
859        glyphName,
860        glyphObject=glyphObject,
861        drawPointsFunc=drawPointsFunc,
862        formatVersion=formatVersion,
863        validate=validate,
864    )
865    return data.decode("utf-8")
866
867
868def _writeAdvance(glyphObject, element, validate):
869    width = getattr(glyphObject, "width", None)
870    if width is not None:
871        if validate and not isinstance(width, numberTypes):
872            raise GlifLibError("width attribute must be int or float")
873        if width == 0:
874            width = None
875    height = getattr(glyphObject, "height", None)
876    if height is not None:
877        if validate and not isinstance(height, numberTypes):
878            raise GlifLibError("height attribute must be int or float")
879        if height == 0:
880            height = None
881    if width is not None and height is not None:
882        etree.SubElement(
883            element,
884            "advance",
885            OrderedDict([("height", repr(height)), ("width", repr(width))]),
886        )
887    elif width is not None:
888        etree.SubElement(element, "advance", dict(width=repr(width)))
889    elif height is not None:
890        etree.SubElement(element, "advance", dict(height=repr(height)))
891
892
893def _writeUnicodes(glyphObject, element, validate):
894    unicodes = getattr(glyphObject, "unicodes", None)
895    if validate and isinstance(unicodes, int):
896        unicodes = [unicodes]
897    seen = set()
898    for code in unicodes:
899        if validate and not isinstance(code, int):
900            raise GlifLibError("unicode values must be int")
901        if code in seen:
902            continue
903        seen.add(code)
904        hexCode = "%04X" % code
905        etree.SubElement(element, "unicode", dict(hex=hexCode))
906
907
908def _writeNote(glyphObject, element, validate):
909    note = getattr(glyphObject, "note", None)
910    if validate and not isinstance(note, str):
911        raise GlifLibError("note attribute must be str")
912    note = note.strip()
913    note = "\n" + note + "\n"
914    etree.SubElement(element, "note").text = note
915
916
917def _writeImage(glyphObject, element, validate):
918    image = getattr(glyphObject, "image", None)
919    if validate and not imageValidator(image):
920        raise GlifLibError(
921            "image attribute must be a dict or dict-like object with the proper structure."
922        )
923    attrs = OrderedDict([("fileName", image["fileName"])])
924    for attr, default in _transformationInfo:
925        value = image.get(attr, default)
926        if value != default:
927            attrs[attr] = repr(value)
928    color = image.get("color")
929    if color is not None:
930        attrs["color"] = color
931    etree.SubElement(element, "image", attrs)
932
933
934def _writeGuidelines(glyphObject, element, identifiers, validate):
935    guidelines = getattr(glyphObject, "guidelines", [])
936    if validate and not guidelinesValidator(guidelines):
937        raise GlifLibError("guidelines attribute does not have the proper structure.")
938    for guideline in guidelines:
939        attrs = OrderedDict()
940        x = guideline.get("x")
941        if x is not None:
942            attrs["x"] = repr(x)
943        y = guideline.get("y")
944        if y is not None:
945            attrs["y"] = repr(y)
946        angle = guideline.get("angle")
947        if angle is not None:
948            attrs["angle"] = repr(angle)
949        name = guideline.get("name")
950        if name is not None:
951            attrs["name"] = name
952        color = guideline.get("color")
953        if color is not None:
954            attrs["color"] = color
955        identifier = guideline.get("identifier")
956        if identifier is not None:
957            if validate and identifier in identifiers:
958                raise GlifLibError("identifier used more than once: %s" % identifier)
959            attrs["identifier"] = identifier
960            identifiers.add(identifier)
961        etree.SubElement(element, "guideline", attrs)
962
963
964def _writeAnchorsFormat1(pen, anchors, validate):
965    if validate and not anchorsValidator(anchors):
966        raise GlifLibError("anchors attribute does not have the proper structure.")
967    for anchor in anchors:
968        attrs = {}
969        x = anchor["x"]
970        attrs["x"] = repr(x)
971        y = anchor["y"]
972        attrs["y"] = repr(y)
973        name = anchor.get("name")
974        if name is not None:
975            attrs["name"] = name
976        pen.beginPath()
977        pen.addPoint((x, y), segmentType="move", name=name)
978        pen.endPath()
979
980
981def _writeAnchors(glyphObject, element, identifiers, validate):
982    anchors = getattr(glyphObject, "anchors", [])
983    if validate and not anchorsValidator(anchors):
984        raise GlifLibError("anchors attribute does not have the proper structure.")
985    for anchor in anchors:
986        attrs = OrderedDict()
987        x = anchor["x"]
988        attrs["x"] = repr(x)
989        y = anchor["y"]
990        attrs["y"] = repr(y)
991        name = anchor.get("name")
992        if name is not None:
993            attrs["name"] = name
994        color = anchor.get("color")
995        if color is not None:
996            attrs["color"] = color
997        identifier = anchor.get("identifier")
998        if identifier is not None:
999            if validate and identifier in identifiers:
1000                raise GlifLibError("identifier used more than once: %s" % identifier)
1001            attrs["identifier"] = identifier
1002            identifiers.add(identifier)
1003        etree.SubElement(element, "anchor", attrs)
1004
1005
1006def _writeLib(glyphObject, element, validate):
1007    lib = getattr(glyphObject, "lib", None)
1008    if not lib:
1009        # don't write empty lib
1010        return
1011    if validate:
1012        valid, message = glyphLibValidator(lib)
1013        if not valid:
1014            raise GlifLibError(message)
1015    if not isinstance(lib, dict):
1016        lib = dict(lib)
1017    # plist inside GLIF begins with 2 levels of indentation
1018    e = plistlib.totree(lib, indent_level=2)
1019    etree.SubElement(element, "lib").append(e)
1020
1021
1022# -----------------------
1023# layerinfo.plist Support
1024# -----------------------
1025
1026layerInfoVersion3ValueData = {
1027    "color": dict(type=str, valueValidator=colorValidator),
1028    "lib": dict(type=dict, valueValidator=genericTypeValidator),
1029}
1030
1031
1032def validateLayerInfoVersion3ValueForAttribute(attr, value):
1033    """
1034    This performs very basic validation of the value for attribute
1035    following the UFO 3 fontinfo.plist specification. The results
1036    of this should not be interpretted as *correct* for the font
1037    that they are part of. This merely indicates that the value
1038    is of the proper type and, where the specification defines
1039    a set range of possible values for an attribute, that the
1040    value is in the accepted range.
1041    """
1042    if attr not in layerInfoVersion3ValueData:
1043        return False
1044    dataValidationDict = layerInfoVersion3ValueData[attr]
1045    valueType = dataValidationDict.get("type")
1046    validator = dataValidationDict.get("valueValidator")
1047    valueOptions = dataValidationDict.get("valueOptions")
1048    # have specific options for the validator
1049    if valueOptions is not None:
1050        isValidValue = validator(value, valueOptions)
1051    # no specific options
1052    else:
1053        if validator == genericTypeValidator:
1054            isValidValue = validator(value, valueType)
1055        else:
1056            isValidValue = validator(value)
1057    return isValidValue
1058
1059
1060def validateLayerInfoVersion3Data(infoData):
1061    """
1062    This performs very basic validation of the value for infoData
1063    following the UFO 3 layerinfo.plist specification. The results
1064    of this should not be interpretted as *correct* for the font
1065    that they are part of. This merely indicates that the values
1066    are of the proper type and, where the specification defines
1067    a set range of possible values for an attribute, that the
1068    value is in the accepted range.
1069    """
1070    for attr, value in infoData.items():
1071        if attr not in layerInfoVersion3ValueData:
1072            raise GlifLibError("Unknown attribute %s." % attr)
1073        isValidValue = validateLayerInfoVersion3ValueForAttribute(attr, value)
1074        if not isValidValue:
1075            raise GlifLibError(f"Invalid value for attribute {attr} ({value!r}).")
1076    return infoData
1077
1078
1079# -----------------
1080# GLIF Tree Support
1081# -----------------
1082
1083
1084def _glifTreeFromFile(aFile):
1085    if etree._have_lxml:
1086        tree = etree.parse(aFile, parser=etree.XMLParser(remove_comments=True))
1087    else:
1088        tree = etree.parse(aFile)
1089    root = tree.getroot()
1090    if root.tag != "glyph":
1091        raise GlifLibError("The GLIF is not properly formatted.")
1092    if root.text and root.text.strip() != "":
1093        raise GlifLibError("Invalid GLIF structure.")
1094    return root
1095
1096
1097def _glifTreeFromString(aString):
1098    data = tobytes(aString, encoding="utf-8")
1099    try:
1100        if etree._have_lxml:
1101            root = etree.fromstring(data, parser=etree.XMLParser(remove_comments=True))
1102        else:
1103            root = etree.fromstring(data)
1104    except Exception as etree_exception:
1105        raise GlifLibError("GLIF contains invalid XML.") from etree_exception
1106
1107    if root.tag != "glyph":
1108        raise GlifLibError("The GLIF is not properly formatted.")
1109    if root.text and root.text.strip() != "":
1110        raise GlifLibError("Invalid GLIF structure.")
1111    return root
1112
1113
1114def _readGlyphFromTree(
1115    tree,
1116    glyphObject=None,
1117    pointPen=None,
1118    formatVersions=GLIFFormatVersion.supported_versions(),
1119    validate=True,
1120):
1121    # check the format version
1122    formatVersionMajor = tree.get("format")
1123    if validate and formatVersionMajor is None:
1124        raise GlifLibError("Unspecified format version in GLIF.")
1125    formatVersionMinor = tree.get("formatMinor", 0)
1126    try:
1127        formatVersion = GLIFFormatVersion(
1128            (int(formatVersionMajor), int(formatVersionMinor))
1129        )
1130    except ValueError as e:
1131        msg = "Unsupported GLIF format: %s.%s" % (
1132            formatVersionMajor,
1133            formatVersionMinor,
1134        )
1135        if validate:
1136            from fontTools.ufoLib.errors import UnsupportedGLIFFormat
1137
1138            raise UnsupportedGLIFFormat(msg) from e
1139        # warn but continue using the latest supported format
1140        formatVersion = GLIFFormatVersion.default()
1141        logger.warning(
1142            "%s. Assuming the latest supported version (%s). "
1143            "Some data may be skipped or parsed incorrectly.",
1144            msg,
1145            formatVersion,
1146        )
1147
1148    if validate and formatVersion not in formatVersions:
1149        raise GlifLibError(f"Forbidden GLIF format version: {formatVersion!s}")
1150
1151    try:
1152        readGlyphFromTree = _READ_GLYPH_FROM_TREE_FUNCS[formatVersion]
1153    except KeyError:
1154        raise NotImplementedError(formatVersion)
1155
1156    readGlyphFromTree(
1157        tree=tree,
1158        glyphObject=glyphObject,
1159        pointPen=pointPen,
1160        validate=validate,
1161        formatMinor=formatVersion.minor,
1162    )
1163
1164
1165def _readGlyphFromTreeFormat1(
1166    tree, glyphObject=None, pointPen=None, validate=None, **kwargs
1167):
1168    # get the name
1169    _readName(glyphObject, tree, validate)
1170    # populate the sub elements
1171    unicodes = []
1172    haveSeenAdvance = haveSeenOutline = haveSeenLib = haveSeenNote = False
1173    for element in tree:
1174        if element.tag == "outline":
1175            if validate:
1176                if haveSeenOutline:
1177                    raise GlifLibError("The outline element occurs more than once.")
1178                if element.attrib:
1179                    raise GlifLibError(
1180                        "The outline element contains unknown attributes."
1181                    )
1182                if element.text and element.text.strip() != "":
1183                    raise GlifLibError("Invalid outline structure.")
1184            haveSeenOutline = True
1185            buildOutlineFormat1(glyphObject, pointPen, element, validate)
1186        elif glyphObject is None:
1187            continue
1188        elif element.tag == "advance":
1189            if validate and haveSeenAdvance:
1190                raise GlifLibError("The advance element occurs more than once.")
1191            haveSeenAdvance = True
1192            _readAdvance(glyphObject, element)
1193        elif element.tag == "unicode":
1194            try:
1195                v = element.get("hex")
1196                v = int(v, 16)
1197                if v not in unicodes:
1198                    unicodes.append(v)
1199            except ValueError:
1200                raise GlifLibError(
1201                    "Illegal value for hex attribute of unicode element."
1202                )
1203        elif element.tag == "note":
1204            if validate and haveSeenNote:
1205                raise GlifLibError("The note element occurs more than once.")
1206            haveSeenNote = True
1207            _readNote(glyphObject, element)
1208        elif element.tag == "lib":
1209            if validate and haveSeenLib:
1210                raise GlifLibError("The lib element occurs more than once.")
1211            haveSeenLib = True
1212            _readLib(glyphObject, element, validate)
1213        else:
1214            raise GlifLibError("Unknown element in GLIF: %s" % element)
1215    # set the collected unicodes
1216    if unicodes:
1217        _relaxedSetattr(glyphObject, "unicodes", unicodes)
1218
1219
1220def _readGlyphFromTreeFormat2(
1221    tree, glyphObject=None, pointPen=None, validate=None, formatMinor=0
1222):
1223    # get the name
1224    _readName(glyphObject, tree, validate)
1225    # populate the sub elements
1226    unicodes = []
1227    guidelines = []
1228    anchors = []
1229    haveSeenAdvance = haveSeenImage = haveSeenOutline = haveSeenLib = haveSeenNote = (
1230        False
1231    )
1232    identifiers = set()
1233    for element in tree:
1234        if element.tag == "outline":
1235            if validate:
1236                if haveSeenOutline:
1237                    raise GlifLibError("The outline element occurs more than once.")
1238                if element.attrib:
1239                    raise GlifLibError(
1240                        "The outline element contains unknown attributes."
1241                    )
1242                if element.text and element.text.strip() != "":
1243                    raise GlifLibError("Invalid outline structure.")
1244            haveSeenOutline = True
1245            if pointPen is not None:
1246                buildOutlineFormat2(
1247                    glyphObject, pointPen, element, identifiers, validate
1248                )
1249        elif glyphObject is None:
1250            continue
1251        elif element.tag == "advance":
1252            if validate and haveSeenAdvance:
1253                raise GlifLibError("The advance element occurs more than once.")
1254            haveSeenAdvance = True
1255            _readAdvance(glyphObject, element)
1256        elif element.tag == "unicode":
1257            try:
1258                v = element.get("hex")
1259                v = int(v, 16)
1260                if v not in unicodes:
1261                    unicodes.append(v)
1262            except ValueError:
1263                raise GlifLibError(
1264                    "Illegal value for hex attribute of unicode element."
1265                )
1266        elif element.tag == "guideline":
1267            if validate and len(element):
1268                raise GlifLibError("Unknown children in guideline element.")
1269            attrib = dict(element.attrib)
1270            for attr in ("x", "y", "angle"):
1271                if attr in attrib:
1272                    attrib[attr] = _number(attrib[attr])
1273            guidelines.append(attrib)
1274        elif element.tag == "anchor":
1275            if validate and len(element):
1276                raise GlifLibError("Unknown children in anchor element.")
1277            attrib = dict(element.attrib)
1278            for attr in ("x", "y"):
1279                if attr in element.attrib:
1280                    attrib[attr] = _number(attrib[attr])
1281            anchors.append(attrib)
1282        elif element.tag == "image":
1283            if validate:
1284                if haveSeenImage:
1285                    raise GlifLibError("The image element occurs more than once.")
1286                if len(element):
1287                    raise GlifLibError("Unknown children in image element.")
1288            haveSeenImage = True
1289            _readImage(glyphObject, element, validate)
1290        elif element.tag == "note":
1291            if validate and haveSeenNote:
1292                raise GlifLibError("The note element occurs more than once.")
1293            haveSeenNote = True
1294            _readNote(glyphObject, element)
1295        elif element.tag == "lib":
1296            if validate and haveSeenLib:
1297                raise GlifLibError("The lib element occurs more than once.")
1298            haveSeenLib = True
1299            _readLib(glyphObject, element, validate)
1300        else:
1301            raise GlifLibError("Unknown element in GLIF: %s" % element)
1302    # set the collected unicodes
1303    if unicodes:
1304        _relaxedSetattr(glyphObject, "unicodes", unicodes)
1305    # set the collected guidelines
1306    if guidelines:
1307        if validate and not guidelinesValidator(guidelines, identifiers):
1308            raise GlifLibError("The guidelines are improperly formatted.")
1309        _relaxedSetattr(glyphObject, "guidelines", guidelines)
1310    # set the collected anchors
1311    if anchors:
1312        if validate and not anchorsValidator(anchors, identifiers):
1313            raise GlifLibError("The anchors are improperly formatted.")
1314        _relaxedSetattr(glyphObject, "anchors", anchors)
1315
1316
1317_READ_GLYPH_FROM_TREE_FUNCS = {
1318    GLIFFormatVersion.FORMAT_1_0: _readGlyphFromTreeFormat1,
1319    GLIFFormatVersion.FORMAT_2_0: _readGlyphFromTreeFormat2,
1320}
1321
1322
1323def _readName(glyphObject, root, validate):
1324    glyphName = root.get("name")
1325    if validate and not glyphName:
1326        raise GlifLibError("Empty glyph name in GLIF.")
1327    if glyphName and glyphObject is not None:
1328        _relaxedSetattr(glyphObject, "name", glyphName)
1329
1330
1331def _readAdvance(glyphObject, advance):
1332    width = _number(advance.get("width", 0))
1333    _relaxedSetattr(glyphObject, "width", width)
1334    height = _number(advance.get("height", 0))
1335    _relaxedSetattr(glyphObject, "height", height)
1336
1337
1338def _readNote(glyphObject, note):
1339    lines = note.text.split("\n")
1340    note = "\n".join(line.strip() for line in lines if line.strip())
1341    _relaxedSetattr(glyphObject, "note", note)
1342
1343
1344def _readLib(glyphObject, lib, validate):
1345    assert len(lib) == 1
1346    child = lib[0]
1347    plist = plistlib.fromtree(child)
1348    if validate:
1349        valid, message = glyphLibValidator(plist)
1350        if not valid:
1351            raise GlifLibError(message)
1352    _relaxedSetattr(glyphObject, "lib", plist)
1353
1354
1355def _readImage(glyphObject, image, validate):
1356    imageData = dict(image.attrib)
1357    for attr, default in _transformationInfo:
1358        value = imageData.get(attr, default)
1359        imageData[attr] = _number(value)
1360    if validate and not imageValidator(imageData):
1361        raise GlifLibError("The image element is not properly formatted.")
1362    _relaxedSetattr(glyphObject, "image", imageData)
1363
1364
1365# ----------------
1366# GLIF to PointPen
1367# ----------------
1368
1369contourAttributesFormat2 = {"identifier"}
1370componentAttributesFormat1 = {
1371    "base",
1372    "xScale",
1373    "xyScale",
1374    "yxScale",
1375    "yScale",
1376    "xOffset",
1377    "yOffset",
1378}
1379componentAttributesFormat2 = componentAttributesFormat1 | {"identifier"}
1380pointAttributesFormat1 = {"x", "y", "type", "smooth", "name"}
1381pointAttributesFormat2 = pointAttributesFormat1 | {"identifier"}
1382pointSmoothOptions = {"no", "yes"}
1383pointTypeOptions = {"move", "line", "offcurve", "curve", "qcurve"}
1384
1385# format 1
1386
1387
1388def buildOutlineFormat1(glyphObject, pen, outline, validate):
1389    anchors = []
1390    for element in outline:
1391        if element.tag == "contour":
1392            if len(element) == 1:
1393                point = element[0]
1394                if point.tag == "point":
1395                    anchor = _buildAnchorFormat1(point, validate)
1396                    if anchor is not None:
1397                        anchors.append(anchor)
1398                        continue
1399            if pen is not None:
1400                _buildOutlineContourFormat1(pen, element, validate)
1401        elif element.tag == "component":
1402            if pen is not None:
1403                _buildOutlineComponentFormat1(pen, element, validate)
1404        else:
1405            raise GlifLibError("Unknown element in outline element: %s" % element)
1406    if glyphObject is not None and anchors:
1407        if validate and not anchorsValidator(anchors):
1408            raise GlifLibError("GLIF 1 anchors are not properly formatted.")
1409        _relaxedSetattr(glyphObject, "anchors", anchors)
1410
1411
1412def _buildAnchorFormat1(point, validate):
1413    if point.get("type") != "move":
1414        return None
1415    name = point.get("name")
1416    if name is None:
1417        return None
1418    x = point.get("x")
1419    y = point.get("y")
1420    if validate and x is None:
1421        raise GlifLibError("Required x attribute is missing in point element.")
1422    if validate and y is None:
1423        raise GlifLibError("Required y attribute is missing in point element.")
1424    x = _number(x)
1425    y = _number(y)
1426    anchor = dict(x=x, y=y, name=name)
1427    return anchor
1428
1429
1430def _buildOutlineContourFormat1(pen, contour, validate):
1431    if validate and contour.attrib:
1432        raise GlifLibError("Unknown attributes in contour element.")
1433    pen.beginPath()
1434    if len(contour):
1435        massaged = _validateAndMassagePointStructures(
1436            contour,
1437            pointAttributesFormat1,
1438            openContourOffCurveLeniency=True,
1439            validate=validate,
1440        )
1441        _buildOutlinePointsFormat1(pen, massaged)
1442    pen.endPath()
1443
1444
1445def _buildOutlinePointsFormat1(pen, contour):
1446    for point in contour:
1447        x = point["x"]
1448        y = point["y"]
1449        segmentType = point["segmentType"]
1450        smooth = point["smooth"]
1451        name = point["name"]
1452        pen.addPoint((x, y), segmentType=segmentType, smooth=smooth, name=name)
1453
1454
1455def _buildOutlineComponentFormat1(pen, component, validate):
1456    if validate:
1457        if len(component):
1458            raise GlifLibError("Unknown child elements of component element.")
1459        for attr in component.attrib.keys():
1460            if attr not in componentAttributesFormat1:
1461                raise GlifLibError("Unknown attribute in component element: %s" % attr)
1462    baseGlyphName = component.get("base")
1463    if validate and baseGlyphName is None:
1464        raise GlifLibError("The base attribute is not defined in the component.")
1465    transformation = []
1466    for attr, default in _transformationInfo:
1467        value = component.get(attr)
1468        if value is None:
1469            value = default
1470        else:
1471            value = _number(value)
1472        transformation.append(value)
1473    pen.addComponent(baseGlyphName, tuple(transformation))
1474
1475
1476# format 2
1477
1478
1479def buildOutlineFormat2(glyphObject, pen, outline, identifiers, validate):
1480    for element in outline:
1481        if element.tag == "contour":
1482            _buildOutlineContourFormat2(pen, element, identifiers, validate)
1483        elif element.tag == "component":
1484            _buildOutlineComponentFormat2(pen, element, identifiers, validate)
1485        else:
1486            raise GlifLibError("Unknown element in outline element: %s" % element.tag)
1487
1488
1489def _buildOutlineContourFormat2(pen, contour, identifiers, validate):
1490    if validate:
1491        for attr in contour.attrib.keys():
1492            if attr not in contourAttributesFormat2:
1493                raise GlifLibError("Unknown attribute in contour element: %s" % attr)
1494    identifier = contour.get("identifier")
1495    if identifier is not None:
1496        if validate:
1497            if identifier in identifiers:
1498                raise GlifLibError(
1499                    "The identifier %s is used more than once." % identifier
1500                )
1501            if not identifierValidator(identifier):
1502                raise GlifLibError(
1503                    "The contour identifier %s is not valid." % identifier
1504                )
1505        identifiers.add(identifier)
1506    try:
1507        pen.beginPath(identifier=identifier)
1508    except TypeError:
1509        pen.beginPath()
1510        warn(
1511            "The beginPath method needs an identifier kwarg. The contour's identifier value has been discarded.",
1512            DeprecationWarning,
1513        )
1514    if len(contour):
1515        massaged = _validateAndMassagePointStructures(
1516            contour, pointAttributesFormat2, validate=validate
1517        )
1518        _buildOutlinePointsFormat2(pen, massaged, identifiers, validate)
1519    pen.endPath()
1520
1521
1522def _buildOutlinePointsFormat2(pen, contour, identifiers, validate):
1523    for point in contour:
1524        x = point["x"]
1525        y = point["y"]
1526        segmentType = point["segmentType"]
1527        smooth = point["smooth"]
1528        name = point["name"]
1529        identifier = point.get("identifier")
1530        if identifier is not None:
1531            if validate:
1532                if identifier in identifiers:
1533                    raise GlifLibError(
1534                        "The identifier %s is used more than once." % identifier
1535                    )
1536                if not identifierValidator(identifier):
1537                    raise GlifLibError("The identifier %s is not valid." % identifier)
1538            identifiers.add(identifier)
1539        try:
1540            pen.addPoint(
1541                (x, y),
1542                segmentType=segmentType,
1543                smooth=smooth,
1544                name=name,
1545                identifier=identifier,
1546            )
1547        except TypeError:
1548            pen.addPoint((x, y), segmentType=segmentType, smooth=smooth, name=name)
1549            warn(
1550                "The addPoint method needs an identifier kwarg. The point's identifier value has been discarded.",
1551                DeprecationWarning,
1552            )
1553
1554
1555def _buildOutlineComponentFormat2(pen, component, identifiers, validate):
1556    if validate:
1557        if len(component):
1558            raise GlifLibError("Unknown child elements of component element.")
1559        for attr in component.attrib.keys():
1560            if attr not in componentAttributesFormat2:
1561                raise GlifLibError("Unknown attribute in component element: %s" % attr)
1562    baseGlyphName = component.get("base")
1563    if validate and baseGlyphName is None:
1564        raise GlifLibError("The base attribute is not defined in the component.")
1565    transformation = []
1566    for attr, default in _transformationInfo:
1567        value = component.get(attr)
1568        if value is None:
1569            value = default
1570        else:
1571            value = _number(value)
1572        transformation.append(value)
1573    identifier = component.get("identifier")
1574    if identifier is not None:
1575        if validate:
1576            if identifier in identifiers:
1577                raise GlifLibError(
1578                    "The identifier %s is used more than once." % identifier
1579                )
1580            if validate and not identifierValidator(identifier):
1581                raise GlifLibError("The identifier %s is not valid." % identifier)
1582        identifiers.add(identifier)
1583    try:
1584        pen.addComponent(baseGlyphName, tuple(transformation), identifier=identifier)
1585    except TypeError:
1586        pen.addComponent(baseGlyphName, tuple(transformation))
1587        warn(
1588            "The addComponent method needs an identifier kwarg. The component's identifier value has been discarded.",
1589            DeprecationWarning,
1590        )
1591
1592
1593# all formats
1594
1595
1596def _validateAndMassagePointStructures(
1597    contour, pointAttributes, openContourOffCurveLeniency=False, validate=True
1598):
1599    if not len(contour):
1600        return
1601    # store some data for later validation
1602    lastOnCurvePoint = None
1603    haveOffCurvePoint = False
1604    # validate and massage the individual point elements
1605    massaged = []
1606    for index, element in enumerate(contour):
1607        # not <point>
1608        if element.tag != "point":
1609            raise GlifLibError(
1610                "Unknown child element (%s) of contour element." % element.tag
1611            )
1612        point = dict(element.attrib)
1613        massaged.append(point)
1614        if validate:
1615            # unknown attributes
1616            for attr in point.keys():
1617                if attr not in pointAttributes:
1618                    raise GlifLibError("Unknown attribute in point element: %s" % attr)
1619            # search for unknown children
1620            if len(element):
1621                raise GlifLibError("Unknown child elements in point element.")
1622        # x and y are required
1623        for attr in ("x", "y"):
1624            try:
1625                point[attr] = _number(point[attr])
1626            except KeyError as e:
1627                raise GlifLibError(
1628                    f"Required {attr} attribute is missing in point element."
1629                ) from e
1630        # segment type
1631        pointType = point.pop("type", "offcurve")
1632        if validate and pointType not in pointTypeOptions:
1633            raise GlifLibError("Unknown point type: %s" % pointType)
1634        if pointType == "offcurve":
1635            pointType = None
1636        point["segmentType"] = pointType
1637        if pointType is None:
1638            haveOffCurvePoint = True
1639        else:
1640            lastOnCurvePoint = index
1641        # move can only occur as the first point
1642        if validate and pointType == "move" and index != 0:
1643            raise GlifLibError(
1644                "A move point occurs after the first point in the contour."
1645            )
1646        # smooth is optional
1647        smooth = point.get("smooth", "no")
1648        if validate and smooth is not None:
1649            if smooth not in pointSmoothOptions:
1650                raise GlifLibError("Unknown point smooth value: %s" % smooth)
1651        smooth = smooth == "yes"
1652        point["smooth"] = smooth
1653        # smooth can only be applied to curve and qcurve
1654        if validate and smooth and pointType is None:
1655            raise GlifLibError("smooth attribute set in an offcurve point.")
1656        # name is optional
1657        if "name" not in element.attrib:
1658            point["name"] = None
1659    if openContourOffCurveLeniency:
1660        # remove offcurves that precede a move. this is technically illegal,
1661        # but we let it slide because there are fonts out there in the wild like this.
1662        if massaged[0]["segmentType"] == "move":
1663            count = 0
1664            for point in reversed(massaged):
1665                if point["segmentType"] is None:
1666                    count += 1
1667                else:
1668                    break
1669            if count:
1670                massaged = massaged[:-count]
1671    # validate the off-curves in the segments
1672    if validate and haveOffCurvePoint and lastOnCurvePoint is not None:
1673        # we only care about how many offCurves there are before an onCurve
1674        # filter out the trailing offCurves
1675        offCurvesCount = len(massaged) - 1 - lastOnCurvePoint
1676        for point in massaged:
1677            segmentType = point["segmentType"]
1678            if segmentType is None:
1679                offCurvesCount += 1
1680            else:
1681                if offCurvesCount:
1682                    # move and line can't be preceded by off-curves
1683                    if segmentType == "move":
1684                        # this will have been filtered out already
1685                        raise GlifLibError("move can not have an offcurve.")
1686                    elif segmentType == "line":
1687                        raise GlifLibError("line can not have an offcurve.")
1688                    elif segmentType == "curve":
1689                        if offCurvesCount > 2:
1690                            raise GlifLibError("Too many offcurves defined for curve.")
1691                    elif segmentType == "qcurve":
1692                        pass
1693                    else:
1694                        # unknown segment type. it'll be caught later.
1695                        pass
1696                offCurvesCount = 0
1697    return massaged
1698
1699
1700# ---------------------
1701# Misc Helper Functions
1702# ---------------------
1703
1704
1705def _relaxedSetattr(object, attr, value):
1706    try:
1707        setattr(object, attr, value)
1708    except AttributeError:
1709        pass
1710
1711
1712def _number(s):
1713    """
1714    Given a numeric string, return an integer or a float, whichever
1715    the string indicates. _number("1") will return the integer 1,
1716    _number("1.0") will return the float 1.0.
1717
1718    >>> _number("1")
1719    1
1720    >>> _number("1.0")
1721    1.0
1722    >>> _number("a")  # doctest: +IGNORE_EXCEPTION_DETAIL
1723    Traceback (most recent call last):
1724        ...
1725    GlifLibError: Could not convert a to an int or float.
1726    """
1727    try:
1728        n = int(s)
1729        return n
1730    except ValueError:
1731        pass
1732    try:
1733        n = float(s)
1734        return n
1735    except ValueError:
1736        raise GlifLibError("Could not convert %s to an int or float." % s)
1737
1738
1739# --------------------
1740# Rapid Value Fetching
1741# --------------------
1742
1743# base
1744
1745
1746class _DoneParsing(Exception):
1747    pass
1748
1749
1750class _BaseParser:
1751    def __init__(self):
1752        self._elementStack = []
1753
1754    def parse(self, text):
1755        from xml.parsers.expat import ParserCreate
1756
1757        parser = ParserCreate()
1758        parser.StartElementHandler = self.startElementHandler
1759        parser.EndElementHandler = self.endElementHandler
1760        parser.Parse(text)
1761
1762    def startElementHandler(self, name, attrs):
1763        self._elementStack.append(name)
1764
1765    def endElementHandler(self, name):
1766        other = self._elementStack.pop(-1)
1767        assert other == name
1768
1769
1770# unicodes
1771
1772
1773def _fetchUnicodes(glif):
1774    """
1775    Get a list of unicodes listed in glif.
1776    """
1777    parser = _FetchUnicodesParser()
1778    parser.parse(glif)
1779    return parser.unicodes
1780
1781
1782class _FetchUnicodesParser(_BaseParser):
1783    def __init__(self):
1784        self.unicodes = []
1785        super().__init__()
1786
1787    def startElementHandler(self, name, attrs):
1788        if (
1789            name == "unicode"
1790            and self._elementStack
1791            and self._elementStack[-1] == "glyph"
1792        ):
1793            value = attrs.get("hex")
1794            if value is not None:
1795                try:
1796                    value = int(value, 16)
1797                    if value not in self.unicodes:
1798                        self.unicodes.append(value)
1799                except ValueError:
1800                    pass
1801        super().startElementHandler(name, attrs)
1802
1803
1804# image
1805
1806
1807def _fetchImageFileName(glif):
1808    """
1809    The image file name (if any) from glif.
1810    """
1811    parser = _FetchImageFileNameParser()
1812    try:
1813        parser.parse(glif)
1814    except _DoneParsing:
1815        pass
1816    return parser.fileName
1817
1818
1819class _FetchImageFileNameParser(_BaseParser):
1820    def __init__(self):
1821        self.fileName = None
1822        super().__init__()
1823
1824    def startElementHandler(self, name, attrs):
1825        if name == "image" and self._elementStack and self._elementStack[-1] == "glyph":
1826            self.fileName = attrs.get("fileName")
1827            raise _DoneParsing
1828        super().startElementHandler(name, attrs)
1829
1830
1831# component references
1832
1833
1834def _fetchComponentBases(glif):
1835    """
1836    Get a list of component base glyphs listed in glif.
1837    """
1838    parser = _FetchComponentBasesParser()
1839    try:
1840        parser.parse(glif)
1841    except _DoneParsing:
1842        pass
1843    return list(parser.bases)
1844
1845
1846class _FetchComponentBasesParser(_BaseParser):
1847    def __init__(self):
1848        self.bases = []
1849        super().__init__()
1850
1851    def startElementHandler(self, name, attrs):
1852        if (
1853            name == "component"
1854            and self._elementStack
1855            and self._elementStack[-1] == "outline"
1856        ):
1857            base = attrs.get("base")
1858            if base is not None:
1859                self.bases.append(base)
1860        super().startElementHandler(name, attrs)
1861
1862    def endElementHandler(self, name):
1863        if name == "outline":
1864            raise _DoneParsing
1865        super().endElementHandler(name)
1866
1867
1868# --------------
1869# GLIF Point Pen
1870# --------------
1871
1872_transformationInfo = [
1873    # field name, default value
1874    ("xScale", 1),
1875    ("xyScale", 0),
1876    ("yxScale", 0),
1877    ("yScale", 1),
1878    ("xOffset", 0),
1879    ("yOffset", 0),
1880]
1881
1882
1883class GLIFPointPen(AbstractPointPen):
1884    """
1885    Helper class using the PointPen protocol to write the <outline>
1886    part of .glif files.
1887    """
1888
1889    def __init__(self, element, formatVersion=None, identifiers=None, validate=True):
1890        if identifiers is None:
1891            identifiers = set()
1892        self.formatVersion = GLIFFormatVersion(formatVersion)
1893        self.identifiers = identifiers
1894        self.outline = element
1895        self.contour = None
1896        self.prevOffCurveCount = 0
1897        self.prevPointTypes = []
1898        self.validate = validate
1899
1900    def beginPath(self, identifier=None, **kwargs):
1901        attrs = OrderedDict()
1902        if identifier is not None and self.formatVersion.major >= 2:
1903            if self.validate:
1904                if identifier in self.identifiers:
1905                    raise GlifLibError(
1906                        "identifier used more than once: %s" % identifier
1907                    )
1908                if not identifierValidator(identifier):
1909                    raise GlifLibError(
1910                        "identifier not formatted properly: %s" % identifier
1911                    )
1912            attrs["identifier"] = identifier
1913            self.identifiers.add(identifier)
1914        self.contour = etree.SubElement(self.outline, "contour", attrs)
1915        self.prevOffCurveCount = 0
1916
1917    def endPath(self):
1918        if self.prevPointTypes and self.prevPointTypes[0] == "move":
1919            if self.validate and self.prevPointTypes[-1] == "offcurve":
1920                raise GlifLibError("open contour has loose offcurve point")
1921        # prevent lxml from writing self-closing tags
1922        if not len(self.contour):
1923            self.contour.text = "\n  "
1924        self.contour = None
1925        self.prevPointType = None
1926        self.prevOffCurveCount = 0
1927        self.prevPointTypes = []
1928
1929    def addPoint(
1930        self, pt, segmentType=None, smooth=None, name=None, identifier=None, **kwargs
1931    ):
1932        attrs = OrderedDict()
1933        # coordinates
1934        if pt is not None:
1935            if self.validate:
1936                for coord in pt:
1937                    if not isinstance(coord, numberTypes):
1938                        raise GlifLibError("coordinates must be int or float")
1939            attrs["x"] = repr(pt[0])
1940            attrs["y"] = repr(pt[1])
1941        # segment type
1942        if segmentType == "offcurve":
1943            segmentType = None
1944        if self.validate:
1945            if segmentType == "move" and self.prevPointTypes:
1946                raise GlifLibError(
1947                    "move occurs after a point has already been added to the contour."
1948                )
1949            if (
1950                segmentType in ("move", "line")
1951                and self.prevPointTypes
1952                and self.prevPointTypes[-1] == "offcurve"
1953            ):
1954                raise GlifLibError("offcurve occurs before %s point." % segmentType)
1955            if segmentType == "curve" and self.prevOffCurveCount > 2:
1956                raise GlifLibError("too many offcurve points before curve point.")
1957        if segmentType is not None:
1958            attrs["type"] = segmentType
1959        else:
1960            segmentType = "offcurve"
1961        if segmentType == "offcurve":
1962            self.prevOffCurveCount += 1
1963        else:
1964            self.prevOffCurveCount = 0
1965        self.prevPointTypes.append(segmentType)
1966        # smooth
1967        if smooth:
1968            if self.validate and segmentType == "offcurve":
1969                raise GlifLibError("can't set smooth in an offcurve point.")
1970            attrs["smooth"] = "yes"
1971        # name
1972        if name is not None:
1973            attrs["name"] = name
1974        # identifier
1975        if identifier is not None and self.formatVersion.major >= 2:
1976            if self.validate:
1977                if identifier in self.identifiers:
1978                    raise GlifLibError(
1979                        "identifier used more than once: %s" % identifier
1980                    )
1981                if not identifierValidator(identifier):
1982                    raise GlifLibError(
1983                        "identifier not formatted properly: %s" % identifier
1984                    )
1985            attrs["identifier"] = identifier
1986            self.identifiers.add(identifier)
1987        etree.SubElement(self.contour, "point", attrs)
1988
1989    def addComponent(self, glyphName, transformation, identifier=None, **kwargs):
1990        attrs = OrderedDict([("base", glyphName)])
1991        for (attr, default), value in zip(_transformationInfo, transformation):
1992            if self.validate and not isinstance(value, numberTypes):
1993                raise GlifLibError("transformation values must be int or float")
1994            if value != default:
1995                attrs[attr] = repr(value)
1996        if identifier is not None and self.formatVersion.major >= 2:
1997            if self.validate:
1998                if identifier in self.identifiers:
1999                    raise GlifLibError(
2000                        "identifier used more than once: %s" % identifier
2001                    )
2002                if self.validate and not identifierValidator(identifier):
2003                    raise GlifLibError(
2004                        "identifier not formatted properly: %s" % identifier
2005                    )
2006            attrs["identifier"] = identifier
2007            self.identifiers.add(identifier)
2008        etree.SubElement(self.outline, "component", attrs)
2009
2010
2011if __name__ == "__main__":
2012    import doctest
2013
2014    doctest.testmod()
2015