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