__init__.py 97 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575
  1. """
  2. A library for importing .ufo files and their descendants.
  3. Refer to http://unifiedfontobject.org for the UFO specification.
  4. The main interfaces are the :class:`.UFOReader` and :class:`.UFOWriter`
  5. classes, which support versions 1, 2, and 3 of the UFO specification.
  6. Set variables are available for external use that list the font
  7. info attribute names for the `fontinfo.plist` formats. These are:
  8. - :obj:`.fontInfoAttributesVersion1`
  9. - :obj:`.fontInfoAttributesVersion2`
  10. - :obj:`.fontInfoAttributesVersion3`
  11. A set listing the `fontinfo.plist` attributes that were deprecated
  12. in version 2 is available for external use:
  13. - :obj:`.deprecatedFontInfoAttributesVersion2`
  14. Functions that do basic validation on values for `fontinfo.plist`
  15. are available for external use. These are
  16. - :func:`.validateFontInfoVersion2ValueForAttribute`
  17. - :func:`.validateFontInfoVersion3ValueForAttribute`
  18. Value conversion functions are available for converting
  19. `fontinfo.plist` values between the possible format versions.
  20. - :func:`.convertFontInfoValueForAttributeFromVersion1ToVersion2`
  21. - :func:`.convertFontInfoValueForAttributeFromVersion2ToVersion1`
  22. - :func:`.convertFontInfoValueForAttributeFromVersion2ToVersion3`
  23. - :func:`.convertFontInfoValueForAttributeFromVersion3ToVersion2`
  24. """
  25. from __future__ import annotations
  26. import enum
  27. import logging
  28. import os
  29. import zipfile
  30. from collections import OrderedDict
  31. from copy import deepcopy
  32. from os import fsdecode
  33. from typing import IO, TYPE_CHECKING, Any, Optional, Union, cast
  34. from fontTools.misc import filesystem as fs
  35. from fontTools.misc import plistlib
  36. from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning
  37. from fontTools.ufoLib.errors import UFOLibError
  38. from fontTools.ufoLib.filenames import userNameToFileName
  39. from fontTools.ufoLib.utils import (
  40. BaseFormatVersion,
  41. normalizeFormatVersion,
  42. numberTypes,
  43. )
  44. from fontTools.ufoLib.validators import *
  45. if TYPE_CHECKING:
  46. from logging import Logger
  47. from fontTools.annotations import (
  48. GlyphNameToFileNameFunc,
  49. K,
  50. KerningDict,
  51. KerningGroups,
  52. KerningNested,
  53. PathOrFS,
  54. PathStr,
  55. UFOFormatVersionInput,
  56. V,
  57. )
  58. from fontTools.misc.filesystem._base import FS
  59. from fontTools.ufoLib.glifLib import GlyphSet
  60. KerningGroupRenameMaps = dict[str, dict[str, str]]
  61. LibDict = dict[str, Any]
  62. LayerOrderList = Optional[list[Optional[str]]]
  63. AttributeDataDict = dict[str, Any]
  64. FontInfoAttributes = dict[str, AttributeDataDict]
  65. # client code can check this to see if the upstream `fs` package is being used
  66. haveFS = fs._haveFS
  67. __all__: list[str] = [
  68. "haveFS",
  69. "makeUFOPath",
  70. "UFOLibError",
  71. "UFOReader",
  72. "UFOWriter",
  73. "UFOReaderWriter",
  74. "UFOFileStructure",
  75. "fontInfoAttributesVersion1",
  76. "fontInfoAttributesVersion2",
  77. "fontInfoAttributesVersion3",
  78. "deprecatedFontInfoAttributesVersion2",
  79. "validateFontInfoVersion2ValueForAttribute",
  80. "validateFontInfoVersion3ValueForAttribute",
  81. "convertFontInfoValueForAttributeFromVersion1ToVersion2",
  82. "convertFontInfoValueForAttributeFromVersion2ToVersion1",
  83. ]
  84. __version__: str = "3.0.0"
  85. logger: Logger = logging.getLogger(__name__)
  86. # ---------
  87. # Constants
  88. # ---------
  89. DEFAULT_GLYPHS_DIRNAME: str = "glyphs"
  90. DATA_DIRNAME: str = "data"
  91. IMAGES_DIRNAME: str = "images"
  92. METAINFO_FILENAME: str = "metainfo.plist"
  93. FONTINFO_FILENAME: str = "fontinfo.plist"
  94. LIB_FILENAME: str = "lib.plist"
  95. GROUPS_FILENAME: str = "groups.plist"
  96. KERNING_FILENAME: str = "kerning.plist"
  97. FEATURES_FILENAME: str = "features.fea"
  98. LAYERCONTENTS_FILENAME: str = "layercontents.plist"
  99. LAYERINFO_FILENAME: str = "layerinfo.plist"
  100. DEFAULT_LAYER_NAME: str = "public.default"
  101. class UFOFormatVersion(BaseFormatVersion):
  102. FORMAT_1_0 = (1, 0)
  103. FORMAT_2_0 = (2, 0)
  104. FORMAT_3_0 = (3, 0)
  105. class UFOFileStructure(enum.Enum):
  106. ZIP = "zip"
  107. PACKAGE = "package"
  108. # --------------
  109. # Shared Methods
  110. # --------------
  111. class _UFOBaseIO:
  112. if TYPE_CHECKING:
  113. fs: FS
  114. _havePreviousFile: bool
  115. def getFileModificationTime(self, path: PathStr) -> Optional[float]:
  116. """
  117. Returns the modification time for the file at the given path, as a
  118. floating point number giving the number of seconds since the epoch.
  119. The path must be relative to the UFO path.
  120. Returns None if the file does not exist.
  121. """
  122. try:
  123. dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified
  124. except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound):
  125. return None
  126. else:
  127. if dt is not None:
  128. return dt.timestamp()
  129. return None
  130. def _getPlist(self, fileName: str, default: Optional[Any] = None) -> Any:
  131. """
  132. Read a property list relative to the UFO filesystem's root.
  133. Raises UFOLibError if the file is missing and default is None,
  134. otherwise default is returned.
  135. The errors that could be raised during the reading of a plist are
  136. unpredictable and/or too large to list, so, a blind try: except:
  137. is done. If an exception occurs, a UFOLibError will be raised.
  138. """
  139. try:
  140. with self.fs.open(fileName, "rb") as f:
  141. return plistlib.load(f)
  142. except fs.errors.ResourceNotFound:
  143. if default is None:
  144. raise UFOLibError(
  145. "'%s' is missing on %s. This file is required" % (fileName, self.fs)
  146. )
  147. else:
  148. return default
  149. except Exception as e:
  150. # TODO(anthrotype): try to narrow this down a little
  151. raise UFOLibError(f"'{fileName}' could not be read on {self.fs}: {e}")
  152. def _writePlist(self, fileName: str, obj: Any) -> None:
  153. """
  154. Write a property list to a file relative to the UFO filesystem's root.
  155. Do this sort of atomically, making it harder to corrupt existing files,
  156. for example when plistlib encounters an error halfway during write.
  157. This also checks to see if text matches the text that is already in the
  158. file at path. If so, the file is not rewritten so that the modification
  159. date is preserved.
  160. The errors that could be raised during the writing of a plist are
  161. unpredictable and/or too large to list, so, a blind try: except: is done.
  162. If an exception occurs, a UFOLibError will be raised.
  163. """
  164. if self._havePreviousFile:
  165. try:
  166. data = plistlib.dumps(obj)
  167. except Exception as e:
  168. raise UFOLibError(
  169. "'%s' could not be written on %s because "
  170. "the data is not properly formatted: %s" % (fileName, self.fs, e)
  171. )
  172. if self.fs.exists(fileName) and data == self.fs.readbytes(fileName):
  173. return
  174. self.fs.writebytes(fileName, data)
  175. else:
  176. with self.fs.open(fileName, mode="wb") as fp:
  177. try:
  178. plistlib.dump(obj, fp)
  179. except Exception as e:
  180. raise UFOLibError(
  181. "'%s' could not be written on %s because "
  182. "the data is not properly formatted: %s"
  183. % (fileName, self.fs, e)
  184. )
  185. # ----------
  186. # UFO Reader
  187. # ----------
  188. class UFOReader(_UFOBaseIO):
  189. """Read the various components of a .ufo.
  190. Attributes:
  191. path: An :class:`os.PathLike` object pointing to the .ufo.
  192. validate: A boolean indicating if the data read should be
  193. validated. Defaults to `True`.
  194. By default read data is validated. Set ``validate`` to
  195. ``False`` to not validate the data.
  196. """
  197. def __init__(self, path: PathOrFS, validate: bool = True) -> None:
  198. # Only call __fspath__ if path is not already a str or FS object
  199. if not isinstance(path, (str, fs.base.FS)) and hasattr(path, "__fspath__"):
  200. path = path.__fspath__()
  201. if isinstance(path, str):
  202. structure = _sniffFileStructure(path)
  203. parentFS: FS
  204. try:
  205. if structure is UFOFileStructure.ZIP:
  206. parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8") # type: ignore[abstract]
  207. else:
  208. parentFS = fs.osfs.OSFS(path)
  209. except fs.errors.CreateFailed as e:
  210. raise UFOLibError(f"unable to open '{path}': {e}")
  211. if structure is UFOFileStructure.ZIP:
  212. # .ufoz zip files must contain a single root directory, with arbitrary
  213. # name, containing all the UFO files
  214. rootDirs = [
  215. p.name
  216. for p in parentFS.scandir("/")
  217. # exclude macOS metadata contained in zip file
  218. if p.is_dir and p.name != "__MACOSX"
  219. ]
  220. if len(rootDirs) == 1:
  221. # 'ClosingSubFS' ensures that the parent zip file is closed when
  222. # its root subdirectory is closed
  223. self.fs: FS = parentFS.opendir(
  224. rootDirs[0], factory=fs.subfs.ClosingSubFS
  225. )
  226. else:
  227. raise UFOLibError(
  228. "Expected exactly 1 root directory, found %d" % len(rootDirs)
  229. )
  230. else:
  231. # normal UFO 'packages' are just a single folder
  232. self.fs = parentFS
  233. # when passed a path string, we make sure we close the newly opened fs
  234. # upon calling UFOReader.close method or context manager's __exit__
  235. self._shouldClose: bool = True
  236. self._fileStructure = structure
  237. elif isinstance(path, fs.base.FS):
  238. filesystem: FS = path
  239. try:
  240. filesystem.check()
  241. except fs.errors.FilesystemClosed:
  242. raise UFOLibError("the filesystem '%s' is closed" % path)
  243. else:
  244. self.fs = filesystem
  245. try:
  246. path = filesystem.getsyspath("/")
  247. except fs.errors.NoSysPath:
  248. # network or in-memory FS may not map to the local one
  249. path = str(filesystem)
  250. # when user passed an already initialized fs instance, it is her
  251. # responsibility to close it, thus UFOReader.close/__exit__ are no-op
  252. self._shouldClose = False
  253. # default to a 'package' structure
  254. self._fileStructure = UFOFileStructure.PACKAGE
  255. else:
  256. raise TypeError(
  257. "Expected a path string or fs.base.FS object, found '%s'"
  258. % type(path).__name__
  259. )
  260. self._path: str = fsdecode(path)
  261. self._validate: bool = validate
  262. self._upConvertedKerningData: Optional[dict[str, Any]] = None
  263. try:
  264. self.readMetaInfo(validate=validate)
  265. except UFOLibError:
  266. self.close()
  267. raise
  268. # properties
  269. def _get_path(self) -> str:
  270. import warnings
  271. warnings.warn(
  272. "The 'path' attribute is deprecated; use the 'fs' attribute instead",
  273. DeprecationWarning,
  274. stacklevel=2,
  275. )
  276. return self._path
  277. path: property = property(_get_path, doc="The path of the UFO (DEPRECATED).")
  278. def _get_formatVersion(self) -> int:
  279. import warnings
  280. warnings.warn(
  281. "The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'",
  282. DeprecationWarning,
  283. stacklevel=2,
  284. )
  285. return self._formatVersion.major
  286. formatVersion = property(
  287. _get_formatVersion,
  288. doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple",
  289. )
  290. @property
  291. def formatVersionTuple(self) -> tuple[int, int]:
  292. """The (major, minor) format version of the UFO.
  293. This is determined by reading metainfo.plist during __init__.
  294. """
  295. return self._formatVersion
  296. def _get_fileStructure(self) -> Any:
  297. return self._fileStructure
  298. fileStructure: property = property(
  299. _get_fileStructure,
  300. doc=(
  301. "The file structure of the UFO: "
  302. "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE"
  303. ),
  304. )
  305. # up conversion
  306. def _upConvertKerning(self, validate: bool) -> None:
  307. """
  308. Up convert kerning and groups in UFO 1 and 2.
  309. The data will be held internally until each bit of data
  310. has been retrieved. The conversion of both must be done
  311. at once, so the raw data is cached and an error is raised
  312. if one bit of data becomes obsolete before it is called.
  313. ``validate`` will validate the data.
  314. """
  315. if self._upConvertedKerningData:
  316. testKerning = self._readKerning()
  317. if testKerning != self._upConvertedKerningData["originalKerning"]:
  318. raise UFOLibError(
  319. "The data in kerning.plist has been modified since it was converted to UFO 3 format."
  320. )
  321. testGroups = self._readGroups()
  322. if testGroups != self._upConvertedKerningData["originalGroups"]:
  323. raise UFOLibError(
  324. "The data in groups.plist has been modified since it was converted to UFO 3 format."
  325. )
  326. else:
  327. groups = self._readGroups()
  328. if validate:
  329. invalidFormatMessage = "groups.plist is not properly formatted."
  330. if not isinstance(groups, dict):
  331. raise UFOLibError(invalidFormatMessage)
  332. for groupName, glyphList in groups.items():
  333. if not isinstance(groupName, str):
  334. raise UFOLibError(invalidFormatMessage)
  335. elif not isinstance(glyphList, list):
  336. raise UFOLibError(invalidFormatMessage)
  337. for glyphName in glyphList:
  338. if not isinstance(glyphName, str):
  339. raise UFOLibError(invalidFormatMessage)
  340. self._upConvertedKerningData = dict(
  341. kerning={},
  342. originalKerning=self._readKerning(),
  343. groups={},
  344. originalGroups=groups,
  345. )
  346. # convert kerning and groups
  347. kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning(
  348. self._upConvertedKerningData["originalKerning"],
  349. deepcopy(self._upConvertedKerningData["originalGroups"]),
  350. self.getGlyphSet(),
  351. )
  352. # store
  353. self._upConvertedKerningData["kerning"] = kerning
  354. self._upConvertedKerningData["groups"] = groups
  355. self._upConvertedKerningData["groupRenameMaps"] = conversionMaps
  356. # support methods
  357. def readBytesFromPath(self, path: PathStr) -> Optional[bytes]:
  358. """
  359. Returns the bytes in the file at the given path.
  360. The path must be relative to the UFO's filesystem root.
  361. Returns None if the file does not exist.
  362. """
  363. try:
  364. return self.fs.readbytes(fsdecode(path))
  365. except fs.errors.ResourceNotFound:
  366. return None
  367. def getReadFileForPath(
  368. self, path: PathStr, encoding: Optional[str] = None
  369. ) -> Optional[Union[IO[bytes], IO[str]]]:
  370. """
  371. Returns a file (or file-like) object for the file at the given path.
  372. The path must be relative to the UFO path.
  373. Returns None if the file does not exist.
  374. By default the file is opened in binary mode (reads bytes).
  375. If encoding is passed, the file is opened in text mode (reads str).
  376. Note: The caller is responsible for closing the open file.
  377. """
  378. path = fsdecode(path)
  379. try:
  380. if encoding is None:
  381. return self.fs.open(path, mode="rb")
  382. else:
  383. return self.fs.open(path, mode="r", encoding=encoding)
  384. except fs.errors.ResourceNotFound:
  385. return None
  386. # metainfo.plist
  387. def _readMetaInfo(self, validate: Optional[bool] = None) -> dict[str, Any]:
  388. """
  389. Read metainfo.plist and return raw data. Only used for internal operations.
  390. ``validate`` will validate the read data, by default it is set
  391. to the class's validate value, can be overridden.
  392. """
  393. if validate is None:
  394. validate = self._validate
  395. data = self._getPlist(METAINFO_FILENAME)
  396. if validate and not isinstance(data, dict):
  397. raise UFOLibError("metainfo.plist is not properly formatted.")
  398. try:
  399. formatVersionMajor = data["formatVersion"]
  400. except KeyError:
  401. raise UFOLibError(
  402. f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}"
  403. )
  404. formatVersionMinor = data.setdefault("formatVersionMinor", 0)
  405. try:
  406. formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor))
  407. except ValueError as e:
  408. unsupportedMsg = (
  409. f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) "
  410. f"in '{METAINFO_FILENAME}' on {self.fs}"
  411. )
  412. if validate:
  413. from fontTools.ufoLib.errors import UnsupportedUFOFormat
  414. raise UnsupportedUFOFormat(unsupportedMsg) from e
  415. formatVersion = UFOFormatVersion.default()
  416. logger.warning(
  417. "%s. Assuming the latest supported version (%s). "
  418. "Some data may be skipped or parsed incorrectly",
  419. unsupportedMsg,
  420. formatVersion,
  421. )
  422. data["formatVersionTuple"] = formatVersion
  423. return data
  424. def readMetaInfo(self, validate: Optional[bool] = None) -> None:
  425. """
  426. Read metainfo.plist and set formatVersion. Only used for internal operations.
  427. ``validate`` will validate the read data, by default it is set
  428. to the class's validate value, can be overridden.
  429. """
  430. data = self._readMetaInfo(validate=validate)
  431. self._formatVersion = data["formatVersionTuple"]
  432. # groups.plist
  433. def _readGroups(self) -> dict[str, list[str]]:
  434. groups = self._getPlist(GROUPS_FILENAME, {})
  435. # remove any duplicate glyphs in a kerning group
  436. for groupName, glyphList in groups.items():
  437. if groupName.startswith(("public.kern1.", "public.kern2.")):
  438. groups[groupName] = list(OrderedDict.fromkeys(glyphList))
  439. return groups
  440. def readGroups(self, validate: Optional[bool] = None) -> dict[str, list[str]]:
  441. """
  442. Read groups.plist. Returns a dict.
  443. ``validate`` will validate the read data, by default it is set to the
  444. class's validate value, can be overridden.
  445. """
  446. if validate is None:
  447. validate = self._validate
  448. # handle up conversion
  449. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  450. self._upConvertKerning(validate)
  451. groups = cast(dict, self._upConvertedKerningData)["groups"]
  452. # normal
  453. else:
  454. groups = self._readGroups()
  455. if validate:
  456. valid, message = groupsValidator(groups)
  457. if not valid:
  458. raise UFOLibError(message)
  459. return groups
  460. def getKerningGroupConversionRenameMaps(
  461. self, validate: Optional[bool] = None
  462. ) -> KerningGroupRenameMaps:
  463. """
  464. Get maps defining the renaming that was done during any
  465. needed kerning group conversion. This method returns a
  466. dictionary of this form::
  467. {
  468. "side1" : {"old group name" : "new group name"},
  469. "side2" : {"old group name" : "new group name"}
  470. }
  471. When no conversion has been performed, the side1 and side2
  472. dictionaries will be empty.
  473. ``validate`` will validate the groups, by default it is set to the
  474. class's validate value, can be overridden.
  475. """
  476. if validate is None:
  477. validate = self._validate
  478. if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
  479. return dict(side1={}, side2={})
  480. # use the public group reader to force the load and
  481. # conversion of the data if it hasn't happened yet.
  482. self.readGroups(validate=validate)
  483. return cast(dict, self._upConvertedKerningData)["groupRenameMaps"]
  484. # fontinfo.plist
  485. def _readInfo(self, validate: bool) -> dict[str, Any]:
  486. data = self._getPlist(FONTINFO_FILENAME, {})
  487. if validate and not isinstance(data, dict):
  488. raise UFOLibError("fontinfo.plist is not properly formatted.")
  489. return data
  490. def readInfo(self, info: Any, validate: Optional[bool] = None) -> None:
  491. """
  492. Read fontinfo.plist. It requires an object that allows
  493. setting attributes with names that follow the fontinfo.plist
  494. version 3 specification. This will write the attributes
  495. defined in the file into the object.
  496. ``validate`` will validate the read data, by default it is set to the
  497. class's validate value, can be overridden.
  498. """
  499. if validate is None:
  500. validate = self._validate
  501. infoDict = self._readInfo(validate)
  502. infoDataToSet = {}
  503. # version 1
  504. if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
  505. for attr in fontInfoAttributesVersion1:
  506. value = infoDict.get(attr)
  507. if value is not None:
  508. infoDataToSet[attr] = value
  509. infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet)
  510. infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
  511. # version 2
  512. elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
  513. for attr, dataValidationDict in list(
  514. fontInfoAttributesVersion2ValueData.items()
  515. ):
  516. value = infoDict.get(attr)
  517. if value is None:
  518. continue
  519. infoDataToSet[attr] = value
  520. infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
  521. # version 3.x
  522. elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
  523. for attr, dataValidationDict in list(
  524. fontInfoAttributesVersion3ValueData.items()
  525. ):
  526. value = infoDict.get(attr)
  527. if value is None:
  528. continue
  529. infoDataToSet[attr] = value
  530. # unsupported version
  531. else:
  532. raise NotImplementedError(self._formatVersion)
  533. # validate data
  534. if validate:
  535. infoDataToSet = validateInfoVersion3Data(infoDataToSet)
  536. # populate the object
  537. for attr, value in list(infoDataToSet.items()):
  538. try:
  539. setattr(info, attr, value)
  540. except AttributeError:
  541. raise UFOLibError(
  542. "The supplied info object does not support setting a necessary attribute (%s)."
  543. % attr
  544. )
  545. # kerning.plist
  546. def _readKerning(self) -> KerningNested:
  547. data = self._getPlist(KERNING_FILENAME, {})
  548. return data
  549. def readKerning(self, validate: Optional[bool] = None) -> KerningDict:
  550. """
  551. Read kerning.plist. Returns a dict.
  552. ``validate`` will validate the kerning data, by default it is set to the
  553. class's validate value, can be overridden.
  554. """
  555. if validate is None:
  556. validate = self._validate
  557. # handle up conversion
  558. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  559. self._upConvertKerning(validate)
  560. kerningNested = cast(dict, self._upConvertedKerningData)["kerning"]
  561. # normal
  562. else:
  563. kerningNested = self._readKerning()
  564. if validate:
  565. valid, message = kerningValidator(kerningNested)
  566. if not valid:
  567. raise UFOLibError(message)
  568. # flatten
  569. kerning = {}
  570. for left in kerningNested:
  571. for right in kerningNested[left]:
  572. value = kerningNested[left][right]
  573. kerning[left, right] = value
  574. return kerning
  575. # lib.plist
  576. def readLib(self, validate: Optional[bool] = None) -> dict[str, Any]:
  577. """
  578. Read lib.plist. Returns a dict.
  579. ``validate`` will validate the data, by default it is set to the
  580. class's validate value, can be overridden.
  581. """
  582. if validate is None:
  583. validate = self._validate
  584. data = self._getPlist(LIB_FILENAME, {})
  585. if validate:
  586. valid, message = fontLibValidator(data)
  587. if not valid:
  588. raise UFOLibError(message)
  589. return data
  590. # features.fea
  591. def readFeatures(self) -> str:
  592. """
  593. Read features.fea. Return a string.
  594. The returned string is empty if the file is missing.
  595. """
  596. try:
  597. with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8-sig") as f:
  598. return f.read()
  599. except fs.errors.ResourceNotFound:
  600. return ""
  601. # glyph sets & layers
  602. def _readLayerContents(self, validate: bool) -> list[tuple[str, str]]:
  603. """
  604. Rebuild the layer contents list by checking what glyphsets
  605. are available on disk.
  606. ``validate`` will validate the layer contents.
  607. """
  608. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  609. return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)]
  610. contents = self._getPlist(LAYERCONTENTS_FILENAME)
  611. if validate:
  612. valid, error = layerContentsValidator(contents, self.fs)
  613. if not valid:
  614. raise UFOLibError(error)
  615. return contents
  616. def getLayerNames(self, validate: Optional[bool] = None) -> list[str]:
  617. """
  618. Get the ordered layer names from layercontents.plist.
  619. ``validate`` will validate the data, by default it is set to the
  620. class's validate value, can be overridden.
  621. """
  622. if validate is None:
  623. validate = self._validate
  624. layerContents = self._readLayerContents(validate)
  625. layerNames = [layerName for layerName, directoryName in layerContents]
  626. return layerNames
  627. def getDefaultLayerName(self, validate: Optional[bool] = None) -> str:
  628. """
  629. Get the default layer name from layercontents.plist.
  630. ``validate`` will validate the data, by default it is set to the
  631. class's validate value, can be overridden.
  632. """
  633. if validate is None:
  634. validate = self._validate
  635. layerContents = self._readLayerContents(validate)
  636. for layerName, layerDirectory in layerContents:
  637. if layerDirectory == DEFAULT_GLYPHS_DIRNAME:
  638. return layerName
  639. # this will already have been raised during __init__
  640. raise UFOLibError("The default layer is not defined in layercontents.plist.")
  641. def getGlyphSet(
  642. self,
  643. layerName: Optional[str] = None,
  644. validateRead: Optional[bool] = None,
  645. validateWrite: Optional[bool] = None,
  646. ) -> GlyphSet:
  647. """
  648. Return the GlyphSet associated with the
  649. glyphs directory mapped to layerName
  650. in the UFO. If layerName is not provided,
  651. the name retrieved with getDefaultLayerName
  652. will be used.
  653. ``validateRead`` will validate the read data, by default it is set to the
  654. class's validate value, can be overridden.
  655. ``validateWrite`` will validate the written data, by default it is set to the
  656. class's validate value, can be overridden.
  657. """
  658. from fontTools.ufoLib.glifLib import GlyphSet
  659. if validateRead is None:
  660. validateRead = self._validate
  661. if validateWrite is None:
  662. validateWrite = self._validate
  663. if layerName is None:
  664. layerName = self.getDefaultLayerName(validate=validateRead)
  665. directory = None
  666. layerContents = self._readLayerContents(validateRead)
  667. for storedLayerName, storedLayerDirectory in layerContents:
  668. if layerName == storedLayerName:
  669. directory = storedLayerDirectory
  670. break
  671. if directory is None:
  672. raise UFOLibError('No glyphs directory is mapped to "%s".' % layerName)
  673. try:
  674. glyphSubFS = self.fs.opendir(directory)
  675. except fs.errors.ResourceNotFound:
  676. raise UFOLibError(f"No '{directory}' directory for layer '{layerName}'")
  677. return GlyphSet(
  678. glyphSubFS,
  679. ufoFormatVersion=self._formatVersion,
  680. validateRead=validateRead,
  681. validateWrite=validateWrite,
  682. expectContentsFile=True,
  683. )
  684. def getCharacterMapping(
  685. self, layerName: Optional[str] = None, validate: Optional[bool] = None
  686. ) -> dict[int, list[str]]:
  687. """
  688. Return a dictionary that maps unicode values (ints) to
  689. lists of glyph names.
  690. """
  691. if validate is None:
  692. validate = self._validate
  693. glyphSet = self.getGlyphSet(
  694. layerName, validateRead=validate, validateWrite=True
  695. )
  696. allUnicodes = glyphSet.getUnicodes()
  697. cmap: dict[int, list[str]] = {}
  698. for glyphName, unicodes in allUnicodes.items():
  699. for code in unicodes:
  700. if code in cmap:
  701. cmap[code].append(glyphName)
  702. else:
  703. cmap[code] = [glyphName]
  704. return cmap
  705. # /data
  706. def getDataDirectoryListing(self) -> list[str]:
  707. """
  708. Returns a list of all files in the data directory.
  709. The returned paths will be relative to the UFO.
  710. This will not list directory names, only file names.
  711. Thus, empty directories will be skipped.
  712. """
  713. try:
  714. self._dataFS = self.fs.opendir(DATA_DIRNAME)
  715. except fs.errors.ResourceNotFound:
  716. return []
  717. except fs.errors.DirectoryExpected:
  718. raise UFOLibError('The UFO contains a "data" file instead of a directory.')
  719. try:
  720. # fs Walker.files method returns "absolute" paths (in terms of the
  721. # root of the 'data' SubFS), so we strip the leading '/' to make
  722. # them relative
  723. return [p.lstrip("/") for p in self._dataFS.walk.files()]
  724. except fs.errors.ResourceError:
  725. return []
  726. def getImageDirectoryListing(self, validate: Optional[bool] = None) -> list[str]:
  727. """
  728. Returns a list of all image file names in
  729. the images directory. Each of the images will
  730. have been verified to have the PNG signature.
  731. ``validate`` will validate the data, by default it is set to the
  732. class's validate value, can be overridden.
  733. """
  734. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  735. return []
  736. if validate is None:
  737. validate = self._validate
  738. try:
  739. self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME)
  740. except fs.errors.ResourceNotFound:
  741. return []
  742. except fs.errors.DirectoryExpected:
  743. raise UFOLibError(
  744. 'The UFO contains an "images" file instead of a directory.'
  745. )
  746. result = []
  747. for path in imagesFS.scandir("/"):
  748. if path.is_dir:
  749. # silently skip this as version control
  750. # systems often have hidden directories
  751. continue
  752. if validate:
  753. with imagesFS.open(path.name, "rb") as fp:
  754. valid, error = pngValidator(fileObj=fp)
  755. if valid:
  756. result.append(path.name)
  757. else:
  758. result.append(path.name)
  759. return result
  760. def readData(self, fileName: PathStr) -> bytes:
  761. """
  762. Return bytes for the file named 'fileName' inside the 'data/' directory.
  763. """
  764. fileName = fsdecode(fileName)
  765. try:
  766. try:
  767. dataFS = self._dataFS
  768. except AttributeError:
  769. # in case readData is called before getDataDirectoryListing
  770. dataFS = self.fs.opendir(DATA_DIRNAME)
  771. data = dataFS.readbytes(fileName)
  772. except fs.errors.ResourceNotFound:
  773. raise UFOLibError(f"No data file named '{fileName}' on {self.fs}")
  774. return data
  775. def readImage(self, fileName: PathStr, validate: Optional[bool] = None) -> bytes:
  776. """
  777. Return image data for the file named fileName.
  778. ``validate`` will validate the data, by default it is set to the
  779. class's validate value, can be overridden.
  780. """
  781. if validate is None:
  782. validate = self._validate
  783. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  784. raise UFOLibError(
  785. f"Reading images is not allowed in UFO {self._formatVersion.major}."
  786. )
  787. fileName = fsdecode(fileName)
  788. try:
  789. try:
  790. imagesFS = self._imagesFS
  791. except AttributeError:
  792. # in case readImage is called before getImageDirectoryListing
  793. imagesFS = self.fs.opendir(IMAGES_DIRNAME)
  794. data = imagesFS.readbytes(fileName)
  795. except fs.errors.ResourceNotFound:
  796. raise UFOLibError(f"No image file named '{fileName}' on {self.fs}")
  797. if validate:
  798. valid, error = pngValidator(data=data)
  799. if not valid:
  800. raise UFOLibError(error)
  801. return data
  802. def close(self) -> None:
  803. if self._shouldClose:
  804. self.fs.close()
  805. def __enter__(self) -> UFOReader:
  806. return self
  807. def __exit__(self, exc_type: Any, exc_value: Any, exc_tb: Any) -> None:
  808. self.close()
  809. # ----------
  810. # UFO Writer
  811. # ----------
  812. class UFOWriter(UFOReader):
  813. """Write the various components of a .ufo.
  814. Attributes:
  815. path: An :class:`os.PathLike` object pointing to the .ufo.
  816. formatVersion: the UFO format version as a tuple of integers (major, minor),
  817. or as a single integer for the major digit only (minor is implied to be 0).
  818. By default, the latest formatVersion will be used; currently it is 3.0,
  819. which is equivalent to formatVersion=(3, 0).
  820. fileCreator: The creator of the .ufo file. Defaults to
  821. `com.github.fonttools.ufoLib`.
  822. structure: The internal structure of the .ufo file: either `ZIP` or `PACKAGE`.
  823. validate: A boolean indicating if the data read should be validated. Defaults
  824. to `True`.
  825. By default, the written data will be validated before writing. Set ``validate`` to
  826. ``False`` if you do not want to validate the data. Validation can also be overriden
  827. on a per-method level if desired.
  828. Raises:
  829. UnsupportedUFOFormat: An exception indicating that the requested UFO
  830. formatVersion is not supported.
  831. """
  832. def __init__(
  833. self,
  834. path: PathOrFS,
  835. formatVersion: UFOFormatVersionInput = None,
  836. fileCreator: str = "com.github.fonttools.ufoLib",
  837. structure: Optional[UFOFileStructure] = None,
  838. validate: bool = True,
  839. ) -> None:
  840. try:
  841. formatVersion = normalizeFormatVersion(formatVersion, UFOFormatVersion)
  842. except ValueError as e:
  843. from fontTools.ufoLib.errors import UnsupportedUFOFormat
  844. raise UnsupportedUFOFormat(
  845. f"Unsupported UFO format: {formatVersion!r}"
  846. ) from e
  847. if hasattr(path, "__fspath__"): # support os.PathLike objects
  848. path = path.__fspath__()
  849. if isinstance(path, str):
  850. # normalize path by removing trailing or double slashes
  851. path = os.path.normpath(path)
  852. havePreviousFile = os.path.exists(path)
  853. if havePreviousFile:
  854. # ensure we use the same structure as the destination
  855. existingStructure = _sniffFileStructure(path)
  856. if structure is not None:
  857. try:
  858. structure = UFOFileStructure(structure)
  859. except ValueError:
  860. raise UFOLibError(
  861. "Invalid or unsupported structure: '%s'" % structure
  862. )
  863. if structure is not existingStructure:
  864. raise UFOLibError(
  865. "A UFO with a different structure (%s) already exists "
  866. "at the given path: '%s'" % (existingStructure, path)
  867. )
  868. else:
  869. structure = existingStructure
  870. else:
  871. # if not exists, default to 'package' structure
  872. if structure is None:
  873. structure = UFOFileStructure.PACKAGE
  874. dirName = os.path.dirname(path)
  875. if dirName and not os.path.isdir(dirName):
  876. raise UFOLibError(
  877. "Cannot write to '%s': directory does not exist" % path
  878. )
  879. if structure is UFOFileStructure.ZIP:
  880. if havePreviousFile:
  881. # we can't write a zip in-place, so we have to copy its
  882. # contents to a temporary location and work from there, then
  883. # upon closing UFOWriter we create the final zip file
  884. parentFS: FS = fs.tempfs.TempFS()
  885. with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: # type: ignore[abstract]
  886. fs.copy.copy_fs(origFS, parentFS)
  887. # if output path is an existing zip, we require that it contains
  888. # one, and only one, root directory (with arbitrary name), in turn
  889. # containing all the existing UFO contents
  890. rootDirs = [
  891. p.name
  892. for p in parentFS.scandir("/")
  893. # exclude macOS metadata contained in zip file
  894. if p.is_dir and p.name != "__MACOSX"
  895. ]
  896. if len(rootDirs) != 1:
  897. raise UFOLibError(
  898. "Expected exactly 1 root directory, found %d"
  899. % len(rootDirs)
  900. )
  901. else:
  902. rootDir = rootDirs[0]
  903. else:
  904. # if the output zip file didn't exist, we create the root folder;
  905. # we name it the same as input 'path', but with '.ufo' extension
  906. rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo"
  907. parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8") # type: ignore[abstract]
  908. parentFS.makedir(rootDir)
  909. # 'ClosingSubFS' ensures that the parent filesystem is closed
  910. # when its root subdirectory is closed
  911. self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS)
  912. else:
  913. self.fs = fs.osfs.OSFS(path, create=True)
  914. self._fileStructure = structure
  915. self._havePreviousFile = havePreviousFile
  916. self._shouldClose = True
  917. elif isinstance(path, fs.base.FS):
  918. filesystem: FS = path
  919. try:
  920. filesystem.check()
  921. except fs.errors.FilesystemClosed:
  922. raise UFOLibError("the filesystem '%s' is closed" % path)
  923. else:
  924. self.fs = filesystem
  925. try:
  926. path = filesystem.getsyspath("/")
  927. except fs.errors.NoSysPath:
  928. # network or in-memory FS may not map to the local one
  929. path = str(filesystem)
  930. # if passed an FS object, always use 'package' structure
  931. if structure and structure is not UFOFileStructure.PACKAGE:
  932. import warnings
  933. warnings.warn(
  934. "The 'structure' argument is not used when input is an FS object",
  935. UserWarning,
  936. stacklevel=2,
  937. )
  938. self._fileStructure = UFOFileStructure.PACKAGE
  939. # if FS contains a "metainfo.plist", we consider it non-empty
  940. self._havePreviousFile = filesystem.exists(METAINFO_FILENAME)
  941. # the user is responsible for closing the FS object
  942. self._shouldClose = False
  943. else:
  944. raise TypeError(
  945. "Expected a path string or fs object, found %s" % type(path).__name__
  946. )
  947. # establish some basic stuff
  948. self._path = fsdecode(path)
  949. self._formatVersion = formatVersion
  950. self._fileCreator = fileCreator
  951. self._downConversionKerningData: Optional[KerningGroupRenameMaps] = None
  952. self._validate = validate
  953. # if the file already exists, get the format version.
  954. # this will be needed for up and down conversion.
  955. previousFormatVersion = None
  956. if self._havePreviousFile:
  957. metaInfo = self._readMetaInfo(validate=validate)
  958. previousFormatVersion = metaInfo["formatVersionTuple"]
  959. # catch down conversion
  960. if previousFormatVersion > formatVersion:
  961. from fontTools.ufoLib.errors import UnsupportedUFOFormat
  962. raise UnsupportedUFOFormat(
  963. "The UFO located at this path is a higher version "
  964. f"({previousFormatVersion}) than the version ({formatVersion}) "
  965. "that is trying to be written. This is not supported."
  966. )
  967. # handle the layer contents
  968. self.layerContents: Union[dict[str, str], OrderedDict[str, str]] = {}
  969. if previousFormatVersion is not None and previousFormatVersion.major >= 3:
  970. # already exists
  971. self.layerContents = OrderedDict(self._readLayerContents(validate))
  972. else:
  973. # previous < 3
  974. # imply the layer contents
  975. if self.fs.exists(DEFAULT_GLYPHS_DIRNAME):
  976. self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME}
  977. # write the new metainfo
  978. self._writeMetaInfo()
  979. # properties
  980. def _get_fileCreator(self) -> str:
  981. return self._fileCreator
  982. fileCreator: property = property(
  983. _get_fileCreator,
  984. doc="The file creator of the UFO. This is set into metainfo.plist during __init__.",
  985. )
  986. # support methods for file system interaction
  987. def copyFromReader(
  988. self, reader: UFOReader, sourcePath: PathStr, destPath: PathStr
  989. ) -> None:
  990. """
  991. Copy the sourcePath in the provided UFOReader to destPath
  992. in this writer. The paths must be relative. This works with
  993. both individual files and directories.
  994. """
  995. if not isinstance(reader, UFOReader):
  996. raise UFOLibError("The reader must be an instance of UFOReader.")
  997. sourcePath = fsdecode(sourcePath)
  998. destPath = fsdecode(destPath)
  999. if not reader.fs.exists(sourcePath):
  1000. raise UFOLibError(
  1001. 'The reader does not have data located at "%s".' % sourcePath
  1002. )
  1003. if self.fs.exists(destPath):
  1004. raise UFOLibError('A file named "%s" already exists.' % destPath)
  1005. # create the destination directory if it doesn't exist
  1006. self.fs.makedirs(fs.path.dirname(destPath), recreate=True)
  1007. if reader.fs.isdir(sourcePath):
  1008. fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath)
  1009. else:
  1010. fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath)
  1011. def writeBytesToPath(self, path: PathStr, data: bytes) -> None:
  1012. """
  1013. Write bytes to a path relative to the UFO filesystem's root.
  1014. If writing to an existing UFO, check to see if data matches the data
  1015. that is already in the file at path; if so, the file is not rewritten
  1016. so that the modification date is preserved.
  1017. If needed, the directory tree for the given path will be built.
  1018. """
  1019. path = fsdecode(path)
  1020. if self._havePreviousFile:
  1021. if self.fs.isfile(path) and data == self.fs.readbytes(path):
  1022. return
  1023. try:
  1024. self.fs.writebytes(path, data)
  1025. except fs.errors.FileExpected:
  1026. raise UFOLibError("A directory exists at '%s'" % path)
  1027. except fs.errors.ResourceNotFound:
  1028. self.fs.makedirs(fs.path.dirname(path), recreate=True)
  1029. self.fs.writebytes(path, data)
  1030. def getFileObjectForPath(
  1031. self,
  1032. path: PathStr,
  1033. mode: str = "w",
  1034. encoding: Optional[str] = None,
  1035. ) -> Optional[IO[Any]]:
  1036. """
  1037. Returns a file (or file-like) object for the
  1038. file at the given path. The path must be relative
  1039. to the UFO path. Returns None if the file does
  1040. not exist and the mode is "r" or "rb.
  1041. An encoding may be passed if the file is opened in text mode.
  1042. Note: The caller is responsible for closing the open file.
  1043. """
  1044. path = fsdecode(path)
  1045. try:
  1046. return self.fs.open(path, mode=mode, encoding=encoding)
  1047. except fs.errors.ResourceNotFound as e:
  1048. m = mode[0]
  1049. if m == "r":
  1050. # XXX I think we should just let it raise. The docstring,
  1051. # however, says that this returns None if mode is 'r'
  1052. return None
  1053. elif m == "w" or m == "a" or m == "x":
  1054. self.fs.makedirs(fs.path.dirname(path), recreate=True)
  1055. return self.fs.open(path, mode=mode, encoding=encoding)
  1056. except fs.errors.ResourceError as e:
  1057. raise UFOLibError(f"unable to open '{path}' on {self.fs}: {e}")
  1058. return None
  1059. def removePath(
  1060. self, path: PathStr, force: bool = False, removeEmptyParents: bool = True
  1061. ) -> None:
  1062. """
  1063. Remove the file (or directory) at path. The path
  1064. must be relative to the UFO.
  1065. Raises UFOLibError if the path doesn't exist.
  1066. If force=True, ignore non-existent paths.
  1067. If the directory where 'path' is located becomes empty, it will
  1068. be automatically removed, unless 'removeEmptyParents' is False.
  1069. """
  1070. path = fsdecode(path)
  1071. try:
  1072. self.fs.remove(path)
  1073. except fs.errors.FileExpected:
  1074. self.fs.removetree(path)
  1075. except fs.errors.ResourceNotFound:
  1076. if not force:
  1077. raise UFOLibError(f"'{path}' does not exist on {self.fs}")
  1078. if removeEmptyParents:
  1079. parent = fs.path.dirname(path)
  1080. if parent:
  1081. fs.tools.remove_empty(self.fs, parent)
  1082. # alias kept for backward compatibility with old API
  1083. removeFileForPath = removePath
  1084. # UFO mod time
  1085. def setModificationTime(self) -> None:
  1086. """
  1087. Set the UFO modification time to the current time.
  1088. This is never called automatically. It is up to the
  1089. caller to call this when finished working on the UFO.
  1090. """
  1091. path = self._path
  1092. if path is not None and os.path.exists(path):
  1093. try:
  1094. # this may fail on some filesystems (e.g. SMB servers)
  1095. os.utime(path, None)
  1096. except OSError as e:
  1097. logger.warning("Failed to set modified time: %s", e)
  1098. # metainfo.plist
  1099. def _writeMetaInfo(self) -> None:
  1100. metaInfo = dict(
  1101. creator=self._fileCreator,
  1102. formatVersion=self._formatVersion.major,
  1103. )
  1104. if self._formatVersion.minor != 0:
  1105. metaInfo["formatVersionMinor"] = self._formatVersion.minor
  1106. self._writePlist(METAINFO_FILENAME, metaInfo)
  1107. # groups.plist
  1108. def setKerningGroupConversionRenameMaps(self, maps: KerningGroupRenameMaps) -> None:
  1109. """
  1110. Set maps defining the renaming that should be done
  1111. when writing groups and kerning in UFO 1 and UFO 2.
  1112. This will effectively undo the conversion done when
  1113. UFOReader reads this data. The dictionary should have
  1114. this form::
  1115. {
  1116. "side1" : {"group name to use when writing" : "group name in data"},
  1117. "side2" : {"group name to use when writing" : "group name in data"}
  1118. }
  1119. This is the same form returned by UFOReader's
  1120. getKerningGroupConversionRenameMaps method.
  1121. """
  1122. if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
  1123. return # XXX raise an error here
  1124. # flip the dictionaries
  1125. remap = {}
  1126. for side in ("side1", "side2"):
  1127. for writeName, dataName in list(maps[side].items()):
  1128. remap[dataName] = writeName
  1129. self._downConversionKerningData = dict(groupRenameMap=remap)
  1130. def writeGroups(
  1131. self, groups: KerningGroups, validate: Optional[bool] = None
  1132. ) -> None:
  1133. """
  1134. Write groups.plist. This method requires a
  1135. dict of glyph groups as an argument.
  1136. ``validate`` will validate the data, by default it is set to the
  1137. class's validate value, can be overridden.
  1138. """
  1139. if validate is None:
  1140. validate = self._validate
  1141. # validate the data structure
  1142. if validate:
  1143. valid, message = groupsValidator(groups)
  1144. if not valid:
  1145. raise UFOLibError(message)
  1146. # down convert
  1147. if (
  1148. self._formatVersion < UFOFormatVersion.FORMAT_3_0
  1149. and self._downConversionKerningData is not None
  1150. ):
  1151. remap = self._downConversionKerningData["groupRenameMap"]
  1152. remappedGroups = {}
  1153. # there are some edge cases here that are ignored:
  1154. # 1. if a group is being renamed to a name that
  1155. # already exists, the existing group is always
  1156. # overwritten. (this is why there are two loops
  1157. # below.) there doesn't seem to be a logical
  1158. # solution to groups mismatching and overwriting
  1159. # with the specifiecd group seems like a better
  1160. # solution than throwing an error.
  1161. # 2. if side 1 and side 2 groups are being renamed
  1162. # to the same group name there is no check to
  1163. # ensure that the contents are identical. that
  1164. # is left up to the caller.
  1165. for name, contents in list(groups.items()):
  1166. if name in remap:
  1167. continue
  1168. remappedGroups[name] = contents
  1169. for name, contents in list(groups.items()):
  1170. if name not in remap:
  1171. continue
  1172. name = remap[name]
  1173. remappedGroups[name] = contents
  1174. groups = remappedGroups
  1175. # pack and write
  1176. groupsNew = {}
  1177. for key, value in groups.items():
  1178. groupsNew[key] = list(value)
  1179. if groupsNew:
  1180. self._writePlist(GROUPS_FILENAME, groupsNew)
  1181. elif self._havePreviousFile:
  1182. self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False)
  1183. # fontinfo.plist
  1184. def writeInfo(self, info: Any, validate: Optional[bool] = None) -> None:
  1185. """
  1186. Write info.plist. This method requires an object
  1187. that supports getting attributes that follow the
  1188. fontinfo.plist version 2 specification. Attributes
  1189. will be taken from the given object and written
  1190. into the file.
  1191. ``validate`` will validate the data, by default it is set to the
  1192. class's validate value, can be overridden.
  1193. """
  1194. if validate is None:
  1195. validate = self._validate
  1196. # gather version 3 data
  1197. infoData = {}
  1198. for attr in list(fontInfoAttributesVersion3ValueData.keys()):
  1199. if hasattr(info, attr):
  1200. try:
  1201. value = getattr(info, attr)
  1202. except AttributeError:
  1203. raise UFOLibError(
  1204. "The supplied info object does not support getting a necessary attribute (%s)."
  1205. % attr
  1206. )
  1207. if value is None:
  1208. continue
  1209. infoData[attr] = value
  1210. # down convert data if necessary and validate
  1211. if self._formatVersion == UFOFormatVersion.FORMAT_3_0:
  1212. if validate:
  1213. infoData = validateInfoVersion3Data(infoData)
  1214. elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
  1215. infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
  1216. if validate:
  1217. infoData = validateInfoVersion2Data(infoData)
  1218. elif self._formatVersion == UFOFormatVersion.FORMAT_1_0:
  1219. infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
  1220. if validate:
  1221. infoData = validateInfoVersion2Data(infoData)
  1222. infoData = _convertFontInfoDataVersion2ToVersion1(infoData)
  1223. # write file if there is anything to write
  1224. if infoData:
  1225. self._writePlist(FONTINFO_FILENAME, infoData)
  1226. # kerning.plist
  1227. def writeKerning(
  1228. self, kerning: KerningDict, validate: Optional[bool] = None
  1229. ) -> None:
  1230. """
  1231. Write kerning.plist. This method requires a
  1232. dict of kerning pairs as an argument.
  1233. This performs basic structural validation of the kerning,
  1234. but it does not check for compliance with the spec in
  1235. regards to conflicting pairs. The assumption is that the
  1236. kerning data being passed is standards compliant.
  1237. ``validate`` will validate the data, by default it is set to the
  1238. class's validate value, can be overridden.
  1239. """
  1240. if validate is None:
  1241. validate = self._validate
  1242. # validate the data structure
  1243. if validate:
  1244. invalidFormatMessage = "The kerning is not properly formatted."
  1245. if not isDictEnough(kerning):
  1246. raise UFOLibError(invalidFormatMessage)
  1247. for pair, value in list(kerning.items()):
  1248. if not isinstance(pair, (list, tuple)):
  1249. raise UFOLibError(invalidFormatMessage)
  1250. if not len(pair) == 2:
  1251. raise UFOLibError(invalidFormatMessage)
  1252. if not isinstance(pair[0], str):
  1253. raise UFOLibError(invalidFormatMessage)
  1254. if not isinstance(pair[1], str):
  1255. raise UFOLibError(invalidFormatMessage)
  1256. if not isinstance(value, numberTypes):
  1257. raise UFOLibError(invalidFormatMessage)
  1258. # down convert
  1259. if (
  1260. self._formatVersion < UFOFormatVersion.FORMAT_3_0
  1261. and self._downConversionKerningData is not None
  1262. ):
  1263. remap = self._downConversionKerningData["groupRenameMap"]
  1264. remappedKerning = {}
  1265. for (side1, side2), value in list(kerning.items()):
  1266. side1 = remap.get(side1, side1)
  1267. side2 = remap.get(side2, side2)
  1268. remappedKerning[side1, side2] = value
  1269. kerning = remappedKerning
  1270. # pack and write
  1271. kerningDict: KerningNested = {}
  1272. for left, right in kerning.keys():
  1273. value = kerning[left, right]
  1274. if left not in kerningDict:
  1275. kerningDict[left] = {}
  1276. kerningDict[left][right] = value
  1277. if kerningDict:
  1278. self._writePlist(KERNING_FILENAME, kerningDict)
  1279. elif self._havePreviousFile:
  1280. self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False)
  1281. # lib.plist
  1282. def writeLib(self, libDict: LibDict, validate: Optional[bool] = None) -> None:
  1283. """
  1284. Write lib.plist. This method requires a
  1285. lib dict as an argument.
  1286. ``validate`` will validate the data, by default it is set to the
  1287. class's validate value, can be overridden.
  1288. """
  1289. if validate is None:
  1290. validate = self._validate
  1291. if validate:
  1292. valid, message = fontLibValidator(libDict)
  1293. if not valid:
  1294. raise UFOLibError(message)
  1295. if libDict:
  1296. self._writePlist(LIB_FILENAME, libDict)
  1297. elif self._havePreviousFile:
  1298. self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False)
  1299. # features.fea
  1300. def writeFeatures(self, features: str, validate: Optional[bool] = None) -> None:
  1301. """
  1302. Write features.fea. This method requires a
  1303. features string as an argument.
  1304. """
  1305. if validate is None:
  1306. validate = self._validate
  1307. if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
  1308. raise UFOLibError("features.fea is not allowed in UFO Format Version 1.")
  1309. if validate:
  1310. if not isinstance(features, str):
  1311. raise UFOLibError("The features are not text.")
  1312. if features:
  1313. self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8"))
  1314. elif self._havePreviousFile:
  1315. self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False)
  1316. # glyph sets & layers
  1317. def writeLayerContents(
  1318. self, layerOrder: LayerOrderList = None, validate: Optional[bool] = None
  1319. ) -> None:
  1320. """
  1321. Write the layercontents.plist file. This method *must* be called
  1322. after all glyph sets have been written.
  1323. """
  1324. if validate is None:
  1325. validate = self._validate
  1326. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1327. return
  1328. if layerOrder is not None:
  1329. newOrder: list[Optional[str]] = []
  1330. for layerName in layerOrder:
  1331. if layerName is None:
  1332. layerName = DEFAULT_LAYER_NAME
  1333. newOrder.append(layerName)
  1334. layerOrder = newOrder
  1335. else:
  1336. layerOrder = list(self.layerContents.keys())
  1337. if validate and set(layerOrder) != set(self.layerContents.keys()):
  1338. raise UFOLibError(
  1339. "The layer order content does not match the glyph sets that have been created."
  1340. )
  1341. layerContents = [
  1342. (layerName, self.layerContents[layerName])
  1343. for layerName in layerOrder
  1344. if layerName is not None
  1345. ]
  1346. self._writePlist(LAYERCONTENTS_FILENAME, layerContents)
  1347. def _findDirectoryForLayerName(self, layerName: Optional[str]) -> str:
  1348. foundDirectory = None
  1349. for existingLayerName, directoryName in list(self.layerContents.items()):
  1350. if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME:
  1351. foundDirectory = directoryName
  1352. break
  1353. elif existingLayerName == layerName:
  1354. foundDirectory = directoryName
  1355. break
  1356. if not foundDirectory:
  1357. raise UFOLibError(
  1358. "Could not locate a glyph set directory for the layer named %s."
  1359. % layerName
  1360. )
  1361. return foundDirectory
  1362. def getGlyphSet( # type: ignore[override]
  1363. self,
  1364. layerName: Optional[str] = None,
  1365. defaultLayer: bool = True,
  1366. glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None,
  1367. validateRead: Optional[bool] = None,
  1368. validateWrite: Optional[bool] = None,
  1369. expectContentsFile: bool = False,
  1370. ) -> GlyphSet:
  1371. """
  1372. Return the GlyphSet object associated with the
  1373. appropriate glyph directory in the .ufo.
  1374. If layerName is None, the default glyph set
  1375. will be used. The defaultLayer flag indictes
  1376. that the layer should be saved into the default
  1377. glyphs directory.
  1378. ``validateRead`` will validate the read data, by default it is set to the
  1379. class's validate value, can be overridden.
  1380. ``validateWrte`` will validate the written data, by default it is set to the
  1381. class's validate value, can be overridden.
  1382. ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
  1383. not found on the glyph set file system. This should be set to ``True`` if you
  1384. are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create
  1385. a fresh glyph set.
  1386. """
  1387. if validateRead is None:
  1388. validateRead = self._validate
  1389. if validateWrite is None:
  1390. validateWrite = self._validate
  1391. # only default can be written in < 3
  1392. if self._formatVersion < UFOFormatVersion.FORMAT_3_0 and (
  1393. not defaultLayer or layerName is not None
  1394. ):
  1395. raise UFOLibError(
  1396. f"Only the default layer can be writen in UFO {self._formatVersion.major}."
  1397. )
  1398. # locate a layer name when None has been given
  1399. if layerName is None and defaultLayer:
  1400. for existingLayerName, directory in self.layerContents.items():
  1401. if directory == DEFAULT_GLYPHS_DIRNAME:
  1402. layerName = existingLayerName
  1403. if layerName is None:
  1404. layerName = DEFAULT_LAYER_NAME
  1405. elif layerName is None and not defaultLayer:
  1406. raise UFOLibError("A layer name must be provided for non-default layers.")
  1407. # move along to format specific writing
  1408. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1409. return self._getDefaultGlyphSet(
  1410. validateRead,
  1411. validateWrite,
  1412. glyphNameToFileNameFunc=glyphNameToFileNameFunc,
  1413. expectContentsFile=expectContentsFile,
  1414. )
  1415. elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
  1416. return self._getGlyphSetFormatVersion3(
  1417. validateRead,
  1418. validateWrite,
  1419. layerName=layerName,
  1420. defaultLayer=defaultLayer,
  1421. glyphNameToFileNameFunc=glyphNameToFileNameFunc,
  1422. expectContentsFile=expectContentsFile,
  1423. )
  1424. else:
  1425. raise NotImplementedError(self._formatVersion)
  1426. def _getDefaultGlyphSet(
  1427. self,
  1428. validateRead: bool,
  1429. validateWrite: bool,
  1430. glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None,
  1431. expectContentsFile: bool = False,
  1432. ) -> GlyphSet:
  1433. from fontTools.ufoLib.glifLib import GlyphSet
  1434. glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True)
  1435. return GlyphSet(
  1436. glyphSubFS,
  1437. glyphNameToFileNameFunc=glyphNameToFileNameFunc,
  1438. ufoFormatVersion=self._formatVersion,
  1439. validateRead=validateRead,
  1440. validateWrite=validateWrite,
  1441. expectContentsFile=expectContentsFile,
  1442. )
  1443. def _getGlyphSetFormatVersion3(
  1444. self,
  1445. validateRead: bool,
  1446. validateWrite: bool,
  1447. layerName: Optional[str] = None,
  1448. defaultLayer: bool = True,
  1449. glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None,
  1450. expectContentsFile: bool = False,
  1451. ) -> GlyphSet:
  1452. from fontTools.ufoLib.glifLib import GlyphSet
  1453. # if the default flag is on, make sure that the default in the file
  1454. # matches the default being written. also make sure that this layer
  1455. # name is not already linked to a non-default layer.
  1456. if defaultLayer:
  1457. for existingLayerName, directory in self.layerContents.items():
  1458. if directory == DEFAULT_GLYPHS_DIRNAME:
  1459. if existingLayerName != layerName:
  1460. raise UFOLibError(
  1461. "Another layer ('%s') is already mapped to the default directory."
  1462. % existingLayerName
  1463. )
  1464. elif existingLayerName == layerName:
  1465. raise UFOLibError(
  1466. "The layer name is already mapped to a non-default layer."
  1467. )
  1468. # handle layerName is None to avoid MyPy errors
  1469. if layerName is None:
  1470. raise TypeError("'leyerName' cannot be None.")
  1471. # get an existing directory name
  1472. if layerName in self.layerContents:
  1473. directory = self.layerContents[layerName]
  1474. # get a new directory name
  1475. else:
  1476. if defaultLayer:
  1477. directory = DEFAULT_GLYPHS_DIRNAME
  1478. else:
  1479. # not caching this could be slightly expensive,
  1480. # but caching it will be cumbersome
  1481. existing = {d.lower() for d in self.layerContents.values()}
  1482. directory = userNameToFileName(
  1483. layerName, existing=existing, prefix="glyphs."
  1484. )
  1485. # make the directory
  1486. glyphSubFS = self.fs.makedir(directory, recreate=True)
  1487. # store the mapping
  1488. self.layerContents[layerName] = directory
  1489. # load the glyph set
  1490. return GlyphSet(
  1491. glyphSubFS,
  1492. glyphNameToFileNameFunc=glyphNameToFileNameFunc,
  1493. ufoFormatVersion=self._formatVersion,
  1494. validateRead=validateRead,
  1495. validateWrite=validateWrite,
  1496. expectContentsFile=expectContentsFile,
  1497. )
  1498. def renameGlyphSet(
  1499. self,
  1500. layerName: Optional[str],
  1501. newLayerName: Optional[str],
  1502. defaultLayer: bool = False,
  1503. ) -> None:
  1504. """
  1505. Rename a glyph set.
  1506. Note: if a GlyphSet object has already been retrieved for
  1507. layerName, it is up to the caller to inform that object that
  1508. the directory it represents has changed.
  1509. """
  1510. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1511. # ignore renaming glyph sets for UFO1 UFO2
  1512. # just write the data from the default layer
  1513. return
  1514. # the new and old names can be the same
  1515. # as long as the default is being switched
  1516. if layerName is not None and layerName == newLayerName:
  1517. # if the default is off and the layer is already not the default, skip
  1518. if (
  1519. self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME
  1520. and not defaultLayer
  1521. ):
  1522. return
  1523. # if the default is on and the layer is already the default, skip
  1524. if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer:
  1525. return
  1526. else:
  1527. # make sure the new layer name doesn't already exist
  1528. if newLayerName is None:
  1529. newLayerName = DEFAULT_LAYER_NAME
  1530. if newLayerName in self.layerContents:
  1531. raise UFOLibError("A layer named %s already exists." % newLayerName)
  1532. # make sure the default layer doesn't already exist
  1533. if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values():
  1534. raise UFOLibError("A default layer already exists.")
  1535. # get the paths
  1536. oldDirectory = self._findDirectoryForLayerName(layerName)
  1537. if defaultLayer:
  1538. newDirectory = DEFAULT_GLYPHS_DIRNAME
  1539. else:
  1540. existing = {name.lower() for name in self.layerContents.values()}
  1541. newDirectory = userNameToFileName(
  1542. newLayerName, existing=existing, prefix="glyphs."
  1543. )
  1544. # update the internal mapping
  1545. if layerName is not None:
  1546. del self.layerContents[layerName]
  1547. self.layerContents[newLayerName] = newDirectory
  1548. # do the file system copy
  1549. self.fs.movedir(oldDirectory, newDirectory, create=True)
  1550. def deleteGlyphSet(self, layerName: Optional[str]) -> None:
  1551. """
  1552. Remove the glyph set matching layerName.
  1553. """
  1554. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1555. # ignore deleting glyph sets for UFO1 UFO2 as there are no layers
  1556. # just write the data from the default layer
  1557. return
  1558. foundDirectory = self._findDirectoryForLayerName(layerName)
  1559. self.removePath(foundDirectory, removeEmptyParents=False)
  1560. if layerName is not None:
  1561. del self.layerContents[layerName]
  1562. def writeData(self, fileName: PathStr, data: bytes) -> None:
  1563. """
  1564. Write data to fileName in the 'data' directory.
  1565. The data must be a bytes string.
  1566. """
  1567. self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data)
  1568. def removeData(self, fileName: PathStr) -> None:
  1569. """
  1570. Remove the file named fileName from the data directory.
  1571. """
  1572. self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}")
  1573. # /images
  1574. def writeImage(
  1575. self,
  1576. fileName: PathStr,
  1577. data: bytes,
  1578. validate: Optional[bool] = None,
  1579. ) -> None:
  1580. """
  1581. Write data to fileName in the images directory.
  1582. The data must be a valid PNG.
  1583. """
  1584. if validate is None:
  1585. validate = self._validate
  1586. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1587. raise UFOLibError(
  1588. f"Images are not allowed in UFO {self._formatVersion.major}."
  1589. )
  1590. fileName = fsdecode(fileName)
  1591. if validate:
  1592. valid, error = pngValidator(data=data)
  1593. if not valid:
  1594. raise UFOLibError(error)
  1595. self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data)
  1596. def removeImage(
  1597. self,
  1598. fileName: PathStr,
  1599. validate: Optional[bool] = None,
  1600. ) -> None: # XXX remove unused 'validate'?
  1601. """
  1602. Remove the file named fileName from the
  1603. images directory.
  1604. """
  1605. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1606. raise UFOLibError(
  1607. f"Images are not allowed in UFO {self._formatVersion.major}."
  1608. )
  1609. self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}")
  1610. def copyImageFromReader(
  1611. self,
  1612. reader: UFOReader,
  1613. sourceFileName: PathStr,
  1614. destFileName: PathStr,
  1615. validate: Optional[bool] = None,
  1616. ) -> None:
  1617. """
  1618. Copy the sourceFileName in the provided UFOReader to destFileName
  1619. in this writer. This uses the most memory efficient method possible
  1620. for copying the data possible.
  1621. """
  1622. if validate is None:
  1623. validate = self._validate
  1624. if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
  1625. raise UFOLibError(
  1626. f"Images are not allowed in UFO {self._formatVersion.major}."
  1627. )
  1628. sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}"
  1629. destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}"
  1630. self.copyFromReader(reader, sourcePath, destPath)
  1631. def close(self) -> None:
  1632. if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP:
  1633. # if we are updating an existing zip file, we can now compress the
  1634. # contents of the temporary filesystem in the destination path
  1635. rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo"
  1636. with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS: # type: ignore[abstract]
  1637. fs.copy.copy_fs(self.fs, destFS.makedir(rootDir))
  1638. super().close()
  1639. # just an alias, makes it more explicit
  1640. UFOReaderWriter = UFOWriter
  1641. # ----------------
  1642. # Helper Functions
  1643. # ----------------
  1644. def _sniffFileStructure(ufo_path: PathStr) -> UFOFileStructure:
  1645. """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str)
  1646. is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a
  1647. directory.
  1648. Raise UFOLibError if it is a file with unknown structure, or if the path
  1649. does not exist.
  1650. """
  1651. if zipfile.is_zipfile(ufo_path):
  1652. return UFOFileStructure.ZIP
  1653. elif os.path.isdir(ufo_path):
  1654. return UFOFileStructure.PACKAGE
  1655. elif os.path.isfile(ufo_path):
  1656. raise UFOLibError(
  1657. "The specified UFO does not have a known structure: '%s'" % ufo_path
  1658. )
  1659. else:
  1660. raise UFOLibError("No such file or directory: '%s'" % ufo_path)
  1661. def makeUFOPath(path: PathStr) -> str:
  1662. """
  1663. Return a .ufo pathname.
  1664. >>> makeUFOPath("directory/something.ext") == (
  1665. ... os.path.join('directory', 'something.ufo'))
  1666. True
  1667. >>> makeUFOPath("directory/something.another.thing.ext") == (
  1668. ... os.path.join('directory', 'something.another.thing.ufo'))
  1669. True
  1670. """
  1671. dir, name = os.path.split(path)
  1672. name = ".".join([".".join(name.split(".")[:-1]), "ufo"])
  1673. return os.path.join(dir, name)
  1674. # ----------------------
  1675. # fontinfo.plist Support
  1676. # ----------------------
  1677. # Version Validators
  1678. # There is no version 1 validator and there shouldn't be.
  1679. # The version 1 spec was very loose and there were numerous
  1680. # cases of invalid values.
  1681. def validateFontInfoVersion2ValueForAttribute(attr: str, value: Any) -> bool:
  1682. """
  1683. This performs very basic validation of the value for attribute
  1684. following the UFO 2 fontinfo.plist specification. The results
  1685. of this should not be interpretted as *correct* for the font
  1686. that they are part of. This merely indicates that the value
  1687. is of the proper type and, where the specification defines
  1688. a set range of possible values for an attribute, that the
  1689. value is in the accepted range.
  1690. """
  1691. dataValidationDict = fontInfoAttributesVersion2ValueData[attr]
  1692. valueType = dataValidationDict.get("type")
  1693. validator = dataValidationDict.get("valueValidator", genericTypeValidator)
  1694. valueOptions = dataValidationDict.get("valueOptions")
  1695. # have specific options for the validator
  1696. if valueOptions is not None:
  1697. isValidValue = validator(value, valueOptions)
  1698. # no specific options
  1699. else:
  1700. if validator == genericTypeValidator:
  1701. isValidValue = validator(value, valueType)
  1702. else:
  1703. isValidValue = validator(value)
  1704. return isValidValue
  1705. def validateInfoVersion2Data(infoData: dict[str, Any]) -> dict[str, Any]:
  1706. """
  1707. This performs very basic validation of the value for infoData
  1708. following the UFO 2 fontinfo.plist specification. The results
  1709. of this should not be interpretted as *correct* for the font
  1710. that they are part of. This merely indicates that the values
  1711. are of the proper type and, where the specification defines
  1712. a set range of possible values for an attribute, that the
  1713. value is in the accepted range.
  1714. """
  1715. validInfoData = {}
  1716. for attr, value in list(infoData.items()):
  1717. isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value)
  1718. if not isValidValue:
  1719. raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
  1720. else:
  1721. validInfoData[attr] = value
  1722. return validInfoData
  1723. def validateFontInfoVersion3ValueForAttribute(attr: str, value: Any) -> bool:
  1724. """
  1725. This performs very basic validation of the value for attribute
  1726. following the UFO 3 fontinfo.plist specification. The results
  1727. of this should not be interpretted as *correct* for the font
  1728. that they are part of. This merely indicates that the value
  1729. is of the proper type and, where the specification defines
  1730. a set range of possible values for an attribute, that the
  1731. value is in the accepted range.
  1732. """
  1733. dataValidationDict = fontInfoAttributesVersion3ValueData[attr]
  1734. valueType = dataValidationDict.get("type")
  1735. validator = dataValidationDict.get("valueValidator", genericTypeValidator)
  1736. valueOptions = dataValidationDict.get("valueOptions")
  1737. # have specific options for the validator
  1738. if valueOptions is not None:
  1739. isValidValue = validator(value, valueOptions)
  1740. # no specific options
  1741. else:
  1742. if validator == genericTypeValidator:
  1743. isValidValue = validator(value, valueType)
  1744. else:
  1745. isValidValue = validator(value)
  1746. return isValidValue
  1747. def validateInfoVersion3Data(infoData: dict[str, Any]) -> dict[str, Any]:
  1748. """
  1749. This performs very basic validation of the value for infoData
  1750. following the UFO 3 fontinfo.plist specification. The results
  1751. of this should not be interpretted as *correct* for the font
  1752. that they are part of. This merely indicates that the values
  1753. are of the proper type and, where the specification defines
  1754. a set range of possible values for an attribute, that the
  1755. value is in the accepted range.
  1756. """
  1757. validInfoData = {}
  1758. for attr, value in list(infoData.items()):
  1759. isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value)
  1760. if not isValidValue:
  1761. raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
  1762. else:
  1763. validInfoData[attr] = value
  1764. return validInfoData
  1765. # Value Options
  1766. fontInfoOpenTypeHeadFlagsOptions: list[int] = list(range(0, 15))
  1767. fontInfoOpenTypeOS2SelectionOptions: list[int] = [1, 2, 3, 4, 7, 8, 9]
  1768. fontInfoOpenTypeOS2UnicodeRangesOptions: list[int] = list(range(0, 128))
  1769. fontInfoOpenTypeOS2CodePageRangesOptions: list[int] = list(range(0, 64))
  1770. fontInfoOpenTypeOS2TypeOptions: list[int] = [0, 1, 2, 3, 8, 9]
  1771. # Version Attribute Definitions
  1772. # This defines the attributes, types and, in some
  1773. # cases the possible values, that can exist is
  1774. # fontinfo.plist.
  1775. fontInfoAttributesVersion1: set[str] = {
  1776. "familyName",
  1777. "styleName",
  1778. "fullName",
  1779. "fontName",
  1780. "menuName",
  1781. "fontStyle",
  1782. "note",
  1783. "versionMajor",
  1784. "versionMinor",
  1785. "year",
  1786. "copyright",
  1787. "notice",
  1788. "trademark",
  1789. "license",
  1790. "licenseURL",
  1791. "createdBy",
  1792. "designer",
  1793. "designerURL",
  1794. "vendorURL",
  1795. "unitsPerEm",
  1796. "ascender",
  1797. "descender",
  1798. "capHeight",
  1799. "xHeight",
  1800. "defaultWidth",
  1801. "slantAngle",
  1802. "italicAngle",
  1803. "widthName",
  1804. "weightName",
  1805. "weightValue",
  1806. "fondName",
  1807. "otFamilyName",
  1808. "otStyleName",
  1809. "otMacName",
  1810. "msCharSet",
  1811. "fondID",
  1812. "uniqueID",
  1813. "ttVendor",
  1814. "ttUniqueID",
  1815. "ttVersion",
  1816. }
  1817. fontInfoAttributesVersion2ValueData: FontInfoAttributes = {
  1818. "familyName": dict(type=str),
  1819. "styleName": dict(type=str),
  1820. "styleMapFamilyName": dict(type=str),
  1821. "styleMapStyleName": dict(
  1822. type=str, valueValidator=fontInfoStyleMapStyleNameValidator
  1823. ),
  1824. "versionMajor": dict(type=int),
  1825. "versionMinor": dict(type=int),
  1826. "year": dict(type=int),
  1827. "copyright": dict(type=str),
  1828. "trademark": dict(type=str),
  1829. "unitsPerEm": dict(type=(int, float)),
  1830. "descender": dict(type=(int, float)),
  1831. "xHeight": dict(type=(int, float)),
  1832. "capHeight": dict(type=(int, float)),
  1833. "ascender": dict(type=(int, float)),
  1834. "italicAngle": dict(type=(float, int)),
  1835. "note": dict(type=str),
  1836. "openTypeHeadCreated": dict(
  1837. type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator
  1838. ),
  1839. "openTypeHeadLowestRecPPEM": dict(type=(int, float)),
  1840. "openTypeHeadFlags": dict(
  1841. type="integerList",
  1842. valueValidator=genericIntListValidator,
  1843. valueOptions=fontInfoOpenTypeHeadFlagsOptions,
  1844. ),
  1845. "openTypeHheaAscender": dict(type=(int, float)),
  1846. "openTypeHheaDescender": dict(type=(int, float)),
  1847. "openTypeHheaLineGap": dict(type=(int, float)),
  1848. "openTypeHheaCaretSlopeRise": dict(type=int),
  1849. "openTypeHheaCaretSlopeRun": dict(type=int),
  1850. "openTypeHheaCaretOffset": dict(type=(int, float)),
  1851. "openTypeNameDesigner": dict(type=str),
  1852. "openTypeNameDesignerURL": dict(type=str),
  1853. "openTypeNameManufacturer": dict(type=str),
  1854. "openTypeNameManufacturerURL": dict(type=str),
  1855. "openTypeNameLicense": dict(type=str),
  1856. "openTypeNameLicenseURL": dict(type=str),
  1857. "openTypeNameVersion": dict(type=str),
  1858. "openTypeNameUniqueID": dict(type=str),
  1859. "openTypeNameDescription": dict(type=str),
  1860. "openTypeNamePreferredFamilyName": dict(type=str),
  1861. "openTypeNamePreferredSubfamilyName": dict(type=str),
  1862. "openTypeNameCompatibleFullName": dict(type=str),
  1863. "openTypeNameSampleText": dict(type=str),
  1864. "openTypeNameWWSFamilyName": dict(type=str),
  1865. "openTypeNameWWSSubfamilyName": dict(type=str),
  1866. "openTypeOS2WidthClass": dict(
  1867. type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator
  1868. ),
  1869. "openTypeOS2WeightClass": dict(
  1870. type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator
  1871. ),
  1872. "openTypeOS2Selection": dict(
  1873. type="integerList",
  1874. valueValidator=genericIntListValidator,
  1875. valueOptions=fontInfoOpenTypeOS2SelectionOptions,
  1876. ),
  1877. "openTypeOS2VendorID": dict(type=str),
  1878. "openTypeOS2Panose": dict(
  1879. type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator
  1880. ),
  1881. "openTypeOS2FamilyClass": dict(
  1882. type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator
  1883. ),
  1884. "openTypeOS2UnicodeRanges": dict(
  1885. type="integerList",
  1886. valueValidator=genericIntListValidator,
  1887. valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions,
  1888. ),
  1889. "openTypeOS2CodePageRanges": dict(
  1890. type="integerList",
  1891. valueValidator=genericIntListValidator,
  1892. valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions,
  1893. ),
  1894. "openTypeOS2TypoAscender": dict(type=(int, float)),
  1895. "openTypeOS2TypoDescender": dict(type=(int, float)),
  1896. "openTypeOS2TypoLineGap": dict(type=(int, float)),
  1897. "openTypeOS2WinAscent": dict(type=(int, float)),
  1898. "openTypeOS2WinDescent": dict(type=(int, float)),
  1899. "openTypeOS2Type": dict(
  1900. type="integerList",
  1901. valueValidator=genericIntListValidator,
  1902. valueOptions=fontInfoOpenTypeOS2TypeOptions,
  1903. ),
  1904. "openTypeOS2SubscriptXSize": dict(type=(int, float)),
  1905. "openTypeOS2SubscriptYSize": dict(type=(int, float)),
  1906. "openTypeOS2SubscriptXOffset": dict(type=(int, float)),
  1907. "openTypeOS2SubscriptYOffset": dict(type=(int, float)),
  1908. "openTypeOS2SuperscriptXSize": dict(type=(int, float)),
  1909. "openTypeOS2SuperscriptYSize": dict(type=(int, float)),
  1910. "openTypeOS2SuperscriptXOffset": dict(type=(int, float)),
  1911. "openTypeOS2SuperscriptYOffset": dict(type=(int, float)),
  1912. "openTypeOS2StrikeoutSize": dict(type=(int, float)),
  1913. "openTypeOS2StrikeoutPosition": dict(type=(int, float)),
  1914. "openTypeVheaVertTypoAscender": dict(type=(int, float)),
  1915. "openTypeVheaVertTypoDescender": dict(type=(int, float)),
  1916. "openTypeVheaVertTypoLineGap": dict(type=(int, float)),
  1917. "openTypeVheaCaretSlopeRise": dict(type=int),
  1918. "openTypeVheaCaretSlopeRun": dict(type=int),
  1919. "openTypeVheaCaretOffset": dict(type=(int, float)),
  1920. "postscriptFontName": dict(type=str),
  1921. "postscriptFullName": dict(type=str),
  1922. "postscriptSlantAngle": dict(type=(float, int)),
  1923. "postscriptUniqueID": dict(type=int),
  1924. "postscriptUnderlineThickness": dict(type=(int, float)),
  1925. "postscriptUnderlinePosition": dict(type=(int, float)),
  1926. "postscriptIsFixedPitch": dict(type=bool),
  1927. "postscriptBlueValues": dict(
  1928. type="integerList", valueValidator=fontInfoPostscriptBluesValidator
  1929. ),
  1930. "postscriptOtherBlues": dict(
  1931. type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
  1932. ),
  1933. "postscriptFamilyBlues": dict(
  1934. type="integerList", valueValidator=fontInfoPostscriptBluesValidator
  1935. ),
  1936. "postscriptFamilyOtherBlues": dict(
  1937. type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
  1938. ),
  1939. "postscriptStemSnapH": dict(
  1940. type="integerList", valueValidator=fontInfoPostscriptStemsValidator
  1941. ),
  1942. "postscriptStemSnapV": dict(
  1943. type="integerList", valueValidator=fontInfoPostscriptStemsValidator
  1944. ),
  1945. "postscriptBlueFuzz": dict(type=(int, float)),
  1946. "postscriptBlueShift": dict(type=(int, float)),
  1947. "postscriptBlueScale": dict(type=(float, int)),
  1948. "postscriptForceBold": dict(type=bool),
  1949. "postscriptDefaultWidthX": dict(type=(int, float)),
  1950. "postscriptNominalWidthX": dict(type=(int, float)),
  1951. "postscriptWeightName": dict(type=str),
  1952. "postscriptDefaultCharacter": dict(type=str),
  1953. "postscriptWindowsCharacterSet": dict(
  1954. type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator
  1955. ),
  1956. "macintoshFONDFamilyID": dict(type=int),
  1957. "macintoshFONDName": dict(type=str),
  1958. }
  1959. fontInfoAttributesVersion2: set[str] = set(fontInfoAttributesVersion2ValueData.keys())
  1960. fontInfoAttributesVersion3ValueData: FontInfoAttributes = deepcopy(
  1961. fontInfoAttributesVersion2ValueData
  1962. )
  1963. fontInfoAttributesVersion3ValueData.update(
  1964. {
  1965. "versionMinor": dict(type=int, valueValidator=genericNonNegativeIntValidator),
  1966. "unitsPerEm": dict(
  1967. type=(int, float), valueValidator=genericNonNegativeNumberValidator
  1968. ),
  1969. "openTypeHeadLowestRecPPEM": dict(
  1970. type=int, valueValidator=genericNonNegativeNumberValidator
  1971. ),
  1972. "openTypeHheaAscender": dict(type=int),
  1973. "openTypeHheaDescender": dict(type=int),
  1974. "openTypeHheaLineGap": dict(type=int),
  1975. "openTypeHheaCaretOffset": dict(type=int),
  1976. "openTypeOS2Panose": dict(
  1977. type="integerList",
  1978. valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator,
  1979. ),
  1980. "openTypeOS2TypoAscender": dict(type=int),
  1981. "openTypeOS2TypoDescender": dict(type=int),
  1982. "openTypeOS2TypoLineGap": dict(type=int),
  1983. "openTypeOS2WinAscent": dict(
  1984. type=int, valueValidator=genericNonNegativeNumberValidator
  1985. ),
  1986. "openTypeOS2WinDescent": dict(
  1987. type=int, valueValidator=genericNonNegativeNumberValidator
  1988. ),
  1989. "openTypeOS2SubscriptXSize": dict(type=int),
  1990. "openTypeOS2SubscriptYSize": dict(type=int),
  1991. "openTypeOS2SubscriptXOffset": dict(type=int),
  1992. "openTypeOS2SubscriptYOffset": dict(type=int),
  1993. "openTypeOS2SuperscriptXSize": dict(type=int),
  1994. "openTypeOS2SuperscriptYSize": dict(type=int),
  1995. "openTypeOS2SuperscriptXOffset": dict(type=int),
  1996. "openTypeOS2SuperscriptYOffset": dict(type=int),
  1997. "openTypeOS2StrikeoutSize": dict(type=int),
  1998. "openTypeOS2StrikeoutPosition": dict(type=int),
  1999. "openTypeGaspRangeRecords": dict(
  2000. type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator
  2001. ),
  2002. "openTypeNameRecords": dict(
  2003. type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator
  2004. ),
  2005. "openTypeVheaVertTypoAscender": dict(type=int),
  2006. "openTypeVheaVertTypoDescender": dict(type=int),
  2007. "openTypeVheaVertTypoLineGap": dict(type=int),
  2008. "openTypeVheaCaretOffset": dict(type=int),
  2009. "woffMajorVersion": dict(
  2010. type=int, valueValidator=genericNonNegativeIntValidator
  2011. ),
  2012. "woffMinorVersion": dict(
  2013. type=int, valueValidator=genericNonNegativeIntValidator
  2014. ),
  2015. "woffMetadataUniqueID": dict(
  2016. type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator
  2017. ),
  2018. "woffMetadataVendor": dict(
  2019. type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator
  2020. ),
  2021. "woffMetadataCredits": dict(
  2022. type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator
  2023. ),
  2024. "woffMetadataDescription": dict(
  2025. type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator
  2026. ),
  2027. "woffMetadataLicense": dict(
  2028. type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator
  2029. ),
  2030. "woffMetadataCopyright": dict(
  2031. type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator
  2032. ),
  2033. "woffMetadataTrademark": dict(
  2034. type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator
  2035. ),
  2036. "woffMetadataLicensee": dict(
  2037. type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator
  2038. ),
  2039. "woffMetadataExtensions": dict(
  2040. type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator
  2041. ),
  2042. "guidelines": dict(type=list, valueValidator=guidelinesValidator),
  2043. }
  2044. )
  2045. fontInfoAttributesVersion3: set[str] = set(fontInfoAttributesVersion3ValueData.keys())
  2046. # insert the type validator for all attrs that
  2047. # have no defined validator.
  2048. for attr, dataDict in list(fontInfoAttributesVersion2ValueData.items()):
  2049. if "valueValidator" not in dataDict:
  2050. dataDict["valueValidator"] = genericTypeValidator
  2051. for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()):
  2052. if "valueValidator" not in dataDict:
  2053. dataDict["valueValidator"] = genericTypeValidator
  2054. # Version Conversion Support
  2055. # These are used from converting from version 1
  2056. # to version 2 or vice-versa.
  2057. def _flipDict(d: dict[K, V]) -> dict[V, K]:
  2058. flipped = {}
  2059. for key, value in list(d.items()):
  2060. flipped[value] = key
  2061. return flipped
  2062. fontInfoAttributesVersion1To2: dict[str, str] = {
  2063. "menuName": "styleMapFamilyName",
  2064. "designer": "openTypeNameDesigner",
  2065. "designerURL": "openTypeNameDesignerURL",
  2066. "createdBy": "openTypeNameManufacturer",
  2067. "vendorURL": "openTypeNameManufacturerURL",
  2068. "license": "openTypeNameLicense",
  2069. "licenseURL": "openTypeNameLicenseURL",
  2070. "ttVersion": "openTypeNameVersion",
  2071. "ttUniqueID": "openTypeNameUniqueID",
  2072. "notice": "openTypeNameDescription",
  2073. "otFamilyName": "openTypeNamePreferredFamilyName",
  2074. "otStyleName": "openTypeNamePreferredSubfamilyName",
  2075. "otMacName": "openTypeNameCompatibleFullName",
  2076. "weightName": "postscriptWeightName",
  2077. "weightValue": "openTypeOS2WeightClass",
  2078. "ttVendor": "openTypeOS2VendorID",
  2079. "uniqueID": "postscriptUniqueID",
  2080. "fontName": "postscriptFontName",
  2081. "fondID": "macintoshFONDFamilyID",
  2082. "fondName": "macintoshFONDName",
  2083. "defaultWidth": "postscriptDefaultWidthX",
  2084. "slantAngle": "postscriptSlantAngle",
  2085. "fullName": "postscriptFullName",
  2086. # require special value conversion
  2087. "fontStyle": "styleMapStyleName",
  2088. "widthName": "openTypeOS2WidthClass",
  2089. "msCharSet": "postscriptWindowsCharacterSet",
  2090. }
  2091. fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2)
  2092. deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys())
  2093. _fontStyle1To2: dict[int, str] = {
  2094. 64: "regular",
  2095. 1: "italic",
  2096. 32: "bold",
  2097. 33: "bold italic",
  2098. }
  2099. _fontStyle2To1: dict[str, int] = _flipDict(_fontStyle1To2)
  2100. # Some UFO 1 files have 0
  2101. _fontStyle1To2[0] = "regular"
  2102. _widthName1To2: dict[str, int] = {
  2103. "Ultra-condensed": 1,
  2104. "Extra-condensed": 2,
  2105. "Condensed": 3,
  2106. "Semi-condensed": 4,
  2107. "Medium (normal)": 5,
  2108. "Semi-expanded": 6,
  2109. "Expanded": 7,
  2110. "Extra-expanded": 8,
  2111. "Ultra-expanded": 9,
  2112. }
  2113. _widthName2To1: dict[int, str] = _flipDict(_widthName1To2)
  2114. # FontLab's default width value is "Normal".
  2115. # Many format version 1 UFOs will have this.
  2116. _widthName1To2["Normal"] = 5
  2117. # FontLab has an "All" width value. In UFO 1
  2118. # move this up to "Normal".
  2119. _widthName1To2["All"] = 5
  2120. # "medium" appears in a lot of UFO 1 files.
  2121. _widthName1To2["medium"] = 5
  2122. # "Medium" appears in a lot of UFO 1 files.
  2123. _widthName1To2["Medium"] = 5
  2124. _msCharSet1To2: dict[int, int] = {
  2125. 0: 1,
  2126. 1: 2,
  2127. 2: 3,
  2128. 77: 4,
  2129. 128: 5,
  2130. 129: 6,
  2131. 130: 7,
  2132. 134: 8,
  2133. 136: 9,
  2134. 161: 10,
  2135. 162: 11,
  2136. 163: 12,
  2137. 177: 13,
  2138. 178: 14,
  2139. 186: 15,
  2140. 200: 16,
  2141. 204: 17,
  2142. 222: 18,
  2143. 238: 19,
  2144. 255: 20,
  2145. }
  2146. _msCharSet2To1: dict[int, int] = _flipDict(_msCharSet1To2)
  2147. # 1 <-> 2
  2148. def convertFontInfoValueForAttributeFromVersion1ToVersion2(
  2149. attr: str, value: Any
  2150. ) -> tuple[str, Any]:
  2151. """
  2152. Convert value from version 1 to version 2 format.
  2153. Returns the new attribute name and the converted value.
  2154. If the value is None, None will be returned for the new value.
  2155. """
  2156. # convert floats to ints if possible
  2157. if isinstance(value, float):
  2158. if int(value) == value:
  2159. value = int(value)
  2160. if value is not None:
  2161. if attr == "fontStyle":
  2162. v: Optional[Union[str, int]] = _fontStyle1To2.get(value)
  2163. if v is None:
  2164. raise UFOLibError(
  2165. f"Cannot convert value ({value!r}) for attribute {attr}."
  2166. )
  2167. value = v
  2168. elif attr == "widthName":
  2169. v = _widthName1To2.get(value)
  2170. if v is None:
  2171. raise UFOLibError(
  2172. f"Cannot convert value ({value!r}) for attribute {attr}."
  2173. )
  2174. value = v
  2175. elif attr == "msCharSet":
  2176. v = _msCharSet1To2.get(value)
  2177. if v is None:
  2178. raise UFOLibError(
  2179. f"Cannot convert value ({value!r}) for attribute {attr}."
  2180. )
  2181. value = v
  2182. attr = fontInfoAttributesVersion1To2.get(attr, attr)
  2183. return attr, value
  2184. def convertFontInfoValueForAttributeFromVersion2ToVersion1(
  2185. attr: str, value: Any
  2186. ) -> tuple[str, Any]:
  2187. """
  2188. Convert value from version 2 to version 1 format.
  2189. Returns the new attribute name and the converted value.
  2190. If the value is None, None will be returned for the new value.
  2191. """
  2192. if value is not None:
  2193. if attr == "styleMapStyleName":
  2194. value = _fontStyle2To1.get(value)
  2195. elif attr == "openTypeOS2WidthClass":
  2196. value = _widthName2To1.get(value)
  2197. elif attr == "postscriptWindowsCharacterSet":
  2198. value = _msCharSet2To1.get(value)
  2199. attr = fontInfoAttributesVersion2To1.get(attr, attr)
  2200. return attr, value
  2201. def _convertFontInfoDataVersion1ToVersion2(data: dict[str, Any]) -> dict[str, Any]:
  2202. converted = {}
  2203. for attr, value in list(data.items()):
  2204. # FontLab gives -1 for the weightValue
  2205. # for fonts wil no defined value. Many
  2206. # format version 1 UFOs will have this.
  2207. if attr == "weightValue" and value == -1:
  2208. continue
  2209. newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2(
  2210. attr, value
  2211. )
  2212. # skip if the attribute is not part of version 2
  2213. if newAttr not in fontInfoAttributesVersion2:
  2214. continue
  2215. # catch values that can't be converted
  2216. if value is None:
  2217. raise UFOLibError(
  2218. f"Cannot convert value ({value!r}) for attribute {newAttr}."
  2219. )
  2220. # store
  2221. converted[newAttr] = newValue
  2222. return converted
  2223. def _convertFontInfoDataVersion2ToVersion1(data: dict[str, Any]) -> dict[str, Any]:
  2224. converted = {}
  2225. for attr, value in list(data.items()):
  2226. newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1(
  2227. attr, value
  2228. )
  2229. # only take attributes that are registered for version 1
  2230. if newAttr not in fontInfoAttributesVersion1:
  2231. continue
  2232. # catch values that can't be converted
  2233. if value is None:
  2234. raise UFOLibError(
  2235. f"Cannot convert value ({value!r}) for attribute {newAttr}."
  2236. )
  2237. # store
  2238. converted[newAttr] = newValue
  2239. return converted
  2240. # 2 <-> 3
  2241. _ufo2To3NonNegativeInt: set[str] = {
  2242. "versionMinor",
  2243. "openTypeHeadLowestRecPPEM",
  2244. "openTypeOS2WinAscent",
  2245. "openTypeOS2WinDescent",
  2246. }
  2247. _ufo2To3NonNegativeIntOrFloat: set[str] = {
  2248. "unitsPerEm",
  2249. }
  2250. _ufo2To3FloatToInt: set[str] = {
  2251. "openTypeHeadLowestRecPPEM",
  2252. "openTypeHheaAscender",
  2253. "openTypeHheaDescender",
  2254. "openTypeHheaLineGap",
  2255. "openTypeHheaCaretOffset",
  2256. "openTypeOS2TypoAscender",
  2257. "openTypeOS2TypoDescender",
  2258. "openTypeOS2TypoLineGap",
  2259. "openTypeOS2WinAscent",
  2260. "openTypeOS2WinDescent",
  2261. "openTypeOS2SubscriptXSize",
  2262. "openTypeOS2SubscriptYSize",
  2263. "openTypeOS2SubscriptXOffset",
  2264. "openTypeOS2SubscriptYOffset",
  2265. "openTypeOS2SuperscriptXSize",
  2266. "openTypeOS2SuperscriptYSize",
  2267. "openTypeOS2SuperscriptXOffset",
  2268. "openTypeOS2SuperscriptYOffset",
  2269. "openTypeOS2StrikeoutSize",
  2270. "openTypeOS2StrikeoutPosition",
  2271. "openTypeVheaVertTypoAscender",
  2272. "openTypeVheaVertTypoDescender",
  2273. "openTypeVheaVertTypoLineGap",
  2274. "openTypeVheaCaretOffset",
  2275. }
  2276. def convertFontInfoValueForAttributeFromVersion2ToVersion3(
  2277. attr: str, value: Any
  2278. ) -> tuple[str, Any]:
  2279. """
  2280. Convert value from version 2 to version 3 format.
  2281. Returns the new attribute name and the converted value.
  2282. If the value is None, None will be returned for the new value.
  2283. """
  2284. if attr in _ufo2To3FloatToInt:
  2285. try:
  2286. value = round(value)
  2287. except (ValueError, TypeError):
  2288. raise UFOLibError("Could not convert value for %s." % attr)
  2289. if attr in _ufo2To3NonNegativeInt:
  2290. try:
  2291. value = int(abs(value))
  2292. except (ValueError, TypeError):
  2293. raise UFOLibError("Could not convert value for %s." % attr)
  2294. elif attr in _ufo2To3NonNegativeIntOrFloat:
  2295. try:
  2296. v = float(abs(value))
  2297. except (ValueError, TypeError):
  2298. raise UFOLibError("Could not convert value for %s." % attr)
  2299. if v == int(v):
  2300. v = int(v)
  2301. if v != value:
  2302. value = v
  2303. return attr, value
  2304. def convertFontInfoValueForAttributeFromVersion3ToVersion2(
  2305. attr: str, value: Any
  2306. ) -> tuple[str, Any]:
  2307. """
  2308. Convert value from version 3 to version 2 format.
  2309. Returns the new attribute name and the converted value.
  2310. If the value is None, None will be returned for the new value.
  2311. """
  2312. return attr, value
  2313. def _convertFontInfoDataVersion3ToVersion2(data: dict[str, Any]) -> dict[str, Any]:
  2314. converted = {}
  2315. for attr, value in list(data.items()):
  2316. newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2(
  2317. attr, value
  2318. )
  2319. if newAttr not in fontInfoAttributesVersion2:
  2320. continue
  2321. converted[newAttr] = newValue
  2322. return converted
  2323. def _convertFontInfoDataVersion2ToVersion3(data: dict[str, Any]) -> dict[str, Any]:
  2324. converted = {}
  2325. for attr, value in list(data.items()):
  2326. attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3(
  2327. attr, value
  2328. )
  2329. converted[attr] = value
  2330. return converted
  2331. if __name__ == "__main__":
  2332. import doctest
  2333. doctest.testmod()