fonttools 4.58.3__cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fonttools might be problematic. Click here for more details.

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