text: from collections import defaultdict
  import io
  import pathlib
  import pickle
  import os
  import re
  import sys
  from types import SimpleNamespace
  from fontTools.feaLib.parser import Parser as FeatureParser
  from fontTools.feaLib.ast import IncludeStatement
  from fontTools.feaLib.error import FeatureLibError
  from fontTools.fontBuilder import FontBuilder
  from fontTools.pens.cocoaPen import CocoaPen  # TODO: factor out mac-specific code
  from fontTools.pens.recordingPen import RecordingPen
  from fontTools.ttLib import TTFont
  from fontTools.ufoLib import UFOReader, UFOFileStructure
  from fontTools.ufoLib import (FONTINFO_FILENAME, GROUPS_FILENAME, KERNING_FILENAME,
                                FEATURES_FILENAME, LIB_FILENAME)
  from fontTools.ufoLib.glifLib import Glyph as GLIFGlyph, CONTENTS_FILENAME
  
  try:
      from ufo2ft.constants import COLOR_LAYER_MAPPING_KEY, COLOR_PALETTES_KEY
  except ImportError:
      COLOR_LAYER_MAPPING_KEY = None
      COLOR_PALETTES_KEY = None
      pass
  
  from .baseFont import BaseFont
  from .glyphDrawing import GlyphDrawing
  from ..compile.compilerPool import compileUFOToBytes
  from ..compile.ufoCompiler import fetchCharacterMappingAndAnchors
  from ..misc.hbShape import HBShape
  from ..misc.properties import cachedProperty
  
  
  class UFOFont(BaseFont):
  
      ufoState = None
  
      def resetCache(self):
          super().resetCache()
          del self.defaultVerticalAdvance
          del self.defaultVerticalOriginY
          del self.globalColorLayerMapping
  
      def _setupReaderAndGlyphSet(self):
          self.reader = UFOReader(self.fontPath, validate=False)
          self.glyphSet = self.reader.getGlyphSet()
          self.glyphSet.glyphClass = Glyph
          self.layerGlyphSets = {}
  
      def load(self, outputWriter):
          if hasattr(self, "reader"):
              self._cachedGlyphs = {}
              return
          self._setupReaderAndGlyphSet()
          self.info = SimpleNamespace()
          self.reader.readInfo(self.info)
          self.lib = self.reader.readLib()
          self._cachedGlyphs = {}
          if self.ufoState is None:
              includedFeatureFiles = extractIncludedFeatureFiles(self.fontPath, self.reader)
              self.ufoState = UFOState(self.reader, self.glyphSet, getUnicodesAndAnchors=self._getUnicodesAndAnchors, includedFeatureFiles=includedFeatureFiles)
  
          fontData = compileUFOToBytes(self.fontPath, outputWriter)
  
          f = io.BytesIO(fontData)
          self.ttFont = TTFont(f, lazy=True)
          self.shaper = self._getShaper(fontData)
  
      def updateFontPath(self, newFontPath):
          """This gets called when the source file was moved."""
          super().updateFontPath(newFontPath)
          self._setupReaderAndGlyphSet()
  
      def getExternalFiles(self):
          return self.ufoState.includedFeatureFiles
  
      def canReloadWithChange(self, externalFilePath):
          if self.reader.fileStructure != UFOFileStructure.PACKAGE:
              # We can't (won't) partially reload .ufoz
              return False
  
          if externalFilePath:
              # Features need to be recompiled no matter what
              return False
  
          self.glyphSet.rebuildContents()
  
          self.ufoState = self.ufoState.newState()
          (needsFeaturesUpdate, needsGlyphUpdate, needsInfoUpdate,
           needsCmapUpdate, needsLibUpdate) = self.ufoState.getUpdateInfo()
  
          if needsFeaturesUpdate:
              return False
  
          if needsInfoUpdate:
              # font.info changed, all we care about is a possibly change unitsPerEm
              self.info = SimpleNamespace()
              self.reader.readInfo(self.info)
  
          if needsCmapUpdate:
              # The cmap changed. Let's update it in-place and only rebuild the shaper
              newCmap = {code: gn for gn, codes in self.ufoState.unicodes.items() for code in codes}
              fb = FontBuilder(font=self.ttFont)
              fb.setupCharacterMap(newCmap)
              f = io.BytesIO()
              self.ttFont.save(f, reorderTables=False)
              self.shaper = self._getShaper(f.getvalue())
  
          if needsLibUpdate:
              self.lib = self.reader.readLib()
  
          # We don't explicitly track changes in layers, but they may be involved
          # in building layered color glyphs, so let's just always reset the cache.
          self.resetCache()
  
          return True
  
      def _getUnicodesAndAnchors(self):
          unicodes = defaultdict(list)
          for code, gn in self.ttFont.getBestCmap().items():
              unicodes[gn].append(code)
          anchors = pickle.loads(self.ttFont["FGAx"].data)
          return unicodes, anchors
  
      def _getShaper(self, fontData):
          return HBShape(fontData,
                         getHorizontalAdvance=self._getHorizontalAdvance,
                         getVerticalAdvance=self._getVerticalAdvance,
                         getVerticalOrigin=self._getVerticalOrigin,
                         ttFont=self.ttFont)
  
      @cachedProperty
      def unitsPerEm(self):
          return self.info.unitsPerEm
  
      def _getGlyph(self, glyphName, layerName=None):
          glyph = self._cachedGlyphs.get((layerName, glyphName))
          if glyph is None:
              if glyphName == ".notdef" and glyphName not in self.glyphSet:
                  # We need a .notdef glyph, so let's make one.
                  glyph = NotDefGlyph(self.info.unitsPerEm)
                  self._addOutlinePathToGlyph(glyph)
              else:
                  try:
                      if layerName is None:
                          glyph = self.glyphSet[glyphName]
                      else:
                          glyph = self.getLayerGlyphSet(layerName)[glyphName]
                      self._addOutlinePathToGlyph(glyph)
                  except Exception as e:
                      # TODO: logging would be better but then capturing in mainWindow.py is harder
                      print(f"Glyph '{glyphName}' could not be read: {e!r}", file=sys.stderr)
                      glyph = self._getGlyph(".notdef")
              self._cachedGlyphs[(layerName, glyphName)] = glyph
          return glyph
  
      def _addOutlinePathToGlyph(self, glyph):
          if self.cocoa:
              pen = CocoaPen(self.glyphSet)
              glyph.draw(pen)
              glyph.outline = pen.path
          else:
              pen = RecordingPen()
              glyph.draw(pen)
              glyph.outline = pen
  
      def _getHorizontalAdvance(self, glyphName):
          glyph = self._getGlyph(glyphName)
          return glyph.width
  
      @cachedProperty
      def defaultVerticalAdvance(self):
          ascender = getattr(self.info, "ascender", None)
          descender = getattr(self.info, "descender", None)
          if ascender is None or descender is None:
              return self.info.unitsPerEm
          else:
              return ascender + abs(descender)
  
      @cachedProperty
      def defaultVerticalOriginY(self):
          ascender = getattr(self.info, "ascender", None)
          if ascender is None:
              return self.info.unitsPerEm  # ???
          else:
              return ascender
  
      def _getVerticalAdvance(self, glyphName):
          glyph = self._getGlyph(glyphName)
          vAdvance = glyph.height
          if vAdvance is None or vAdvance == 0:  # XXX default vAdv == 0 -> bad UFO spec
              vAdvance = self.defaultVerticalAdvance
          return -abs(vAdvance)
  
      def _getVerticalOrigin(self, glyphName):
          glyph = self._getGlyph(glyphName)
          vOrgX = glyph.width / 2
          lib = getattr(glyph, "lib", {})
          vOrgY = lib.get("public.verticalOrigin")
          if vOrgY is None:
              vOrgY = self.defaultVerticalOriginY
          return True, vOrgX, vOrgY
  
      def _getGlyphDrawing(self, glyphName, colorLayers):
          glyph = self._getGlyph(glyphName)
          if colorLayers:
              colorLayerMapping = glyph.lib.get(COLOR_LAYER_MAPPING_KEY)
              if colorLayerMapping is None:
                  colorLayerMapping = self.globalColorLayerMapping
              if colorLayerMapping is not None:
                  layers = []
                  for layerName, colorID in colorLayerMapping:
                      glyph = self._getGlyph(glyphName, layerName)
                      if not isinstance(glyph, NotDefGlyph):
                          layers.append((glyph.outline, colorID))
                  if layers:
                      return GlyphDrawing(layers)
          return GlyphDrawing([(glyph.outline, None)])
  
      @cachedProperty
      def colorPalettes(self):
          return self.lib.get(COLOR_PALETTES_KEY)
  
      @cachedProperty
      def globalColorLayerMapping(self):
          return self.lib.get(COLOR_LAYER_MAPPING_KEY)
  
      def getLayerGlyphSet(self, layerName):
          layerGlyphSet = self.layerGlyphSets.get(layerName)
          if layerGlyphSet is None:
              layerGlyphSet = self.reader.getGlyphSet(layerName)
              self.layerGlyphSets[layerName] = layerGlyphSet
          return layerGlyphSet
  
  
  class NotDefGlyph:
  
      def __init__(self, unitsPerEm):
          self.unitsPerEm = unitsPerEm
          self.width = unitsPerEm // 2
          self.height = unitsPerEm
          self.verticalOrigin = (self.width / 2, 0.75 * unitsPerEm)
          self.lib = {}
  
      def draw(self, pen):
          inset = 0.05 * self.unitsPerEm
          sideBearing = 0.05 * self.unitsPerEm
          height = 0.75 * self.unitsPerEm
          xMin, yMin, xMax, yMax = sideBearing, 0, self.width - sideBearing, height
          pen.moveTo((xMin, yMin))
          pen.lineTo((xMin, yMax))
          pen.lineTo((xMax, yMax))
          pen.lineTo((xMax, yMin))
          pen.closePath()
          xMin += inset
          yMin += inset
          xMax -= inset
          yMax -= inset
          pen.moveTo((xMin, yMin))
          pen.lineTo((xMax, yMin))
          pen.lineTo((xMax, yMax))
          pen.lineTo((xMin, yMax))
          pen.closePath()
  
      def setVarLocation(self, varLocation):
          # For compatibility with dsFont.VarGlyph
          pass
  
      def getOutline(self, cocoa=True):
          if cocoa:
              pen = CocoaPen(None)  # by now there are no more composites
              self.draw(pen)
              return pen.path
          else:
              pen = RecordingPen()
              self.draw(pen)
              return pen
  
  
  class Glyph(GLIFGlyph):
      width = 0
      height = None
      lib = {}  # readonly default!
  
  
  def extractIncludedFeatureFiles(ufoPath, reader=None):
      if isinstance(ufoPath, str):
          ufoPath = pathlib.Path(ufoPath)
      if reader is None:
          reader = UFOReader(ufoPath, validate=False)
      mainFeatures = reader.readFeatures()
      if not mainFeatures:
          return ()
      return sorted(set(_extractIncludedFeatureFiles(mainFeatures, [ufoPath.parent])))
  
  
  def _extractIncludedFeatureFiles(featureSource, searchPaths, recursionLevel=0):
      if recursionLevel > 50:
          raise FeatureLibError("Too many recursive includes", None)
      for fileName in _parseFeaSource(featureSource):
          for d in searchPaths:
              p = d / fileName
              if p.exists():
                  p = p.resolve()
                  yield p
                  yield from _extractIncludedFeatureFiles(p.read_text("utf-8", "replace"),
                                                          [searchPaths[0], p.parent],
                                                          recursionLevel+1)
                  break
  
  
  _feaIncludePat = re.compile(r"include\s*\(([^)]+)\)")
  
  
  def _parseFeaSource(featureSource):
      pos = 0
      while True:
          m = _feaIncludePat.search(featureSource, pos)
          if m is None:
              break
          pos = m.end()
  
          lineStart = featureSource.rfind("\n", 0, m.start())
          lineEnd = featureSource.find("\n", m.end())
          if lineStart == -1:
              lineStart = 0
          if lineEnd == -1:
              lineEnd = len(featureSource)
          line = featureSource[lineStart:lineEnd]
          f = io.StringIO(line)
          p = FeatureParser(f, followIncludes=False)
          for st in p.parse().statements:
              if isinstance(st, IncludeStatement):
                  yield st.filename
  
  
  ufoFilesToTrack = [FONTINFO_FILENAME, GROUPS_FILENAME, KERNING_FILENAME, FEATURES_FILENAME,
                     LIB_FILENAME]
  
  
  class UFOState:
  
      """Object to keep track of various file modification times and miscellaneous
      other state needed to determine how to handle reloading of the UFO data. Sometimes
      features need to be rebuilt, other times an outline cache flush is enough.
      """
  
      #
      # This is rather intricate as we try to do as little work as possible.
      # For example, the first time a UFO is loaded, all .glif files are parsed
      # to collect anchor information (needed for mark features) and unicodes
      # (to build the cmap). Upon external changes to the UFO, we don't want to
      # redo all that work (it wouldn't scale to large UFOs) so we keep track
      # of the anchors and unicodes by only reparsing .glif files that actually
      # changed.
      #
      # This does _not_ track changes to included .fea files. That part is handled
      # by UFOFont.canReloadWithChange() as we'll get separate file-changed events
      # for that.
      #
  
      def __init__(self, reader, glyphSet, anchors=None, unicodes=None,
                   getUnicodesAndAnchors=None, includedFeatureFiles=(),
                   previousState=None):
          if COLOR_LAYER_MAPPING_KEY is None or COLOR_PALETTES_KEY is None:
              raise Exception("pip install ufo2ft (note: constants)")
  
          self.reader = reader
          self.glyphSet = glyphSet
          assert (anchors is not None) == (getUnicodesAndAnchors is None)
          assert (unicodes is not None) == (getUnicodesAndAnchors is None)
          self._anchors = anchors
          self._unicodes = unicodes
          self._getUnicodesAndAnchors = getUnicodesAndAnchors
          self.glyphModTimes, self.contentsModTime = getGlyphModTimes(glyphSet)
          self.fileModTimes = getFileModTimes(reader.fs.getsyspath("/"), ufoFilesToTrack)
          self.includedFeatureFiles = includedFeatureFiles
          self._previousState = previousState
  
      def newState(self):
          # This method can only be called on a brand new state without a previous
          # state, or on a state that was properly updated via a call to getUpdateInfo()
          assert self._previousState is None, "state was not updated"
          newState = UFOState(self.reader, self.glyphSet,
                              self._anchors, self._unicodes,
                              self._getUnicodesAndAnchors,
                              self.includedFeatureFiles,
                              self)
          self._previousState = None
          return newState
  
      def getUpdateInfo(self):
          # Calling this method has the side effect of updating the internal state
          # to the current state of the UFO.
          prev = self._previousState
          assert prev is not None, "getUpdateInfo() is a one-shot method"  # Or: memoize
          self._previousState = None
  
          changedFiles = {fileName for fileName, modTime in prev.fileModTimes ^ self.fileModTimes}
  
          needsInfoUpdate = FONTINFO_FILENAME in changedFiles
          needsLibUpdate = LIB_FILENAME in changedFiles
  
          needsFeaturesUpdate = (FEATURES_FILENAME in changedFiles or
                                 GROUPS_FILENAME in changedFiles or
                                 KERNING_FILENAME in changedFiles)
  
          needsGlyphUpdate = False
          needsCmapUpdate = False
  
          if prev.glyphModTimes != self.glyphModTimes or prev.contentsModTime != self.contentsModTime:
              changedGlyphNames = {glyphName for glyphName, mtime in prev.glyphModTimes ^ self.glyphModTimes}
              deletedGlyphNames = {glyphName for glyphName in changedGlyphNames if glyphName not in self.glyphSet}
  
              _, changedUnicodes, changedAnchors = fetchCharacterMappingAndAnchors(self.glyphSet,
                                                                                   self.reader.fs.getsyspath("/"),
                                                                                   changedGlyphNames - deletedGlyphNames)
  
              # Within the changed glyphs, let's see if their anchors changed
              for gn in changedGlyphNames:
                  if gn in prev.anchors and gn not in changedAnchors:
                      changedAnchors[gn] = []  # Anchor(s) got deleted
  
              anchors = dict(prev.anchors)
              anchors.update(changedAnchors)
              self.anchors = {gn: anchorList for gn, anchorList in anchors.items() if anchorList}
              needsFeaturesUpdate = needsFeaturesUpdate or prev.anchors != self.anchors
  
              # Within the changed glyphs, let's see if their unicodes changed
              for gn in changedGlyphNames:
                  if gn in prev.unicodes and gn not in changedUnicodes:
                      changedUnicodes[gn] = []  # Unicode(s) got deleted
  
              unicodes = dict(prev.unicodes)
              unicodes.update(changedUnicodes)
              self.unicodes = {gn: codes for gn, codes in unicodes.items() if codes}
              needsCmapUpdate = prev.unicodes != self.unicodes
              needsGlyphUpdate = bool(changedGlyphNames)
  
          return needsFeaturesUpdate, needsGlyphUpdate, needsInfoUpdate, needsCmapUpdate, needsLibUpdate
  
      @property
      def anchors(self):
          if self._anchors is None:
              self._unicodes, self._anchors = self._getUnicodesAndAnchors()
              self._getUnicodesAndAnchors = None
          return self._anchors
  
      @anchors.setter
      def anchors(self, anchors):
          self._anchors = anchors
          self._getUnicodesAndAnchors = None
  
      @property
      def unicodes(self):
          if self._unicodes is None:
              self._unicodes, self._anchors = self._getUnicodesAndAnchors()
              self._getUnicodesAndAnchors = None
          return self._unicodes
  
      @unicodes.setter
      def unicodes(self, unicodes):
          self._unicodes = unicodes
          self._getUnicodesAndAnchors = None
  
  
  def getModTime(path):
      try:
          return os.stat(path).st_mtime
      except FileNotFoundError:
          return None
  
  
  def getGlyphModTimes(glyphSet):
      folder = glyphSet.fs.getsyspath("/")  # We don't support .ufoz here
      contentsModTime = getModTime(os.path.join(folder, CONTENTS_FILENAME))
      return {(glyphName, getModTime(os.path.join(folder, fileName)))
              for glyphName, fileName in glyphSet.contents.items()}, contentsModTime
  
  
  def getFileModTimes(folder, fileNames):
      return {(fileName, getModTime(os.path.join(folder, fileName)))
              for fileName in fileNames}
  
  
  if __name__ == "__main__":
      for feaPath in extractIncludedFeatureFiles(sys.argv[1]):
          print(feaPath)
  