12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477 |
- """
- A library for importing .ufo files and their descendants.
- Refer to http://unifiedfontobject.org for the UFO specification.
- The main interfaces are the :class:`.UFOReader` and :class:`.UFOWriter`
- classes, which support versions 1, 2, and 3 of the UFO specification.
- Set variables are available for external use that list the font
- info attribute names for the `fontinfo.plist` formats. These are:
- - :obj:`.fontInfoAttributesVersion1`
- - :obj:`.fontInfoAttributesVersion2`
- - :obj:`.fontInfoAttributesVersion3`
- A set listing the `fontinfo.plist` attributes that were deprecated
- in version 2 is available for external use:
- - :obj:`.deprecatedFontInfoAttributesVersion2`
- Functions that do basic validation on values for `fontinfo.plist`
- are available for external use. These are
- - :func:`.validateFontInfoVersion2ValueForAttribute`
- - :func:`.validateFontInfoVersion3ValueForAttribute`
- Value conversion functions are available for converting
- `fontinfo.plist` values between the possible format versions.
- - :func:`.convertFontInfoValueForAttributeFromVersion1ToVersion2`
- - :func:`.convertFontInfoValueForAttributeFromVersion2ToVersion1`
- - :func:`.convertFontInfoValueForAttributeFromVersion2ToVersion3`
- - :func:`.convertFontInfoValueForAttributeFromVersion3ToVersion2`
- """
- import os
- from copy import deepcopy
- from os import fsdecode
- import logging
- import zipfile
- import enum
- from collections import OrderedDict
- import fs
- import fs.base
- import fs.subfs
- import fs.errors
- import fs.copy
- import fs.osfs
- import fs.zipfs
- import fs.tempfs
- import fs.tools
- from fontTools.misc import plistlib
- from fontTools.ufoLib.validators import *
- from fontTools.ufoLib.filenames import userNameToFileName
- from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning
- from fontTools.ufoLib.errors import UFOLibError
- from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin
- __all__ = [
- "makeUFOPath",
- "UFOLibError",
- "UFOReader",
- "UFOWriter",
- "UFOReaderWriter",
- "UFOFileStructure",
- "fontInfoAttributesVersion1",
- "fontInfoAttributesVersion2",
- "fontInfoAttributesVersion3",
- "deprecatedFontInfoAttributesVersion2",
- "validateFontInfoVersion2ValueForAttribute",
- "validateFontInfoVersion3ValueForAttribute",
- "convertFontInfoValueForAttributeFromVersion1ToVersion2",
- "convertFontInfoValueForAttributeFromVersion2ToVersion1",
- ]
- __version__ = "3.0.0"
- logger = logging.getLogger(__name__)
- # ---------
- # Constants
- # ---------
- DEFAULT_GLYPHS_DIRNAME = "glyphs"
- DATA_DIRNAME = "data"
- IMAGES_DIRNAME = "images"
- METAINFO_FILENAME = "metainfo.plist"
- FONTINFO_FILENAME = "fontinfo.plist"
- LIB_FILENAME = "lib.plist"
- GROUPS_FILENAME = "groups.plist"
- KERNING_FILENAME = "kerning.plist"
- FEATURES_FILENAME = "features.fea"
- LAYERCONTENTS_FILENAME = "layercontents.plist"
- LAYERINFO_FILENAME = "layerinfo.plist"
- DEFAULT_LAYER_NAME = "public.default"
- class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum):
- FORMAT_1_0 = (1, 0)
- FORMAT_2_0 = (2, 0)
- FORMAT_3_0 = (3, 0)
- # python 3.11 doesn't like when a mixin overrides a dunder method like __str__
- # for some reasons it keep using Enum.__str__, see
- # https://github.com/fonttools/fonttools/pull/2655
- UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__
- class UFOFileStructure(enum.Enum):
- ZIP = "zip"
- PACKAGE = "package"
- # --------------
- # Shared Methods
- # --------------
- class _UFOBaseIO:
- def getFileModificationTime(self, path):
- """
- Returns the modification time for the file at the given path, as a
- floating point number giving the number of seconds since the epoch.
- The path must be relative to the UFO path.
- Returns None if the file does not exist.
- """
- try:
- dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified
- except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound):
- return None
- else:
- return dt.timestamp()
- def _getPlist(self, fileName, default=None):
- """
- Read a property list relative to the UFO filesystem's root.
- Raises UFOLibError if the file is missing and default is None,
- otherwise default is returned.
- The errors that could be raised during the reading of a plist are
- unpredictable and/or too large to list, so, a blind try: except:
- is done. If an exception occurs, a UFOLibError will be raised.
- """
- try:
- with self.fs.open(fileName, "rb") as f:
- return plistlib.load(f)
- except fs.errors.ResourceNotFound:
- if default is None:
- raise UFOLibError(
- "'%s' is missing on %s. This file is required" % (fileName, self.fs)
- )
- else:
- return default
- except Exception as e:
- # TODO(anthrotype): try to narrow this down a little
- raise UFOLibError(f"'{fileName}' could not be read on {self.fs}: {e}")
- def _writePlist(self, fileName, obj):
- """
- Write a property list to a file relative to the UFO filesystem's root.
- Do this sort of atomically, making it harder to corrupt existing files,
- for example when plistlib encounters an error halfway during write.
- This also checks to see if text matches the text that is already in the
- file at path. If so, the file is not rewritten so that the modification
- date is preserved.
- The errors that could be raised during the writing of a plist are
- unpredictable and/or too large to list, so, a blind try: except: is done.
- If an exception occurs, a UFOLibError will be raised.
- """
- if self._havePreviousFile:
- try:
- data = plistlib.dumps(obj)
- except Exception as e:
- raise UFOLibError(
- "'%s' could not be written on %s because "
- "the data is not properly formatted: %s" % (fileName, self.fs, e)
- )
- if self.fs.exists(fileName) and data == self.fs.readbytes(fileName):
- return
- self.fs.writebytes(fileName, data)
- else:
- with self.fs.openbin(fileName, mode="w") as fp:
- try:
- plistlib.dump(obj, fp)
- except Exception as e:
- raise UFOLibError(
- "'%s' could not be written on %s because "
- "the data is not properly formatted: %s"
- % (fileName, self.fs, e)
- )
- # ----------
- # UFO Reader
- # ----------
- class UFOReader(_UFOBaseIO):
- """Read the various components of a .ufo.
- Attributes:
- path: An `os.PathLike` object pointing to the .ufo.
- validate: A boolean indicating if the data read should be
- validated. Defaults to `True`.
- By default read data is validated. Set ``validate`` to
- ``False`` to not validate the data.
- """
- def __init__(self, path, validate=True):
- if hasattr(path, "__fspath__"): # support os.PathLike objects
- path = path.__fspath__()
- if isinstance(path, str):
- structure = _sniffFileStructure(path)
- try:
- if structure is UFOFileStructure.ZIP:
- parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8")
- else:
- parentFS = fs.osfs.OSFS(path)
- except fs.errors.CreateFailed as e:
- raise UFOLibError(f"unable to open '{path}': {e}")
- if structure is UFOFileStructure.ZIP:
- # .ufoz zip files must contain a single root directory, with arbitrary
- # name, containing all the UFO files
- rootDirs = [
- p.name
- for p in parentFS.scandir("/")
- # exclude macOS metadata contained in zip file
- if p.is_dir and p.name != "__MACOSX"
- ]
- if len(rootDirs) == 1:
- # 'ClosingSubFS' ensures that the parent zip file is closed when
- # its root subdirectory is closed
- self.fs = parentFS.opendir(
- rootDirs[0], factory=fs.subfs.ClosingSubFS
- )
- else:
- raise UFOLibError(
- "Expected exactly 1 root directory, found %d" % len(rootDirs)
- )
- else:
- # normal UFO 'packages' are just a single folder
- self.fs = parentFS
- # when passed a path string, we make sure we close the newly opened fs
- # upon calling UFOReader.close method or context manager's __exit__
- self._shouldClose = True
- self._fileStructure = structure
- elif isinstance(path, fs.base.FS):
- filesystem = path
- try:
- filesystem.check()
- except fs.errors.FilesystemClosed:
- raise UFOLibError("the filesystem '%s' is closed" % path)
- else:
- self.fs = filesystem
- try:
- path = filesystem.getsyspath("/")
- except fs.errors.NoSysPath:
- # network or in-memory FS may not map to the local one
- path = str(filesystem)
- # when user passed an already initialized fs instance, it is her
- # responsibility to close it, thus UFOReader.close/__exit__ are no-op
- self._shouldClose = False
- # default to a 'package' structure
- self._fileStructure = UFOFileStructure.PACKAGE
- else:
- raise TypeError(
- "Expected a path string or fs.base.FS object, found '%s'"
- % type(path).__name__
- )
- self._path = fsdecode(path)
- self._validate = validate
- self._upConvertedKerningData = None
- try:
- self.readMetaInfo(validate=validate)
- except UFOLibError:
- self.close()
- raise
- # properties
- def _get_path(self):
- import warnings
- warnings.warn(
- "The 'path' attribute is deprecated; use the 'fs' attribute instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return self._path
- path = property(_get_path, doc="The path of the UFO (DEPRECATED).")
- def _get_formatVersion(self):
- import warnings
- warnings.warn(
- "The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'",
- DeprecationWarning,
- stacklevel=2,
- )
- return self._formatVersion.major
- formatVersion = property(
- _get_formatVersion,
- doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple",
- )
- @property
- def formatVersionTuple(self):
- """The (major, minor) format version of the UFO.
- This is determined by reading metainfo.plist during __init__.
- """
- return self._formatVersion
- def _get_fileStructure(self):
- return self._fileStructure
- fileStructure = property(
- _get_fileStructure,
- doc=(
- "The file structure of the UFO: "
- "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE"
- ),
- )
- # up conversion
- def _upConvertKerning(self, validate):
- """
- Up convert kerning and groups in UFO 1 and 2.
- The data will be held internally until each bit of data
- has been retrieved. The conversion of both must be done
- at once, so the raw data is cached and an error is raised
- if one bit of data becomes obsolete before it is called.
- ``validate`` will validate the data.
- """
- if self._upConvertedKerningData:
- testKerning = self._readKerning()
- if testKerning != self._upConvertedKerningData["originalKerning"]:
- raise UFOLibError(
- "The data in kerning.plist has been modified since it was converted to UFO 3 format."
- )
- testGroups = self._readGroups()
- if testGroups != self._upConvertedKerningData["originalGroups"]:
- raise UFOLibError(
- "The data in groups.plist has been modified since it was converted to UFO 3 format."
- )
- else:
- groups = self._readGroups()
- if validate:
- invalidFormatMessage = "groups.plist is not properly formatted."
- if not isinstance(groups, dict):
- raise UFOLibError(invalidFormatMessage)
- for groupName, glyphList in groups.items():
- if not isinstance(groupName, str):
- raise UFOLibError(invalidFormatMessage)
- elif not isinstance(glyphList, list):
- raise UFOLibError(invalidFormatMessage)
- for glyphName in glyphList:
- if not isinstance(glyphName, str):
- raise UFOLibError(invalidFormatMessage)
- self._upConvertedKerningData = dict(
- kerning={},
- originalKerning=self._readKerning(),
- groups={},
- originalGroups=groups,
- )
- # convert kerning and groups
- kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning(
- self._upConvertedKerningData["originalKerning"],
- deepcopy(self._upConvertedKerningData["originalGroups"]),
- self.getGlyphSet(),
- )
- # store
- self._upConvertedKerningData["kerning"] = kerning
- self._upConvertedKerningData["groups"] = groups
- self._upConvertedKerningData["groupRenameMaps"] = conversionMaps
- # support methods
- def readBytesFromPath(self, path):
- """
- Returns the bytes in the file at the given path.
- The path must be relative to the UFO's filesystem root.
- Returns None if the file does not exist.
- """
- try:
- return self.fs.readbytes(fsdecode(path))
- except fs.errors.ResourceNotFound:
- return None
- def getReadFileForPath(self, path, encoding=None):
- """
- Returns a file (or file-like) object for the file at the given path.
- The path must be relative to the UFO path.
- Returns None if the file does not exist.
- By default the file is opened in binary mode (reads bytes).
- If encoding is passed, the file is opened in text mode (reads str).
- Note: The caller is responsible for closing the open file.
- """
- path = fsdecode(path)
- try:
- if encoding is None:
- return self.fs.openbin(path)
- else:
- return self.fs.open(path, mode="r", encoding=encoding)
- except fs.errors.ResourceNotFound:
- return None
- # metainfo.plist
- def _readMetaInfo(self, validate=None):
- """
- Read metainfo.plist and return raw data. Only used for internal operations.
- ``validate`` will validate the read data, by default it is set
- to the class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- data = self._getPlist(METAINFO_FILENAME)
- if validate and not isinstance(data, dict):
- raise UFOLibError("metainfo.plist is not properly formatted.")
- try:
- formatVersionMajor = data["formatVersion"]
- except KeyError:
- raise UFOLibError(
- f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}"
- )
- formatVersionMinor = data.setdefault("formatVersionMinor", 0)
- try:
- formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor))
- except ValueError as e:
- unsupportedMsg = (
- f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) "
- f"in '{METAINFO_FILENAME}' on {self.fs}"
- )
- if validate:
- from fontTools.ufoLib.errors import UnsupportedUFOFormat
- raise UnsupportedUFOFormat(unsupportedMsg) from e
- formatVersion = UFOFormatVersion.default()
- logger.warning(
- "%s. Assuming the latest supported version (%s). "
- "Some data may be skipped or parsed incorrectly",
- unsupportedMsg,
- formatVersion,
- )
- data["formatVersionTuple"] = formatVersion
- return data
- def readMetaInfo(self, validate=None):
- """
- Read metainfo.plist and set formatVersion. Only used for internal operations.
- ``validate`` will validate the read data, by default it is set
- to the class's validate value, can be overridden.
- """
- data = self._readMetaInfo(validate=validate)
- self._formatVersion = data["formatVersionTuple"]
- # groups.plist
- def _readGroups(self):
- groups = self._getPlist(GROUPS_FILENAME, {})
- # remove any duplicate glyphs in a kerning group
- for groupName, glyphList in groups.items():
- if groupName.startswith(("public.kern1.", "public.kern2.")):
- groups[groupName] = list(OrderedDict.fromkeys(glyphList))
- return groups
- def readGroups(self, validate=None):
- """
- Read groups.plist. Returns a dict.
- ``validate`` will validate the read data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- # handle up conversion
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- self._upConvertKerning(validate)
- groups = self._upConvertedKerningData["groups"]
- # normal
- else:
- groups = self._readGroups()
- if validate:
- valid, message = groupsValidator(groups)
- if not valid:
- raise UFOLibError(message)
- return groups
- def getKerningGroupConversionRenameMaps(self, validate=None):
- """
- Get maps defining the renaming that was done during any
- needed kerning group conversion. This method returns a
- dictionary of this form::
- {
- "side1" : {"old group name" : "new group name"},
- "side2" : {"old group name" : "new group name"}
- }
- When no conversion has been performed, the side1 and side2
- dictionaries will be empty.
- ``validate`` will validate the groups, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
- return dict(side1={}, side2={})
- # use the public group reader to force the load and
- # conversion of the data if it hasn't happened yet.
- self.readGroups(validate=validate)
- return self._upConvertedKerningData["groupRenameMaps"]
- # fontinfo.plist
- def _readInfo(self, validate):
- data = self._getPlist(FONTINFO_FILENAME, {})
- if validate and not isinstance(data, dict):
- raise UFOLibError("fontinfo.plist is not properly formatted.")
- return data
- def readInfo(self, info, validate=None):
- """
- Read fontinfo.plist. It requires an object that allows
- setting attributes with names that follow the fontinfo.plist
- version 3 specification. This will write the attributes
- defined in the file into the object.
- ``validate`` will validate the read data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- infoDict = self._readInfo(validate)
- infoDataToSet = {}
- # version 1
- if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
- for attr in fontInfoAttributesVersion1:
- value = infoDict.get(attr)
- if value is not None:
- infoDataToSet[attr] = value
- infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet)
- infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
- # version 2
- elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
- for attr, dataValidationDict in list(
- fontInfoAttributesVersion2ValueData.items()
- ):
- value = infoDict.get(attr)
- if value is None:
- continue
- infoDataToSet[attr] = value
- infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
- # version 3.x
- elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
- for attr, dataValidationDict in list(
- fontInfoAttributesVersion3ValueData.items()
- ):
- value = infoDict.get(attr)
- if value is None:
- continue
- infoDataToSet[attr] = value
- # unsupported version
- else:
- raise NotImplementedError(self._formatVersion)
- # validate data
- if validate:
- infoDataToSet = validateInfoVersion3Data(infoDataToSet)
- # populate the object
- for attr, value in list(infoDataToSet.items()):
- try:
- setattr(info, attr, value)
- except AttributeError:
- raise UFOLibError(
- "The supplied info object does not support setting a necessary attribute (%s)."
- % attr
- )
- # kerning.plist
- def _readKerning(self):
- data = self._getPlist(KERNING_FILENAME, {})
- return data
- def readKerning(self, validate=None):
- """
- Read kerning.plist. Returns a dict.
- ``validate`` will validate the kerning data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- # handle up conversion
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- self._upConvertKerning(validate)
- kerningNested = self._upConvertedKerningData["kerning"]
- # normal
- else:
- kerningNested = self._readKerning()
- if validate:
- valid, message = kerningValidator(kerningNested)
- if not valid:
- raise UFOLibError(message)
- # flatten
- kerning = {}
- for left in kerningNested:
- for right in kerningNested[left]:
- value = kerningNested[left][right]
- kerning[left, right] = value
- return kerning
- # lib.plist
- def readLib(self, validate=None):
- """
- Read lib.plist. Returns a dict.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- data = self._getPlist(LIB_FILENAME, {})
- if validate:
- valid, message = fontLibValidator(data)
- if not valid:
- raise UFOLibError(message)
- return data
- # features.fea
- def readFeatures(self):
- """
- Read features.fea. Return a string.
- The returned string is empty if the file is missing.
- """
- try:
- with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f:
- return f.read()
- except fs.errors.ResourceNotFound:
- return ""
- # glyph sets & layers
- def _readLayerContents(self, validate):
- """
- Rebuild the layer contents list by checking what glyphsets
- are available on disk.
- ``validate`` will validate the layer contents.
- """
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)]
- contents = self._getPlist(LAYERCONTENTS_FILENAME)
- if validate:
- valid, error = layerContentsValidator(contents, self.fs)
- if not valid:
- raise UFOLibError(error)
- return contents
- def getLayerNames(self, validate=None):
- """
- Get the ordered layer names from layercontents.plist.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- layerContents = self._readLayerContents(validate)
- layerNames = [layerName for layerName, directoryName in layerContents]
- return layerNames
- def getDefaultLayerName(self, validate=None):
- """
- Get the default layer name from layercontents.plist.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- layerContents = self._readLayerContents(validate)
- for layerName, layerDirectory in layerContents:
- if layerDirectory == DEFAULT_GLYPHS_DIRNAME:
- return layerName
- # this will already have been raised during __init__
- raise UFOLibError("The default layer is not defined in layercontents.plist.")
- def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None):
- """
- Return the GlyphSet associated with the
- glyphs directory mapped to layerName
- in the UFO. If layerName is not provided,
- the name retrieved with getDefaultLayerName
- will be used.
- ``validateRead`` will validate the read data, by default it is set to the
- class's validate value, can be overridden.
- ``validateWrite`` will validate the written data, by default it is set to the
- class's validate value, can be overridden.
- """
- from fontTools.ufoLib.glifLib import GlyphSet
- if validateRead is None:
- validateRead = self._validate
- if validateWrite is None:
- validateWrite = self._validate
- if layerName is None:
- layerName = self.getDefaultLayerName(validate=validateRead)
- directory = None
- layerContents = self._readLayerContents(validateRead)
- for storedLayerName, storedLayerDirectory in layerContents:
- if layerName == storedLayerName:
- directory = storedLayerDirectory
- break
- if directory is None:
- raise UFOLibError('No glyphs directory is mapped to "%s".' % layerName)
- try:
- glyphSubFS = self.fs.opendir(directory)
- except fs.errors.ResourceNotFound:
- raise UFOLibError(f"No '{directory}' directory for layer '{layerName}'")
- return GlyphSet(
- glyphSubFS,
- ufoFormatVersion=self._formatVersion,
- validateRead=validateRead,
- validateWrite=validateWrite,
- expectContentsFile=True,
- )
- def getCharacterMapping(self, layerName=None, validate=None):
- """
- Return a dictionary that maps unicode values (ints) to
- lists of glyph names.
- """
- if validate is None:
- validate = self._validate
- glyphSet = self.getGlyphSet(
- layerName, validateRead=validate, validateWrite=True
- )
- allUnicodes = glyphSet.getUnicodes()
- cmap = {}
- for glyphName, unicodes in allUnicodes.items():
- for code in unicodes:
- if code in cmap:
- cmap[code].append(glyphName)
- else:
- cmap[code] = [glyphName]
- return cmap
- # /data
- def getDataDirectoryListing(self):
- """
- Returns a list of all files in the data directory.
- The returned paths will be relative to the UFO.
- This will not list directory names, only file names.
- Thus, empty directories will be skipped.
- """
- try:
- self._dataFS = self.fs.opendir(DATA_DIRNAME)
- except fs.errors.ResourceNotFound:
- return []
- except fs.errors.DirectoryExpected:
- raise UFOLibError('The UFO contains a "data" file instead of a directory.')
- try:
- # fs Walker.files method returns "absolute" paths (in terms of the
- # root of the 'data' SubFS), so we strip the leading '/' to make
- # them relative
- return [p.lstrip("/") for p in self._dataFS.walk.files()]
- except fs.errors.ResourceError:
- return []
- def getImageDirectoryListing(self, validate=None):
- """
- Returns a list of all image file names in
- the images directory. Each of the images will
- have been verified to have the PNG signature.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- return []
- if validate is None:
- validate = self._validate
- try:
- self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME)
- except fs.errors.ResourceNotFound:
- return []
- except fs.errors.DirectoryExpected:
- raise UFOLibError(
- 'The UFO contains an "images" file instead of a directory.'
- )
- result = []
- for path in imagesFS.scandir("/"):
- if path.is_dir:
- # silently skip this as version control
- # systems often have hidden directories
- continue
- if validate:
- with imagesFS.openbin(path.name) as fp:
- valid, error = pngValidator(fileObj=fp)
- if valid:
- result.append(path.name)
- else:
- result.append(path.name)
- return result
- def readData(self, fileName):
- """
- Return bytes for the file named 'fileName' inside the 'data/' directory.
- """
- fileName = fsdecode(fileName)
- try:
- try:
- dataFS = self._dataFS
- except AttributeError:
- # in case readData is called before getDataDirectoryListing
- dataFS = self.fs.opendir(DATA_DIRNAME)
- data = dataFS.readbytes(fileName)
- except fs.errors.ResourceNotFound:
- raise UFOLibError(f"No data file named '{fileName}' on {self.fs}")
- return data
- def readImage(self, fileName, validate=None):
- """
- Return image data for the file named fileName.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- raise UFOLibError(
- f"Reading images is not allowed in UFO {self._formatVersion.major}."
- )
- fileName = fsdecode(fileName)
- try:
- try:
- imagesFS = self._imagesFS
- except AttributeError:
- # in case readImage is called before getImageDirectoryListing
- imagesFS = self.fs.opendir(IMAGES_DIRNAME)
- data = imagesFS.readbytes(fileName)
- except fs.errors.ResourceNotFound:
- raise UFOLibError(f"No image file named '{fileName}' on {self.fs}")
- if validate:
- valid, error = pngValidator(data=data)
- if not valid:
- raise UFOLibError(error)
- return data
- def close(self):
- if self._shouldClose:
- self.fs.close()
- def __enter__(self):
- return self
- def __exit__(self, exc_type, exc_value, exc_tb):
- self.close()
- # ----------
- # UFO Writer
- # ----------
- class UFOWriter(UFOReader):
- """Write the various components of a .ufo.
- Attributes:
- path: An `os.PathLike` object pointing to the .ufo.
- formatVersion: the UFO format version as a tuple of integers (major, minor),
- or as a single integer for the major digit only (minor is implied to be 0).
- By default, the latest formatVersion will be used; currently it is 3.0,
- which is equivalent to formatVersion=(3, 0).
- fileCreator: The creator of the .ufo file. Defaults to
- `com.github.fonttools.ufoLib`.
- structure: The internal structure of the .ufo file: either `ZIP` or `PACKAGE`.
- validate: A boolean indicating if the data read should be validated. Defaults
- to `True`.
- By default, the written data will be validated before writing. Set ``validate`` to
- ``False`` if you do not want to validate the data. Validation can also be overriden
- on a per-method level if desired.
- Raises:
- UnsupportedUFOFormat: An exception indicating that the requested UFO
- formatVersion is not supported.
- """
- def __init__(
- self,
- path,
- formatVersion=None,
- fileCreator="com.github.fonttools.ufoLib",
- structure=None,
- validate=True,
- ):
- try:
- formatVersion = UFOFormatVersion(formatVersion)
- except ValueError as e:
- from fontTools.ufoLib.errors import UnsupportedUFOFormat
- raise UnsupportedUFOFormat(
- f"Unsupported UFO format: {formatVersion!r}"
- ) from e
- if hasattr(path, "__fspath__"): # support os.PathLike objects
- path = path.__fspath__()
- if isinstance(path, str):
- # normalize path by removing trailing or double slashes
- path = os.path.normpath(path)
- havePreviousFile = os.path.exists(path)
- if havePreviousFile:
- # ensure we use the same structure as the destination
- existingStructure = _sniffFileStructure(path)
- if structure is not None:
- try:
- structure = UFOFileStructure(structure)
- except ValueError:
- raise UFOLibError(
- "Invalid or unsupported structure: '%s'" % structure
- )
- if structure is not existingStructure:
- raise UFOLibError(
- "A UFO with a different structure (%s) already exists "
- "at the given path: '%s'" % (existingStructure, path)
- )
- else:
- structure = existingStructure
- else:
- # if not exists, default to 'package' structure
- if structure is None:
- structure = UFOFileStructure.PACKAGE
- dirName = os.path.dirname(path)
- if dirName and not os.path.isdir(dirName):
- raise UFOLibError(
- "Cannot write to '%s': directory does not exist" % path
- )
- if structure is UFOFileStructure.ZIP:
- if havePreviousFile:
- # we can't write a zip in-place, so we have to copy its
- # contents to a temporary location and work from there, then
- # upon closing UFOWriter we create the final zip file
- parentFS = fs.tempfs.TempFS()
- with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS:
- fs.copy.copy_fs(origFS, parentFS)
- # if output path is an existing zip, we require that it contains
- # one, and only one, root directory (with arbitrary name), in turn
- # containing all the existing UFO contents
- rootDirs = [
- p.name
- for p in parentFS.scandir("/")
- # exclude macOS metadata contained in zip file
- if p.is_dir and p.name != "__MACOSX"
- ]
- if len(rootDirs) != 1:
- raise UFOLibError(
- "Expected exactly 1 root directory, found %d"
- % len(rootDirs)
- )
- else:
- # 'ClosingSubFS' ensures that the parent filesystem is closed
- # when its root subdirectory is closed
- self.fs = parentFS.opendir(
- rootDirs[0], factory=fs.subfs.ClosingSubFS
- )
- else:
- # if the output zip file didn't exist, we create the root folder;
- # we name it the same as input 'path', but with '.ufo' extension
- rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo"
- parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8")
- parentFS.makedir(rootDir)
- self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS)
- else:
- self.fs = fs.osfs.OSFS(path, create=True)
- self._fileStructure = structure
- self._havePreviousFile = havePreviousFile
- self._shouldClose = True
- elif isinstance(path, fs.base.FS):
- filesystem = path
- try:
- filesystem.check()
- except fs.errors.FilesystemClosed:
- raise UFOLibError("the filesystem '%s' is closed" % path)
- else:
- self.fs = filesystem
- try:
- path = filesystem.getsyspath("/")
- except fs.errors.NoSysPath:
- # network or in-memory FS may not map to the local one
- path = str(filesystem)
- # if passed an FS object, always use 'package' structure
- if structure and structure is not UFOFileStructure.PACKAGE:
- import warnings
- warnings.warn(
- "The 'structure' argument is not used when input is an FS object",
- UserWarning,
- stacklevel=2,
- )
- self._fileStructure = UFOFileStructure.PACKAGE
- # if FS contains a "metainfo.plist", we consider it non-empty
- self._havePreviousFile = filesystem.exists(METAINFO_FILENAME)
- # the user is responsible for closing the FS object
- self._shouldClose = False
- else:
- raise TypeError(
- "Expected a path string or fs object, found %s" % type(path).__name__
- )
- # establish some basic stuff
- self._path = fsdecode(path)
- self._formatVersion = formatVersion
- self._fileCreator = fileCreator
- self._downConversionKerningData = None
- self._validate = validate
- # if the file already exists, get the format version.
- # this will be needed for up and down conversion.
- previousFormatVersion = None
- if self._havePreviousFile:
- metaInfo = self._readMetaInfo(validate=validate)
- previousFormatVersion = metaInfo["formatVersionTuple"]
- # catch down conversion
- if previousFormatVersion > formatVersion:
- from fontTools.ufoLib.errors import UnsupportedUFOFormat
- raise UnsupportedUFOFormat(
- "The UFO located at this path is a higher version "
- f"({previousFormatVersion}) than the version ({formatVersion}) "
- "that is trying to be written. This is not supported."
- )
- # handle the layer contents
- self.layerContents = {}
- if previousFormatVersion is not None and previousFormatVersion.major >= 3:
- # already exists
- self.layerContents = OrderedDict(self._readLayerContents(validate))
- else:
- # previous < 3
- # imply the layer contents
- if self.fs.exists(DEFAULT_GLYPHS_DIRNAME):
- self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME}
- # write the new metainfo
- self._writeMetaInfo()
- # properties
- def _get_fileCreator(self):
- return self._fileCreator
- fileCreator = property(
- _get_fileCreator,
- doc="The file creator of the UFO. This is set into metainfo.plist during __init__.",
- )
- # support methods for file system interaction
- def copyFromReader(self, reader, sourcePath, destPath):
- """
- Copy the sourcePath in the provided UFOReader to destPath
- in this writer. The paths must be relative. This works with
- both individual files and directories.
- """
- if not isinstance(reader, UFOReader):
- raise UFOLibError("The reader must be an instance of UFOReader.")
- sourcePath = fsdecode(sourcePath)
- destPath = fsdecode(destPath)
- if not reader.fs.exists(sourcePath):
- raise UFOLibError(
- 'The reader does not have data located at "%s".' % sourcePath
- )
- if self.fs.exists(destPath):
- raise UFOLibError('A file named "%s" already exists.' % destPath)
- # create the destination directory if it doesn't exist
- self.fs.makedirs(fs.path.dirname(destPath), recreate=True)
- if reader.fs.isdir(sourcePath):
- fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath)
- else:
- fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath)
- def writeBytesToPath(self, path, data):
- """
- Write bytes to a path relative to the UFO filesystem's root.
- If writing to an existing UFO, check to see if data matches the data
- that is already in the file at path; if so, the file is not rewritten
- so that the modification date is preserved.
- If needed, the directory tree for the given path will be built.
- """
- path = fsdecode(path)
- if self._havePreviousFile:
- if self.fs.isfile(path) and data == self.fs.readbytes(path):
- return
- try:
- self.fs.writebytes(path, data)
- except fs.errors.FileExpected:
- raise UFOLibError("A directory exists at '%s'" % path)
- except fs.errors.ResourceNotFound:
- self.fs.makedirs(fs.path.dirname(path), recreate=True)
- self.fs.writebytes(path, data)
- def getFileObjectForPath(self, path, mode="w", encoding=None):
- """
- Returns a file (or file-like) object for the
- file at the given path. The path must be relative
- to the UFO path. Returns None if the file does
- not exist and the mode is "r" or "rb.
- An encoding may be passed if the file is opened in text mode.
- Note: The caller is responsible for closing the open file.
- """
- path = fsdecode(path)
- try:
- return self.fs.open(path, mode=mode, encoding=encoding)
- except fs.errors.ResourceNotFound as e:
- m = mode[0]
- if m == "r":
- # XXX I think we should just let it raise. The docstring,
- # however, says that this returns None if mode is 'r'
- return None
- elif m == "w" or m == "a" or m == "x":
- self.fs.makedirs(fs.path.dirname(path), recreate=True)
- return self.fs.open(path, mode=mode, encoding=encoding)
- except fs.errors.ResourceError as e:
- return UFOLibError(f"unable to open '{path}' on {self.fs}: {e}")
- def removePath(self, path, force=False, removeEmptyParents=True):
- """
- Remove the file (or directory) at path. The path
- must be relative to the UFO.
- Raises UFOLibError if the path doesn't exist.
- If force=True, ignore non-existent paths.
- If the directory where 'path' is located becomes empty, it will
- be automatically removed, unless 'removeEmptyParents' is False.
- """
- path = fsdecode(path)
- try:
- self.fs.remove(path)
- except fs.errors.FileExpected:
- self.fs.removetree(path)
- except fs.errors.ResourceNotFound:
- if not force:
- raise UFOLibError(f"'{path}' does not exist on {self.fs}")
- if removeEmptyParents:
- parent = fs.path.dirname(path)
- if parent:
- fs.tools.remove_empty(self.fs, parent)
- # alias kept for backward compatibility with old API
- removeFileForPath = removePath
- # UFO mod time
- def setModificationTime(self):
- """
- Set the UFO modification time to the current time.
- This is never called automatically. It is up to the
- caller to call this when finished working on the UFO.
- """
- path = self._path
- if path is not None and os.path.exists(path):
- try:
- # this may fail on some filesystems (e.g. SMB servers)
- os.utime(path, None)
- except OSError as e:
- logger.warning("Failed to set modified time: %s", e)
- # metainfo.plist
- def _writeMetaInfo(self):
- metaInfo = dict(
- creator=self._fileCreator,
- formatVersion=self._formatVersion.major,
- )
- if self._formatVersion.minor != 0:
- metaInfo["formatVersionMinor"] = self._formatVersion.minor
- self._writePlist(METAINFO_FILENAME, metaInfo)
- # groups.plist
- def setKerningGroupConversionRenameMaps(self, maps):
- """
- Set maps defining the renaming that should be done
- when writing groups and kerning in UFO 1 and UFO 2.
- This will effectively undo the conversion done when
- UFOReader reads this data. The dictionary should have
- this form::
- {
- "side1" : {"group name to use when writing" : "group name in data"},
- "side2" : {"group name to use when writing" : "group name in data"}
- }
- This is the same form returned by UFOReader's
- getKerningGroupConversionRenameMaps method.
- """
- if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
- return # XXX raise an error here
- # flip the dictionaries
- remap = {}
- for side in ("side1", "side2"):
- for writeName, dataName in list(maps[side].items()):
- remap[dataName] = writeName
- self._downConversionKerningData = dict(groupRenameMap=remap)
- def writeGroups(self, groups, validate=None):
- """
- Write groups.plist. This method requires a
- dict of glyph groups as an argument.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- # validate the data structure
- if validate:
- valid, message = groupsValidator(groups)
- if not valid:
- raise UFOLibError(message)
- # down convert
- if (
- self._formatVersion < UFOFormatVersion.FORMAT_3_0
- and self._downConversionKerningData is not None
- ):
- remap = self._downConversionKerningData["groupRenameMap"]
- remappedGroups = {}
- # there are some edge cases here that are ignored:
- # 1. if a group is being renamed to a name that
- # already exists, the existing group is always
- # overwritten. (this is why there are two loops
- # below.) there doesn't seem to be a logical
- # solution to groups mismatching and overwriting
- # with the specifiecd group seems like a better
- # solution than throwing an error.
- # 2. if side 1 and side 2 groups are being renamed
- # to the same group name there is no check to
- # ensure that the contents are identical. that
- # is left up to the caller.
- for name, contents in list(groups.items()):
- if name in remap:
- continue
- remappedGroups[name] = contents
- for name, contents in list(groups.items()):
- if name not in remap:
- continue
- name = remap[name]
- remappedGroups[name] = contents
- groups = remappedGroups
- # pack and write
- groupsNew = {}
- for key, value in groups.items():
- groupsNew[key] = list(value)
- if groupsNew:
- self._writePlist(GROUPS_FILENAME, groupsNew)
- elif self._havePreviousFile:
- self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False)
- # fontinfo.plist
- def writeInfo(self, info, validate=None):
- """
- Write info.plist. This method requires an object
- that supports getting attributes that follow the
- fontinfo.plist version 2 specification. Attributes
- will be taken from the given object and written
- into the file.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- # gather version 3 data
- infoData = {}
- for attr in list(fontInfoAttributesVersion3ValueData.keys()):
- if hasattr(info, attr):
- try:
- value = getattr(info, attr)
- except AttributeError:
- raise UFOLibError(
- "The supplied info object does not support getting a necessary attribute (%s)."
- % attr
- )
- if value is None:
- continue
- infoData[attr] = value
- # down convert data if necessary and validate
- if self._formatVersion == UFOFormatVersion.FORMAT_3_0:
- if validate:
- infoData = validateInfoVersion3Data(infoData)
- elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
- infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
- if validate:
- infoData = validateInfoVersion2Data(infoData)
- elif self._formatVersion == UFOFormatVersion.FORMAT_1_0:
- infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
- if validate:
- infoData = validateInfoVersion2Data(infoData)
- infoData = _convertFontInfoDataVersion2ToVersion1(infoData)
- # write file if there is anything to write
- if infoData:
- self._writePlist(FONTINFO_FILENAME, infoData)
- # kerning.plist
- def writeKerning(self, kerning, validate=None):
- """
- Write kerning.plist. This method requires a
- dict of kerning pairs as an argument.
- This performs basic structural validation of the kerning,
- but it does not check for compliance with the spec in
- regards to conflicting pairs. The assumption is that the
- kerning data being passed is standards compliant.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- # validate the data structure
- if validate:
- invalidFormatMessage = "The kerning is not properly formatted."
- if not isDictEnough(kerning):
- raise UFOLibError(invalidFormatMessage)
- for pair, value in list(kerning.items()):
- if not isinstance(pair, (list, tuple)):
- raise UFOLibError(invalidFormatMessage)
- if not len(pair) == 2:
- raise UFOLibError(invalidFormatMessage)
- if not isinstance(pair[0], str):
- raise UFOLibError(invalidFormatMessage)
- if not isinstance(pair[1], str):
- raise UFOLibError(invalidFormatMessage)
- if not isinstance(value, numberTypes):
- raise UFOLibError(invalidFormatMessage)
- # down convert
- if (
- self._formatVersion < UFOFormatVersion.FORMAT_3_0
- and self._downConversionKerningData is not None
- ):
- remap = self._downConversionKerningData["groupRenameMap"]
- remappedKerning = {}
- for (side1, side2), value in list(kerning.items()):
- side1 = remap.get(side1, side1)
- side2 = remap.get(side2, side2)
- remappedKerning[side1, side2] = value
- kerning = remappedKerning
- # pack and write
- kerningDict = {}
- for left, right in kerning.keys():
- value = kerning[left, right]
- if left not in kerningDict:
- kerningDict[left] = {}
- kerningDict[left][right] = value
- if kerningDict:
- self._writePlist(KERNING_FILENAME, kerningDict)
- elif self._havePreviousFile:
- self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False)
- # lib.plist
- def writeLib(self, libDict, validate=None):
- """
- Write lib.plist. This method requires a
- lib dict as an argument.
- ``validate`` will validate the data, by default it is set to the
- class's validate value, can be overridden.
- """
- if validate is None:
- validate = self._validate
- if validate:
- valid, message = fontLibValidator(libDict)
- if not valid:
- raise UFOLibError(message)
- if libDict:
- self._writePlist(LIB_FILENAME, libDict)
- elif self._havePreviousFile:
- self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False)
- # features.fea
- def writeFeatures(self, features, validate=None):
- """
- Write features.fea. This method requires a
- features string as an argument.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
- raise UFOLibError("features.fea is not allowed in UFO Format Version 1.")
- if validate:
- if not isinstance(features, str):
- raise UFOLibError("The features are not text.")
- if features:
- self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8"))
- elif self._havePreviousFile:
- self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False)
- # glyph sets & layers
- def writeLayerContents(self, layerOrder=None, validate=None):
- """
- Write the layercontents.plist file. This method *must* be called
- after all glyph sets have been written.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- return
- if layerOrder is not None:
- newOrder = []
- for layerName in layerOrder:
- if layerName is None:
- layerName = DEFAULT_LAYER_NAME
- newOrder.append(layerName)
- layerOrder = newOrder
- else:
- layerOrder = list(self.layerContents.keys())
- if validate and set(layerOrder) != set(self.layerContents.keys()):
- raise UFOLibError(
- "The layer order content does not match the glyph sets that have been created."
- )
- layerContents = [
- (layerName, self.layerContents[layerName]) for layerName in layerOrder
- ]
- self._writePlist(LAYERCONTENTS_FILENAME, layerContents)
- def _findDirectoryForLayerName(self, layerName):
- foundDirectory = None
- for existingLayerName, directoryName in list(self.layerContents.items()):
- if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME:
- foundDirectory = directoryName
- break
- elif existingLayerName == layerName:
- foundDirectory = directoryName
- break
- if not foundDirectory:
- raise UFOLibError(
- "Could not locate a glyph set directory for the layer named %s."
- % layerName
- )
- return foundDirectory
- def getGlyphSet(
- self,
- layerName=None,
- defaultLayer=True,
- glyphNameToFileNameFunc=None,
- validateRead=None,
- validateWrite=None,
- expectContentsFile=False,
- ):
- """
- Return the GlyphSet object associated with the
- appropriate glyph directory in the .ufo.
- If layerName is None, the default glyph set
- will be used. The defaultLayer flag indictes
- that the layer should be saved into the default
- glyphs directory.
- ``validateRead`` will validate the read data, by default it is set to the
- class's validate value, can be overridden.
- ``validateWrte`` will validate the written data, by default it is set to the
- class's validate value, can be overridden.
- ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
- not found on the glyph set file system. This should be set to ``True`` if you
- are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create
- a fresh glyph set.
- """
- if validateRead is None:
- validateRead = self._validate
- if validateWrite is None:
- validateWrite = self._validate
- # only default can be written in < 3
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0 and (
- not defaultLayer or layerName is not None
- ):
- raise UFOLibError(
- f"Only the default layer can be writen in UFO {self._formatVersion.major}."
- )
- # locate a layer name when None has been given
- if layerName is None and defaultLayer:
- for existingLayerName, directory in self.layerContents.items():
- if directory == DEFAULT_GLYPHS_DIRNAME:
- layerName = existingLayerName
- if layerName is None:
- layerName = DEFAULT_LAYER_NAME
- elif layerName is None and not defaultLayer:
- raise UFOLibError("A layer name must be provided for non-default layers.")
- # move along to format specific writing
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- return self._getDefaultGlyphSet(
- validateRead,
- validateWrite,
- glyphNameToFileNameFunc=glyphNameToFileNameFunc,
- expectContentsFile=expectContentsFile,
- )
- elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
- return self._getGlyphSetFormatVersion3(
- validateRead,
- validateWrite,
- layerName=layerName,
- defaultLayer=defaultLayer,
- glyphNameToFileNameFunc=glyphNameToFileNameFunc,
- expectContentsFile=expectContentsFile,
- )
- else:
- raise NotImplementedError(self._formatVersion)
- def _getDefaultGlyphSet(
- self,
- validateRead,
- validateWrite,
- glyphNameToFileNameFunc=None,
- expectContentsFile=False,
- ):
- from fontTools.ufoLib.glifLib import GlyphSet
- glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True)
- return GlyphSet(
- glyphSubFS,
- glyphNameToFileNameFunc=glyphNameToFileNameFunc,
- ufoFormatVersion=self._formatVersion,
- validateRead=validateRead,
- validateWrite=validateWrite,
- expectContentsFile=expectContentsFile,
- )
- def _getGlyphSetFormatVersion3(
- self,
- validateRead,
- validateWrite,
- layerName=None,
- defaultLayer=True,
- glyphNameToFileNameFunc=None,
- expectContentsFile=False,
- ):
- from fontTools.ufoLib.glifLib import GlyphSet
- # if the default flag is on, make sure that the default in the file
- # matches the default being written. also make sure that this layer
- # name is not already linked to a non-default layer.
- if defaultLayer:
- for existingLayerName, directory in self.layerContents.items():
- if directory == DEFAULT_GLYPHS_DIRNAME:
- if existingLayerName != layerName:
- raise UFOLibError(
- "Another layer ('%s') is already mapped to the default directory."
- % existingLayerName
- )
- elif existingLayerName == layerName:
- raise UFOLibError(
- "The layer name is already mapped to a non-default layer."
- )
- # get an existing directory name
- if layerName in self.layerContents:
- directory = self.layerContents[layerName]
- # get a new directory name
- else:
- if defaultLayer:
- directory = DEFAULT_GLYPHS_DIRNAME
- else:
- # not caching this could be slightly expensive,
- # but caching it will be cumbersome
- existing = {d.lower() for d in self.layerContents.values()}
- directory = userNameToFileName(
- layerName, existing=existing, prefix="glyphs."
- )
- # make the directory
- glyphSubFS = self.fs.makedir(directory, recreate=True)
- # store the mapping
- self.layerContents[layerName] = directory
- # load the glyph set
- return GlyphSet(
- glyphSubFS,
- glyphNameToFileNameFunc=glyphNameToFileNameFunc,
- ufoFormatVersion=self._formatVersion,
- validateRead=validateRead,
- validateWrite=validateWrite,
- expectContentsFile=expectContentsFile,
- )
- def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False):
- """
- Rename a glyph set.
- Note: if a GlyphSet object has already been retrieved for
- layerName, it is up to the caller to inform that object that
- the directory it represents has changed.
- """
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- # ignore renaming glyph sets for UFO1 UFO2
- # just write the data from the default layer
- return
- # the new and old names can be the same
- # as long as the default is being switched
- if layerName == newLayerName:
- # if the default is off and the layer is already not the default, skip
- if (
- self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME
- and not defaultLayer
- ):
- return
- # if the default is on and the layer is already the default, skip
- if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer:
- return
- else:
- # make sure the new layer name doesn't already exist
- if newLayerName is None:
- newLayerName = DEFAULT_LAYER_NAME
- if newLayerName in self.layerContents:
- raise UFOLibError("A layer named %s already exists." % newLayerName)
- # make sure the default layer doesn't already exist
- if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values():
- raise UFOLibError("A default layer already exists.")
- # get the paths
- oldDirectory = self._findDirectoryForLayerName(layerName)
- if defaultLayer:
- newDirectory = DEFAULT_GLYPHS_DIRNAME
- else:
- existing = {name.lower() for name in self.layerContents.values()}
- newDirectory = userNameToFileName(
- newLayerName, existing=existing, prefix="glyphs."
- )
- # update the internal mapping
- del self.layerContents[layerName]
- self.layerContents[newLayerName] = newDirectory
- # do the file system copy
- self.fs.movedir(oldDirectory, newDirectory, create=True)
- def deleteGlyphSet(self, layerName):
- """
- Remove the glyph set matching layerName.
- """
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- # ignore deleting glyph sets for UFO1 UFO2 as there are no layers
- # just write the data from the default layer
- return
- foundDirectory = self._findDirectoryForLayerName(layerName)
- self.removePath(foundDirectory, removeEmptyParents=False)
- del self.layerContents[layerName]
- def writeData(self, fileName, data):
- """
- Write data to fileName in the 'data' directory.
- The data must be a bytes string.
- """
- self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data)
- def removeData(self, fileName):
- """
- Remove the file named fileName from the data directory.
- """
- self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}")
- # /images
- def writeImage(self, fileName, data, validate=None):
- """
- Write data to fileName in the images directory.
- The data must be a valid PNG.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- raise UFOLibError(
- f"Images are not allowed in UFO {self._formatVersion.major}."
- )
- fileName = fsdecode(fileName)
- if validate:
- valid, error = pngValidator(data=data)
- if not valid:
- raise UFOLibError(error)
- self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data)
- def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'?
- """
- Remove the file named fileName from the
- images directory.
- """
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- raise UFOLibError(
- f"Images are not allowed in UFO {self._formatVersion.major}."
- )
- self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}")
- def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None):
- """
- Copy the sourceFileName in the provided UFOReader to destFileName
- in this writer. This uses the most memory efficient method possible
- for copying the data possible.
- """
- if validate is None:
- validate = self._validate
- if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
- raise UFOLibError(
- f"Images are not allowed in UFO {self._formatVersion.major}."
- )
- sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}"
- destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}"
- self.copyFromReader(reader, sourcePath, destPath)
- def close(self):
- if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP:
- # if we are updating an existing zip file, we can now compress the
- # contents of the temporary filesystem in the destination path
- rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo"
- with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS:
- fs.copy.copy_fs(self.fs, destFS.makedir(rootDir))
- super().close()
- # just an alias, makes it more explicit
- UFOReaderWriter = UFOWriter
- # ----------------
- # Helper Functions
- # ----------------
- def _sniffFileStructure(ufo_path):
- """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str)
- is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a
- directory.
- Raise UFOLibError if it is a file with unknown structure, or if the path
- does not exist.
- """
- if zipfile.is_zipfile(ufo_path):
- return UFOFileStructure.ZIP
- elif os.path.isdir(ufo_path):
- return UFOFileStructure.PACKAGE
- elif os.path.isfile(ufo_path):
- raise UFOLibError(
- "The specified UFO does not have a known structure: '%s'" % ufo_path
- )
- else:
- raise UFOLibError("No such file or directory: '%s'" % ufo_path)
- def makeUFOPath(path):
- """
- Return a .ufo pathname.
- >>> makeUFOPath("directory/something.ext") == (
- ... os.path.join('directory', 'something.ufo'))
- True
- >>> makeUFOPath("directory/something.another.thing.ext") == (
- ... os.path.join('directory', 'something.another.thing.ufo'))
- True
- """
- dir, name = os.path.split(path)
- name = ".".join([".".join(name.split(".")[:-1]), "ufo"])
- return os.path.join(dir, name)
- # ----------------------
- # fontinfo.plist Support
- # ----------------------
- # Version Validators
- # There is no version 1 validator and there shouldn't be.
- # The version 1 spec was very loose and there were numerous
- # cases of invalid values.
- def validateFontInfoVersion2ValueForAttribute(attr, value):
- """
- This performs very basic validation of the value for attribute
- following the UFO 2 fontinfo.plist specification. The results
- of this should not be interpretted as *correct* for the font
- that they are part of. This merely indicates that the value
- is of the proper type and, where the specification defines
- a set range of possible values for an attribute, that the
- value is in the accepted range.
- """
- dataValidationDict = fontInfoAttributesVersion2ValueData[attr]
- valueType = dataValidationDict.get("type")
- validator = dataValidationDict.get("valueValidator")
- valueOptions = dataValidationDict.get("valueOptions")
- # have specific options for the validator
- if valueOptions is not None:
- isValidValue = validator(value, valueOptions)
- # no specific options
- else:
- if validator == genericTypeValidator:
- isValidValue = validator(value, valueType)
- else:
- isValidValue = validator(value)
- return isValidValue
- def validateInfoVersion2Data(infoData):
- """
- This performs very basic validation of the value for infoData
- following the UFO 2 fontinfo.plist specification. The results
- of this should not be interpretted as *correct* for the font
- that they are part of. This merely indicates that the values
- are of the proper type and, where the specification defines
- a set range of possible values for an attribute, that the
- value is in the accepted range.
- """
- validInfoData = {}
- for attr, value in list(infoData.items()):
- isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value)
- if not isValidValue:
- raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
- else:
- validInfoData[attr] = value
- return validInfoData
- def validateFontInfoVersion3ValueForAttribute(attr, value):
- """
- This performs very basic validation of the value for attribute
- following the UFO 3 fontinfo.plist specification. The results
- of this should not be interpretted as *correct* for the font
- that they are part of. This merely indicates that the value
- is of the proper type and, where the specification defines
- a set range of possible values for an attribute, that the
- value is in the accepted range.
- """
- dataValidationDict = fontInfoAttributesVersion3ValueData[attr]
- valueType = dataValidationDict.get("type")
- validator = dataValidationDict.get("valueValidator")
- valueOptions = dataValidationDict.get("valueOptions")
- # have specific options for the validator
- if valueOptions is not None:
- isValidValue = validator(value, valueOptions)
- # no specific options
- else:
- if validator == genericTypeValidator:
- isValidValue = validator(value, valueType)
- else:
- isValidValue = validator(value)
- return isValidValue
- def validateInfoVersion3Data(infoData):
- """
- This performs very basic validation of the value for infoData
- following the UFO 3 fontinfo.plist specification. The results
- of this should not be interpretted as *correct* for the font
- that they are part of. This merely indicates that the values
- are of the proper type and, where the specification defines
- a set range of possible values for an attribute, that the
- value is in the accepted range.
- """
- validInfoData = {}
- for attr, value in list(infoData.items()):
- isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value)
- if not isValidValue:
- raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
- else:
- validInfoData[attr] = value
- return validInfoData
- # Value Options
- fontInfoOpenTypeHeadFlagsOptions = list(range(0, 15))
- fontInfoOpenTypeOS2SelectionOptions = [1, 2, 3, 4, 7, 8, 9]
- fontInfoOpenTypeOS2UnicodeRangesOptions = list(range(0, 128))
- fontInfoOpenTypeOS2CodePageRangesOptions = list(range(0, 64))
- fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9]
- # Version Attribute Definitions
- # This defines the attributes, types and, in some
- # cases the possible values, that can exist is
- # fontinfo.plist.
- fontInfoAttributesVersion1 = {
- "familyName",
- "styleName",
- "fullName",
- "fontName",
- "menuName",
- "fontStyle",
- "note",
- "versionMajor",
- "versionMinor",
- "year",
- "copyright",
- "notice",
- "trademark",
- "license",
- "licenseURL",
- "createdBy",
- "designer",
- "designerURL",
- "vendorURL",
- "unitsPerEm",
- "ascender",
- "descender",
- "capHeight",
- "xHeight",
- "defaultWidth",
- "slantAngle",
- "italicAngle",
- "widthName",
- "weightName",
- "weightValue",
- "fondName",
- "otFamilyName",
- "otStyleName",
- "otMacName",
- "msCharSet",
- "fondID",
- "uniqueID",
- "ttVendor",
- "ttUniqueID",
- "ttVersion",
- }
- fontInfoAttributesVersion2ValueData = {
- "familyName": dict(type=str),
- "styleName": dict(type=str),
- "styleMapFamilyName": dict(type=str),
- "styleMapStyleName": dict(
- type=str, valueValidator=fontInfoStyleMapStyleNameValidator
- ),
- "versionMajor": dict(type=int),
- "versionMinor": dict(type=int),
- "year": dict(type=int),
- "copyright": dict(type=str),
- "trademark": dict(type=str),
- "unitsPerEm": dict(type=(int, float)),
- "descender": dict(type=(int, float)),
- "xHeight": dict(type=(int, float)),
- "capHeight": dict(type=(int, float)),
- "ascender": dict(type=(int, float)),
- "italicAngle": dict(type=(float, int)),
- "note": dict(type=str),
- "openTypeHeadCreated": dict(
- type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator
- ),
- "openTypeHeadLowestRecPPEM": dict(type=(int, float)),
- "openTypeHeadFlags": dict(
- type="integerList",
- valueValidator=genericIntListValidator,
- valueOptions=fontInfoOpenTypeHeadFlagsOptions,
- ),
- "openTypeHheaAscender": dict(type=(int, float)),
- "openTypeHheaDescender": dict(type=(int, float)),
- "openTypeHheaLineGap": dict(type=(int, float)),
- "openTypeHheaCaretSlopeRise": dict(type=int),
- "openTypeHheaCaretSlopeRun": dict(type=int),
- "openTypeHheaCaretOffset": dict(type=(int, float)),
- "openTypeNameDesigner": dict(type=str),
- "openTypeNameDesignerURL": dict(type=str),
- "openTypeNameManufacturer": dict(type=str),
- "openTypeNameManufacturerURL": dict(type=str),
- "openTypeNameLicense": dict(type=str),
- "openTypeNameLicenseURL": dict(type=str),
- "openTypeNameVersion": dict(type=str),
- "openTypeNameUniqueID": dict(type=str),
- "openTypeNameDescription": dict(type=str),
- "openTypeNamePreferredFamilyName": dict(type=str),
- "openTypeNamePreferredSubfamilyName": dict(type=str),
- "openTypeNameCompatibleFullName": dict(type=str),
- "openTypeNameSampleText": dict(type=str),
- "openTypeNameWWSFamilyName": dict(type=str),
- "openTypeNameWWSSubfamilyName": dict(type=str),
- "openTypeOS2WidthClass": dict(
- type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator
- ),
- "openTypeOS2WeightClass": dict(
- type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator
- ),
- "openTypeOS2Selection": dict(
- type="integerList",
- valueValidator=genericIntListValidator,
- valueOptions=fontInfoOpenTypeOS2SelectionOptions,
- ),
- "openTypeOS2VendorID": dict(type=str),
- "openTypeOS2Panose": dict(
- type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator
- ),
- "openTypeOS2FamilyClass": dict(
- type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator
- ),
- "openTypeOS2UnicodeRanges": dict(
- type="integerList",
- valueValidator=genericIntListValidator,
- valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions,
- ),
- "openTypeOS2CodePageRanges": dict(
- type="integerList",
- valueValidator=genericIntListValidator,
- valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions,
- ),
- "openTypeOS2TypoAscender": dict(type=(int, float)),
- "openTypeOS2TypoDescender": dict(type=(int, float)),
- "openTypeOS2TypoLineGap": dict(type=(int, float)),
- "openTypeOS2WinAscent": dict(type=(int, float)),
- "openTypeOS2WinDescent": dict(type=(int, float)),
- "openTypeOS2Type": dict(
- type="integerList",
- valueValidator=genericIntListValidator,
- valueOptions=fontInfoOpenTypeOS2TypeOptions,
- ),
- "openTypeOS2SubscriptXSize": dict(type=(int, float)),
- "openTypeOS2SubscriptYSize": dict(type=(int, float)),
- "openTypeOS2SubscriptXOffset": dict(type=(int, float)),
- "openTypeOS2SubscriptYOffset": dict(type=(int, float)),
- "openTypeOS2SuperscriptXSize": dict(type=(int, float)),
- "openTypeOS2SuperscriptYSize": dict(type=(int, float)),
- "openTypeOS2SuperscriptXOffset": dict(type=(int, float)),
- "openTypeOS2SuperscriptYOffset": dict(type=(int, float)),
- "openTypeOS2StrikeoutSize": dict(type=(int, float)),
- "openTypeOS2StrikeoutPosition": dict(type=(int, float)),
- "openTypeVheaVertTypoAscender": dict(type=(int, float)),
- "openTypeVheaVertTypoDescender": dict(type=(int, float)),
- "openTypeVheaVertTypoLineGap": dict(type=(int, float)),
- "openTypeVheaCaretSlopeRise": dict(type=int),
- "openTypeVheaCaretSlopeRun": dict(type=int),
- "openTypeVheaCaretOffset": dict(type=(int, float)),
- "postscriptFontName": dict(type=str),
- "postscriptFullName": dict(type=str),
- "postscriptSlantAngle": dict(type=(float, int)),
- "postscriptUniqueID": dict(type=int),
- "postscriptUnderlineThickness": dict(type=(int, float)),
- "postscriptUnderlinePosition": dict(type=(int, float)),
- "postscriptIsFixedPitch": dict(type=bool),
- "postscriptBlueValues": dict(
- type="integerList", valueValidator=fontInfoPostscriptBluesValidator
- ),
- "postscriptOtherBlues": dict(
- type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
- ),
- "postscriptFamilyBlues": dict(
- type="integerList", valueValidator=fontInfoPostscriptBluesValidator
- ),
- "postscriptFamilyOtherBlues": dict(
- type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
- ),
- "postscriptStemSnapH": dict(
- type="integerList", valueValidator=fontInfoPostscriptStemsValidator
- ),
- "postscriptStemSnapV": dict(
- type="integerList", valueValidator=fontInfoPostscriptStemsValidator
- ),
- "postscriptBlueFuzz": dict(type=(int, float)),
- "postscriptBlueShift": dict(type=(int, float)),
- "postscriptBlueScale": dict(type=(float, int)),
- "postscriptForceBold": dict(type=bool),
- "postscriptDefaultWidthX": dict(type=(int, float)),
- "postscriptNominalWidthX": dict(type=(int, float)),
- "postscriptWeightName": dict(type=str),
- "postscriptDefaultCharacter": dict(type=str),
- "postscriptWindowsCharacterSet": dict(
- type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator
- ),
- "macintoshFONDFamilyID": dict(type=int),
- "macintoshFONDName": dict(type=str),
- }
- fontInfoAttributesVersion2 = set(fontInfoAttributesVersion2ValueData.keys())
- fontInfoAttributesVersion3ValueData = deepcopy(fontInfoAttributesVersion2ValueData)
- fontInfoAttributesVersion3ValueData.update(
- {
- "versionMinor": dict(type=int, valueValidator=genericNonNegativeIntValidator),
- "unitsPerEm": dict(
- type=(int, float), valueValidator=genericNonNegativeNumberValidator
- ),
- "openTypeHeadLowestRecPPEM": dict(
- type=int, valueValidator=genericNonNegativeNumberValidator
- ),
- "openTypeHheaAscender": dict(type=int),
- "openTypeHheaDescender": dict(type=int),
- "openTypeHheaLineGap": dict(type=int),
- "openTypeHheaCaretOffset": dict(type=int),
- "openTypeOS2Panose": dict(
- type="integerList",
- valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator,
- ),
- "openTypeOS2TypoAscender": dict(type=int),
- "openTypeOS2TypoDescender": dict(type=int),
- "openTypeOS2TypoLineGap": dict(type=int),
- "openTypeOS2WinAscent": dict(
- type=int, valueValidator=genericNonNegativeNumberValidator
- ),
- "openTypeOS2WinDescent": dict(
- type=int, valueValidator=genericNonNegativeNumberValidator
- ),
- "openTypeOS2SubscriptXSize": dict(type=int),
- "openTypeOS2SubscriptYSize": dict(type=int),
- "openTypeOS2SubscriptXOffset": dict(type=int),
- "openTypeOS2SubscriptYOffset": dict(type=int),
- "openTypeOS2SuperscriptXSize": dict(type=int),
- "openTypeOS2SuperscriptYSize": dict(type=int),
- "openTypeOS2SuperscriptXOffset": dict(type=int),
- "openTypeOS2SuperscriptYOffset": dict(type=int),
- "openTypeOS2StrikeoutSize": dict(type=int),
- "openTypeOS2StrikeoutPosition": dict(type=int),
- "openTypeGaspRangeRecords": dict(
- type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator
- ),
- "openTypeNameRecords": dict(
- type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator
- ),
- "openTypeVheaVertTypoAscender": dict(type=int),
- "openTypeVheaVertTypoDescender": dict(type=int),
- "openTypeVheaVertTypoLineGap": dict(type=int),
- "openTypeVheaCaretOffset": dict(type=int),
- "woffMajorVersion": dict(
- type=int, valueValidator=genericNonNegativeIntValidator
- ),
- "woffMinorVersion": dict(
- type=int, valueValidator=genericNonNegativeIntValidator
- ),
- "woffMetadataUniqueID": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator
- ),
- "woffMetadataVendor": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator
- ),
- "woffMetadataCredits": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator
- ),
- "woffMetadataDescription": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator
- ),
- "woffMetadataLicense": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator
- ),
- "woffMetadataCopyright": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator
- ),
- "woffMetadataTrademark": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator
- ),
- "woffMetadataLicensee": dict(
- type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator
- ),
- "woffMetadataExtensions": dict(
- type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator
- ),
- "guidelines": dict(type=list, valueValidator=guidelinesValidator),
- }
- )
- fontInfoAttributesVersion3 = set(fontInfoAttributesVersion3ValueData.keys())
- # insert the type validator for all attrs that
- # have no defined validator.
- for attr, dataDict in list(fontInfoAttributesVersion2ValueData.items()):
- if "valueValidator" not in dataDict:
- dataDict["valueValidator"] = genericTypeValidator
- for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()):
- if "valueValidator" not in dataDict:
- dataDict["valueValidator"] = genericTypeValidator
- # Version Conversion Support
- # These are used from converting from version 1
- # to version 2 or vice-versa.
- def _flipDict(d):
- flipped = {}
- for key, value in list(d.items()):
- flipped[value] = key
- return flipped
- fontInfoAttributesVersion1To2 = {
- "menuName": "styleMapFamilyName",
- "designer": "openTypeNameDesigner",
- "designerURL": "openTypeNameDesignerURL",
- "createdBy": "openTypeNameManufacturer",
- "vendorURL": "openTypeNameManufacturerURL",
- "license": "openTypeNameLicense",
- "licenseURL": "openTypeNameLicenseURL",
- "ttVersion": "openTypeNameVersion",
- "ttUniqueID": "openTypeNameUniqueID",
- "notice": "openTypeNameDescription",
- "otFamilyName": "openTypeNamePreferredFamilyName",
- "otStyleName": "openTypeNamePreferredSubfamilyName",
- "otMacName": "openTypeNameCompatibleFullName",
- "weightName": "postscriptWeightName",
- "weightValue": "openTypeOS2WeightClass",
- "ttVendor": "openTypeOS2VendorID",
- "uniqueID": "postscriptUniqueID",
- "fontName": "postscriptFontName",
- "fondID": "macintoshFONDFamilyID",
- "fondName": "macintoshFONDName",
- "defaultWidth": "postscriptDefaultWidthX",
- "slantAngle": "postscriptSlantAngle",
- "fullName": "postscriptFullName",
- # require special value conversion
- "fontStyle": "styleMapStyleName",
- "widthName": "openTypeOS2WidthClass",
- "msCharSet": "postscriptWindowsCharacterSet",
- }
- fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2)
- deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys())
- _fontStyle1To2 = {64: "regular", 1: "italic", 32: "bold", 33: "bold italic"}
- _fontStyle2To1 = _flipDict(_fontStyle1To2)
- # Some UFO 1 files have 0
- _fontStyle1To2[0] = "regular"
- _widthName1To2 = {
- "Ultra-condensed": 1,
- "Extra-condensed": 2,
- "Condensed": 3,
- "Semi-condensed": 4,
- "Medium (normal)": 5,
- "Semi-expanded": 6,
- "Expanded": 7,
- "Extra-expanded": 8,
- "Ultra-expanded": 9,
- }
- _widthName2To1 = _flipDict(_widthName1To2)
- # FontLab's default width value is "Normal".
- # Many format version 1 UFOs will have this.
- _widthName1To2["Normal"] = 5
- # FontLab has an "All" width value. In UFO 1
- # move this up to "Normal".
- _widthName1To2["All"] = 5
- # "medium" appears in a lot of UFO 1 files.
- _widthName1To2["medium"] = 5
- # "Medium" appears in a lot of UFO 1 files.
- _widthName1To2["Medium"] = 5
- _msCharSet1To2 = {
- 0: 1,
- 1: 2,
- 2: 3,
- 77: 4,
- 128: 5,
- 129: 6,
- 130: 7,
- 134: 8,
- 136: 9,
- 161: 10,
- 162: 11,
- 163: 12,
- 177: 13,
- 178: 14,
- 186: 15,
- 200: 16,
- 204: 17,
- 222: 18,
- 238: 19,
- 255: 20,
- }
- _msCharSet2To1 = _flipDict(_msCharSet1To2)
- # 1 <-> 2
- def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value):
- """
- Convert value from version 1 to version 2 format.
- Returns the new attribute name and the converted value.
- If the value is None, None will be returned for the new value.
- """
- # convert floats to ints if possible
- if isinstance(value, float):
- if int(value) == value:
- value = int(value)
- if value is not None:
- if attr == "fontStyle":
- v = _fontStyle1To2.get(value)
- if v is None:
- raise UFOLibError(
- f"Cannot convert value ({value!r}) for attribute {attr}."
- )
- value = v
- elif attr == "widthName":
- v = _widthName1To2.get(value)
- if v is None:
- raise UFOLibError(
- f"Cannot convert value ({value!r}) for attribute {attr}."
- )
- value = v
- elif attr == "msCharSet":
- v = _msCharSet1To2.get(value)
- if v is None:
- raise UFOLibError(
- f"Cannot convert value ({value!r}) for attribute {attr}."
- )
- value = v
- attr = fontInfoAttributesVersion1To2.get(attr, attr)
- return attr, value
- def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value):
- """
- Convert value from version 2 to version 1 format.
- Returns the new attribute name and the converted value.
- If the value is None, None will be returned for the new value.
- """
- if value is not None:
- if attr == "styleMapStyleName":
- value = _fontStyle2To1.get(value)
- elif attr == "openTypeOS2WidthClass":
- value = _widthName2To1.get(value)
- elif attr == "postscriptWindowsCharacterSet":
- value = _msCharSet2To1.get(value)
- attr = fontInfoAttributesVersion2To1.get(attr, attr)
- return attr, value
- def _convertFontInfoDataVersion1ToVersion2(data):
- converted = {}
- for attr, value in list(data.items()):
- # FontLab gives -1 for the weightValue
- # for fonts wil no defined value. Many
- # format version 1 UFOs will have this.
- if attr == "weightValue" and value == -1:
- continue
- newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2(
- attr, value
- )
- # skip if the attribute is not part of version 2
- if newAttr not in fontInfoAttributesVersion2:
- continue
- # catch values that can't be converted
- if value is None:
- raise UFOLibError(
- f"Cannot convert value ({value!r}) for attribute {newAttr}."
- )
- # store
- converted[newAttr] = newValue
- return converted
- def _convertFontInfoDataVersion2ToVersion1(data):
- converted = {}
- for attr, value in list(data.items()):
- newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1(
- attr, value
- )
- # only take attributes that are registered for version 1
- if newAttr not in fontInfoAttributesVersion1:
- continue
- # catch values that can't be converted
- if value is None:
- raise UFOLibError(
- f"Cannot convert value ({value!r}) for attribute {newAttr}."
- )
- # store
- converted[newAttr] = newValue
- return converted
- # 2 <-> 3
- _ufo2To3NonNegativeInt = {
- "versionMinor",
- "openTypeHeadLowestRecPPEM",
- "openTypeOS2WinAscent",
- "openTypeOS2WinDescent",
- }
- _ufo2To3NonNegativeIntOrFloat = {
- "unitsPerEm",
- }
- _ufo2To3FloatToInt = {
- "openTypeHeadLowestRecPPEM",
- "openTypeHheaAscender",
- "openTypeHheaDescender",
- "openTypeHheaLineGap",
- "openTypeHheaCaretOffset",
- "openTypeOS2TypoAscender",
- "openTypeOS2TypoDescender",
- "openTypeOS2TypoLineGap",
- "openTypeOS2WinAscent",
- "openTypeOS2WinDescent",
- "openTypeOS2SubscriptXSize",
- "openTypeOS2SubscriptYSize",
- "openTypeOS2SubscriptXOffset",
- "openTypeOS2SubscriptYOffset",
- "openTypeOS2SuperscriptXSize",
- "openTypeOS2SuperscriptYSize",
- "openTypeOS2SuperscriptXOffset",
- "openTypeOS2SuperscriptYOffset",
- "openTypeOS2StrikeoutSize",
- "openTypeOS2StrikeoutPosition",
- "openTypeVheaVertTypoAscender",
- "openTypeVheaVertTypoDescender",
- "openTypeVheaVertTypoLineGap",
- "openTypeVheaCaretOffset",
- }
- def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value):
- """
- Convert value from version 2 to version 3 format.
- Returns the new attribute name and the converted value.
- If the value is None, None will be returned for the new value.
- """
- if attr in _ufo2To3FloatToInt:
- try:
- value = round(value)
- except (ValueError, TypeError):
- raise UFOLibError("Could not convert value for %s." % attr)
- if attr in _ufo2To3NonNegativeInt:
- try:
- value = int(abs(value))
- except (ValueError, TypeError):
- raise UFOLibError("Could not convert value for %s." % attr)
- elif attr in _ufo2To3NonNegativeIntOrFloat:
- try:
- v = float(abs(value))
- except (ValueError, TypeError):
- raise UFOLibError("Could not convert value for %s." % attr)
- if v == int(v):
- v = int(v)
- if v != value:
- value = v
- return attr, value
- def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value):
- """
- Convert value from version 3 to version 2 format.
- Returns the new attribute name and the converted value.
- If the value is None, None will be returned for the new value.
- """
- return attr, value
- def _convertFontInfoDataVersion3ToVersion2(data):
- converted = {}
- for attr, value in list(data.items()):
- newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2(
- attr, value
- )
- if newAttr not in fontInfoAttributesVersion2:
- continue
- converted[newAttr] = newValue
- return converted
- def _convertFontInfoDataVersion2ToVersion3(data):
- converted = {}
- for attr, value in list(data.items()):
- attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3(
- attr, value
- )
- converted[attr] = value
- return converted
- if __name__ == "__main__":
- import doctest
- doctest.testmod()
|