jumpy-lion 1.0.0-beta.0 → 1.0.0-beta.4

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 (249) hide show
  1. package/browser/fonts/macos-bundle/41-macos-remaining.conf +157 -0
  2. package/browser/fonts/macos-bundle/build-metric-fonts.py +1010 -0
  3. package/browser/fonts/macos-bundle/generated/AmericanTypewriter-Bold.ttf +0 -0
  4. package/browser/fonts/macos-bundle/generated/AmericanTypewriter-Regular.ttf +0 -0
  5. package/browser/fonts/macos-bundle/generated/AndaleMono-Bold.ttf +0 -0
  6. package/browser/fonts/macos-bundle/generated/AndaleMono-BoldItalic.ttf +0 -0
  7. package/browser/fonts/macos-bundle/generated/AndaleMono-Italic.ttf +0 -0
  8. package/browser/fonts/macos-bundle/generated/AndaleMono-Regular.ttf +0 -0
  9. package/browser/fonts/macos-bundle/generated/AppleChancery-Bold.ttf +0 -0
  10. package/browser/fonts/macos-bundle/generated/AppleChancery-BoldItalic.ttf +0 -0
  11. package/browser/fonts/macos-bundle/generated/AppleChancery-Italic.ttf +0 -0
  12. package/browser/fonts/macos-bundle/generated/AppleChancery-Regular.ttf +0 -0
  13. package/browser/fonts/macos-bundle/generated/Arial-Bold.ttf +0 -0
  14. package/browser/fonts/macos-bundle/generated/Arial-BoldItalic.ttf +0 -0
  15. package/browser/fonts/macos-bundle/generated/Arial-Italic.ttf +0 -0
  16. package/browser/fonts/macos-bundle/generated/Arial-Regular.ttf +0 -0
  17. package/browser/fonts/macos-bundle/generated/ArialBlack-Bold.ttf +0 -0
  18. package/browser/fonts/macos-bundle/generated/ArialBlack-BoldItalic.ttf +0 -0
  19. package/browser/fonts/macos-bundle/generated/ArialBlack-Italic.ttf +0 -0
  20. package/browser/fonts/macos-bundle/generated/ArialBlack-Regular.ttf +0 -0
  21. package/browser/fonts/macos-bundle/generated/ArialNarrow-Bold.ttf +0 -0
  22. package/browser/fonts/macos-bundle/generated/ArialNarrow-BoldItalic.ttf +0 -0
  23. package/browser/fonts/macos-bundle/generated/ArialNarrow-Italic.ttf +0 -0
  24. package/browser/fonts/macos-bundle/generated/ArialNarrow-Regular.ttf +0 -0
  25. package/browser/fonts/macos-bundle/generated/Avenir-Bold.ttf +0 -0
  26. package/browser/fonts/macos-bundle/generated/Avenir-BoldItalic.ttf +0 -0
  27. package/browser/fonts/macos-bundle/generated/Avenir-Italic.ttf +0 -0
  28. package/browser/fonts/macos-bundle/generated/Avenir-Regular.ttf +0 -0
  29. package/browser/fonts/macos-bundle/generated/AvenirNext-Bold.ttf +0 -0
  30. package/browser/fonts/macos-bundle/generated/AvenirNext-BoldItalic.ttf +0 -0
  31. package/browser/fonts/macos-bundle/generated/AvenirNext-Italic.ttf +0 -0
  32. package/browser/fonts/macos-bundle/generated/AvenirNext-Regular.ttf +0 -0
  33. package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Bold.ttf +0 -0
  34. package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-BoldItalic.ttf +0 -0
  35. package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Italic.ttf +0 -0
  36. package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Regular.ttf +0 -0
  37. package/browser/fonts/macos-bundle/generated/Baskerville-Bold.ttf +0 -0
  38. package/browser/fonts/macos-bundle/generated/Baskerville-BoldItalic.ttf +0 -0
  39. package/browser/fonts/macos-bundle/generated/Baskerville-Italic.ttf +0 -0
  40. package/browser/fonts/macos-bundle/generated/Baskerville-Regular.ttf +0 -0
  41. package/browser/fonts/macos-bundle/generated/BigCaslon-Bold.ttf +0 -0
  42. package/browser/fonts/macos-bundle/generated/BigCaslon-BoldItalic.ttf +0 -0
  43. package/browser/fonts/macos-bundle/generated/BigCaslon-Italic.ttf +0 -0
  44. package/browser/fonts/macos-bundle/generated/BigCaslon-Regular.ttf +0 -0
  45. package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Bold.ttf +0 -0
  46. package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-BoldItalic.ttf +0 -0
  47. package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Italic.ttf +0 -0
  48. package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Regular.ttf +0 -0
  49. package/browser/fonts/macos-bundle/generated/Bodoni72-Bold.ttf +0 -0
  50. package/browser/fonts/macos-bundle/generated/Bodoni72-BoldItalic.ttf +0 -0
  51. package/browser/fonts/macos-bundle/generated/Bodoni72-Italic.ttf +0 -0
  52. package/browser/fonts/macos-bundle/generated/Bodoni72-Regular.ttf +0 -0
  53. package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Bold.ttf +0 -0
  54. package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-BoldItalic.ttf +0 -0
  55. package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Italic.ttf +0 -0
  56. package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Regular.ttf +0 -0
  57. package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Bold.ttf +0 -0
  58. package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-BoldItalic.ttf +0 -0
  59. package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Italic.ttf +0 -0
  60. package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Regular.ttf +0 -0
  61. package/browser/fonts/macos-bundle/generated/BradleyHand-Regular.ttf +0 -0
  62. package/browser/fonts/macos-bundle/generated/BrushScriptMT-Regular.ttf +0 -0
  63. package/browser/fonts/macos-bundle/generated/Chalkboard-Bold.ttf +0 -0
  64. package/browser/fonts/macos-bundle/generated/Chalkboard-BoldItalic.ttf +0 -0
  65. package/browser/fonts/macos-bundle/generated/Chalkboard-Italic.ttf +0 -0
  66. package/browser/fonts/macos-bundle/generated/Chalkboard-Regular.ttf +0 -0
  67. package/browser/fonts/macos-bundle/generated/ChalkboardSE-Bold.ttf +0 -0
  68. package/browser/fonts/macos-bundle/generated/ChalkboardSE-BoldItalic.ttf +0 -0
  69. package/browser/fonts/macos-bundle/generated/ChalkboardSE-Italic.ttf +0 -0
  70. package/browser/fonts/macos-bundle/generated/ChalkboardSE-Regular.ttf +0 -0
  71. package/browser/fonts/macos-bundle/generated/Chalkduster-Bold.ttf +0 -0
  72. package/browser/fonts/macos-bundle/generated/Chalkduster-BoldItalic.ttf +0 -0
  73. package/browser/fonts/macos-bundle/generated/Chalkduster-Italic.ttf +0 -0
  74. package/browser/fonts/macos-bundle/generated/Chalkduster-Regular.ttf +0 -0
  75. package/browser/fonts/macos-bundle/generated/Charter-Bold.ttf +0 -0
  76. package/browser/fonts/macos-bundle/generated/Charter-BoldItalic.ttf +0 -0
  77. package/browser/fonts/macos-bundle/generated/Charter-Italic.ttf +0 -0
  78. package/browser/fonts/macos-bundle/generated/Charter-Regular.ttf +0 -0
  79. package/browser/fonts/macos-bundle/generated/Cochin-Bold.ttf +0 -0
  80. package/browser/fonts/macos-bundle/generated/Cochin-BoldItalic.ttf +0 -0
  81. package/browser/fonts/macos-bundle/generated/Cochin-Italic.ttf +0 -0
  82. package/browser/fonts/macos-bundle/generated/Cochin-Regular.ttf +0 -0
  83. package/browser/fonts/macos-bundle/generated/ComicSansMS-Bold.ttf +0 -0
  84. package/browser/fonts/macos-bundle/generated/ComicSansMS-BoldItalic.ttf +0 -0
  85. package/browser/fonts/macos-bundle/generated/ComicSansMS-Italic.ttf +0 -0
  86. package/browser/fonts/macos-bundle/generated/ComicSansMS-Regular.ttf +0 -0
  87. package/browser/fonts/macos-bundle/generated/Copperplate-Bold.ttf +0 -0
  88. package/browser/fonts/macos-bundle/generated/Copperplate-Regular.ttf +0 -0
  89. package/browser/fonts/macos-bundle/generated/Courier-Bold.ttf +0 -0
  90. package/browser/fonts/macos-bundle/generated/Courier-BoldItalic.ttf +0 -0
  91. package/browser/fonts/macos-bundle/generated/Courier-Italic.ttf +0 -0
  92. package/browser/fonts/macos-bundle/generated/Courier-Regular.ttf +0 -0
  93. package/browser/fonts/macos-bundle/generated/CourierNew-Regular.ttf +0 -0
  94. package/browser/fonts/macos-bundle/generated/Didot-Bold.ttf +0 -0
  95. package/browser/fonts/macos-bundle/generated/Didot-BoldItalic.ttf +0 -0
  96. package/browser/fonts/macos-bundle/generated/Didot-Italic.ttf +0 -0
  97. package/browser/fonts/macos-bundle/generated/Didot-Regular.ttf +0 -0
  98. package/browser/fonts/macos-bundle/generated/Futura-Bold.ttf +0 -0
  99. package/browser/fonts/macos-bundle/generated/Futura-BoldItalic.ttf +0 -0
  100. package/browser/fonts/macos-bundle/generated/Futura-Italic.ttf +0 -0
  101. package/browser/fonts/macos-bundle/generated/Futura-Regular.ttf +0 -0
  102. package/browser/fonts/macos-bundle/generated/Geneva-Regular.ttf +0 -0
  103. package/browser/fonts/macos-bundle/generated/Georgia-Bold.ttf +0 -0
  104. package/browser/fonts/macos-bundle/generated/Georgia-BoldItalic.ttf +0 -0
  105. package/browser/fonts/macos-bundle/generated/Georgia-Italic.ttf +0 -0
  106. package/browser/fonts/macos-bundle/generated/Georgia-Regular.ttf +0 -0
  107. package/browser/fonts/macos-bundle/generated/GillSans-Bold.ttf +0 -0
  108. package/browser/fonts/macos-bundle/generated/GillSans-BoldItalic.ttf +0 -0
  109. package/browser/fonts/macos-bundle/generated/GillSans-Italic.ttf +0 -0
  110. package/browser/fonts/macos-bundle/generated/GillSans-Regular.ttf +0 -0
  111. package/browser/fonts/macos-bundle/generated/Helvetica-Bold.ttf +0 -0
  112. package/browser/fonts/macos-bundle/generated/Helvetica-BoldItalic.ttf +0 -0
  113. package/browser/fonts/macos-bundle/generated/Helvetica-Italic.ttf +0 -0
  114. package/browser/fonts/macos-bundle/generated/Helvetica-Regular.ttf +0 -0
  115. package/browser/fonts/macos-bundle/generated/HelveticaNeue-Bold.ttf +0 -0
  116. package/browser/fonts/macos-bundle/generated/HelveticaNeue-BoldItalic.ttf +0 -0
  117. package/browser/fonts/macos-bundle/generated/HelveticaNeue-Italic.ttf +0 -0
  118. package/browser/fonts/macos-bundle/generated/HelveticaNeue-Regular.ttf +0 -0
  119. package/browser/fonts/macos-bundle/generated/Herculanum-Bold.ttf +0 -0
  120. package/browser/fonts/macos-bundle/generated/Herculanum-BoldItalic.ttf +0 -0
  121. package/browser/fonts/macos-bundle/generated/Herculanum-Italic.ttf +0 -0
  122. package/browser/fonts/macos-bundle/generated/Herculanum-Regular.ttf +0 -0
  123. package/browser/fonts/macos-bundle/generated/HoeflerText-Bold.ttf +0 -0
  124. package/browser/fonts/macos-bundle/generated/HoeflerText-BoldItalic.ttf +0 -0
  125. package/browser/fonts/macos-bundle/generated/HoeflerText-Italic.ttf +0 -0
  126. package/browser/fonts/macos-bundle/generated/HoeflerText-Regular.ttf +0 -0
  127. package/browser/fonts/macos-bundle/generated/Impact-Bold.ttf +0 -0
  128. package/browser/fonts/macos-bundle/generated/Impact-BoldItalic.ttf +0 -0
  129. package/browser/fonts/macos-bundle/generated/Impact-Italic.ttf +0 -0
  130. package/browser/fonts/macos-bundle/generated/Impact-Regular.ttf +0 -0
  131. package/browser/fonts/macos-bundle/generated/LucidaGrande-Bold.ttf +0 -0
  132. package/browser/fonts/macos-bundle/generated/LucidaGrande-Regular.ttf +0 -0
  133. package/browser/fonts/macos-bundle/generated/MarkerFelt-Bold.ttf +0 -0
  134. package/browser/fonts/macos-bundle/generated/MarkerFelt-BoldItalic.ttf +0 -0
  135. package/browser/fonts/macos-bundle/generated/MarkerFelt-Italic.ttf +0 -0
  136. package/browser/fonts/macos-bundle/generated/MarkerFelt-Regular.ttf +0 -0
  137. package/browser/fonts/macos-bundle/generated/Menlo-Regular.ttf +0 -0
  138. package/browser/fonts/macos-bundle/generated/Monaco-Bold.ttf +0 -0
  139. package/browser/fonts/macos-bundle/generated/Monaco-BoldItalic.ttf +0 -0
  140. package/browser/fonts/macos-bundle/generated/Monaco-Italic.ttf +0 -0
  141. package/browser/fonts/macos-bundle/generated/Monaco-Regular.ttf +0 -0
  142. package/browser/fonts/macos-bundle/generated/Noteworthy-Bold.ttf +0 -0
  143. package/browser/fonts/macos-bundle/generated/Noteworthy-Regular.ttf +0 -0
  144. package/browser/fonts/macos-bundle/generated/Optima-Bold.ttf +0 -0
  145. package/browser/fonts/macos-bundle/generated/Optima-BoldItalic.ttf +0 -0
  146. package/browser/fonts/macos-bundle/generated/Optima-Italic.ttf +0 -0
  147. package/browser/fonts/macos-bundle/generated/Optima-Regular.ttf +0 -0
  148. package/browser/fonts/macos-bundle/generated/Palatino-Bold.ttf +0 -0
  149. package/browser/fonts/macos-bundle/generated/Palatino-BoldItalic.ttf +0 -0
  150. package/browser/fonts/macos-bundle/generated/Palatino-Italic.ttf +0 -0
  151. package/browser/fonts/macos-bundle/generated/Palatino-Regular.ttf +0 -0
  152. package/browser/fonts/macos-bundle/generated/Papyrus-Bold.ttf +0 -0
  153. package/browser/fonts/macos-bundle/generated/Papyrus-BoldItalic.ttf +0 -0
  154. package/browser/fonts/macos-bundle/generated/Papyrus-Italic.ttf +0 -0
  155. package/browser/fonts/macos-bundle/generated/Papyrus-Regular.ttf +0 -0
  156. package/browser/fonts/macos-bundle/generated/PartyLET-Bold.ttf +0 -0
  157. package/browser/fonts/macos-bundle/generated/PartyLET-BoldItalic.ttf +0 -0
  158. package/browser/fonts/macos-bundle/generated/PartyLET-Italic.ttf +0 -0
  159. package/browser/fonts/macos-bundle/generated/PartyLET-Regular.ttf +0 -0
  160. package/browser/fonts/macos-bundle/generated/Phosphate-Regular.ttf +0 -0
  161. package/browser/fonts/macos-bundle/generated/README.md +132 -0
  162. package/browser/fonts/macos-bundle/generated/Rockwell-Bold.ttf +0 -0
  163. package/browser/fonts/macos-bundle/generated/Rockwell-BoldItalic.ttf +0 -0
  164. package/browser/fonts/macos-bundle/generated/Rockwell-Italic.ttf +0 -0
  165. package/browser/fonts/macos-bundle/generated/Rockwell-Regular.ttf +0 -0
  166. package/browser/fonts/macos-bundle/generated/SavoyeLET-Bold.ttf +0 -0
  167. package/browser/fonts/macos-bundle/generated/SavoyeLET-BoldItalic.ttf +0 -0
  168. package/browser/fonts/macos-bundle/generated/SavoyeLET-Italic.ttf +0 -0
  169. package/browser/fonts/macos-bundle/generated/SavoyeLET-Regular.ttf +0 -0
  170. package/browser/fonts/macos-bundle/generated/Seravek-Bold.ttf +0 -0
  171. package/browser/fonts/macos-bundle/generated/Seravek-BoldItalic.ttf +0 -0
  172. package/browser/fonts/macos-bundle/generated/Seravek-Italic.ttf +0 -0
  173. package/browser/fonts/macos-bundle/generated/Seravek-Regular.ttf +0 -0
  174. package/browser/fonts/macos-bundle/generated/SignPainter-Bold.ttf +0 -0
  175. package/browser/fonts/macos-bundle/generated/SignPainter-Regular.ttf +0 -0
  176. package/browser/fonts/macos-bundle/generated/Skia-Bold.ttf +0 -0
  177. package/browser/fonts/macos-bundle/generated/Skia-BoldItalic.ttf +0 -0
  178. package/browser/fonts/macos-bundle/generated/Skia-Italic.ttf +0 -0
  179. package/browser/fonts/macos-bundle/generated/Skia-Regular.ttf +0 -0
  180. package/browser/fonts/macos-bundle/generated/SnellRoundhand-Bold.ttf +0 -0
  181. package/browser/fonts/macos-bundle/generated/SnellRoundhand-Regular.ttf +0 -0
  182. package/browser/fonts/macos-bundle/generated/Tahoma-Bold.ttf +0 -0
  183. package/browser/fonts/macos-bundle/generated/Tahoma-Regular.ttf +0 -0
  184. package/browser/fonts/macos-bundle/generated/Times-Bold.ttf +0 -0
  185. package/browser/fonts/macos-bundle/generated/Times-BoldItalic.ttf +0 -0
  186. package/browser/fonts/macos-bundle/generated/Times-Italic.ttf +0 -0
  187. package/browser/fonts/macos-bundle/generated/Times-Regular.ttf +0 -0
  188. package/browser/fonts/macos-bundle/generated/TimesNewRoman-Bold.ttf +0 -0
  189. package/browser/fonts/macos-bundle/generated/TimesNewRoman-BoldItalic.ttf +0 -0
  190. package/browser/fonts/macos-bundle/generated/TimesNewRoman-Italic.ttf +0 -0
  191. package/browser/fonts/macos-bundle/generated/TimesNewRoman-Regular.ttf +0 -0
  192. package/browser/fonts/macos-bundle/generated/Trattatello-Regular.ttf +0 -0
  193. package/browser/fonts/macos-bundle/generated/TrebuchetMS-Bold.ttf +0 -0
  194. package/browser/fonts/macos-bundle/generated/TrebuchetMS-BoldItalic.ttf +0 -0
  195. package/browser/fonts/macos-bundle/generated/TrebuchetMS-Italic.ttf +0 -0
  196. package/browser/fonts/macos-bundle/generated/TrebuchetMS-Regular.ttf +0 -0
  197. package/browser/fonts/macos-bundle/generated/Verdana-Bold.ttf +0 -0
  198. package/browser/fonts/macos-bundle/generated/Verdana-BoldItalic.ttf +0 -0
  199. package/browser/fonts/macos-bundle/generated/Verdana-Italic.ttf +0 -0
  200. package/browser/fonts/macos-bundle/generated/Verdana-Regular.ttf +0 -0
  201. package/browser/fonts/macos-bundle/generated/Webdings-Bold.ttf +0 -0
  202. package/browser/fonts/macos-bundle/generated/Webdings-BoldItalic.ttf +0 -0
  203. package/browser/fonts/macos-bundle/generated/Webdings-Italic.ttf +0 -0
  204. package/browser/fonts/macos-bundle/generated/Webdings-Regular.ttf +0 -0
  205. package/browser/fonts/macos-bundle/generated/Zapfino-Bold.ttf +0 -0
  206. package/browser/fonts/macos-bundle/generated/Zapfino-BoldItalic.ttf +0 -0
  207. package/browser/fonts/macos-bundle/generated/Zapfino-Italic.ttf +0 -0
  208. package/browser/fonts/macos-bundle/generated/Zapfino-Regular.ttf +0 -0
  209. package/browser/fonts/macos-bundle/generated/manifest.json +2928 -0
  210. package/browser/fonts/macos-bundle/generated/systemui-Bold.ttf +0 -0
  211. package/browser/fonts/macos-bundle/generated/systemui-BoldItalic.ttf +0 -0
  212. package/browser/fonts/macos-bundle/generated/systemui-Italic.ttf +0 -0
  213. package/browser/fonts/macos-bundle/generated/systemui-Regular.ttf +0 -0
  214. package/browser/fonts/macos-bundle/install.sh +66 -13
  215. package/browser/fonts/macos-bundle/vendor/README.md +30 -0
  216. package/browser/fonts/macos-bundle/vendor/anton/Anton-Regular.ttf +0 -0
  217. package/browser/fonts/macos-bundle/vendor/anton/OFL.txt +93 -0
  218. package/browser/fonts/macos-bundle/vendor/gelasio/Gelasio-Italic-VariableFont_wght.ttf +0 -0
  219. package/browser/fonts/macos-bundle/vendor/gelasio/Gelasio-VariableFont_wght.ttf +0 -0
  220. package/browser/fonts/macos-bundle/vendor/gelasio/OFL.txt +93 -0
  221. package/browser/fonts/macos-bundle/verify-metric-fonts.py +171 -0
  222. package/dist/browser-plugin.d.ts.map +1 -1
  223. package/dist/browser-plugin.js +39 -10
  224. package/dist/browser-plugin.js.map +1 -1
  225. package/dist/browser-process/anti-detect-config.d.ts +18 -3
  226. package/dist/browser-process/anti-detect-config.d.ts.map +1 -1
  227. package/dist/browser-process/anti-detect-config.js +103 -8
  228. package/dist/browser-process/anti-detect-config.js.map +1 -1
  229. package/dist/browser-process/browser.d.ts.map +1 -1
  230. package/dist/browser-process/browser.js +67 -2
  231. package/dist/browser-process/browser.js.map +1 -1
  232. package/dist/browser-process/fingerprint-config.d.ts.map +1 -1
  233. package/dist/browser-process/fingerprint-config.js +44 -1
  234. package/dist/browser-process/fingerprint-config.js.map +1 -1
  235. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js +18 -18
  236. package/dist/fingerprinting/hardware-realism.d.ts +0 -9
  237. package/dist/fingerprinting/hardware-realism.d.ts.map +1 -1
  238. package/dist/fingerprinting/hardware-realism.js +21 -22
  239. package/dist/fingerprinting/hardware-realism.js.map +1 -1
  240. package/dist/fingerprinting/non-apify-fingerprint-generator.js +11 -11
  241. package/dist/fingerprinting/non-apify-fingerprint-generator.js.map +1 -1
  242. package/dist/fingerprinting/non-apify-profiles.json +1 -1
  243. package/dist/page.d.ts +5 -0
  244. package/dist/page.d.ts.map +1 -1
  245. package/dist/page.js +40 -2
  246. package/dist/page.js.map +1 -1
  247. package/dist/tsconfig.build.tsbuildinfo +1 -1
  248. package/package.json +1 -1
  249. package/browser/fonts/macos-bundle/40-macos-aliases.conf +0 -515
@@ -0,0 +1,1010 @@
1
+ #!/usr/bin/env python3
2
+ """Build one real font file per macOS family, carrying that family's own advances.
3
+
4
+ The problem this solves
5
+ -----------------------
6
+ The bundle used to reach every Mac family through a fontconfig alias, and one
7
+ clone served many aliases - Liberation Sans stood in for 21 of them. Measured per
8
+ character against real Chrome 151 on macOS, that collapsed 54 families onto 19
9
+ distinct advance signatures where a Mac has 41. Zapfino measured identically to
10
+ Helvetica. Times, Baskerville, Hoefler Text, Cochin, Rockwell, Tahoma, SF Mono,
11
+ Charter, Bookman and six more were one single face. Any page that measures two
12
+ families and compares them has found the container, and the comparison is a
13
+ one-liner.
14
+
15
+ It cannot be corrected with a per-family number. Fitting a per-glyph tracking
16
+ value and a proportional scale factor on one string and scoring them on strings
17
+ they were not fitted to (test/docker-linux/fit-advance-model.mjs) put the
18
+ worst-case error at 5.76% and 5.89% against 6.30% for doing nothing at all: the
19
+ error is specific to each glyph, so only a correction that carries every glyph's
20
+ own advance generalises.
21
+
22
+ What this does
23
+ --------------
24
+ For each Mac family: pick the installed face whose advance PROPORTIONS are
25
+ closest, copy it, overwrite the advance of every captured character with the
26
+ Mac's own value, replace its kerning with the Mac's measured pairs, and rename it
27
+ to the Mac family. The result is a font that is genuinely named "Copperplate" and
28
+ genuinely measures like the Mac's Copperplate, so the alias for it can go away.
29
+
30
+ Both halves are needed, because a string's width is the sum of its advances PLUS
31
+ the kerning of each adjacent pair and Chrome kerns by default. With exact
32
+ advances and the donor's kern pairs, the system face was still uniformly 0.6%
33
+ wide at EVERY size - which looks like a tracking error and is not one. Writing
34
+ the Mac's pairs in took that from 0 of 75 sizes within 0.5% to 75 of 75.
35
+
36
+ Advances are overwritten exactly, so the donor choice does not affect width at
37
+ all - it decides what the text LOOKS like, and so how far the ink bounding box
38
+ lands from the Mac's. Hence picking on shape after a best-fit uniform scale: a
39
+ donor that is a uniformly narrower cut of the same design is a good donor,
40
+ because the scale is absorbed when the advances are rewritten.
41
+
42
+ Each glyph's OUTLINE is then fitted to the Mac's ink box - the four
43
+ actualBoundingBox* numbers a page reads from the same measureText call - by a
44
+ per-glyph affine, and the face is rescaled to the Mac face's own units-per-em
45
+ first so that the grid the binary snaps metrics to is the grid the Mac uses.
46
+
47
+ What it does NOT fix
48
+ --------------------
49
+ Glyph SHAPES are still the donor's. The ink now starts and stops where the Mac's
50
+ does; what happens between those edges is the donor's outline, stretched. Text
51
+ looks wrong where the donor is a poor visual match (a script face like Zapfino
52
+ has no free equivalent), and a detector hashing canvas PIXELS rather than metrics
53
+ still sees a different face.
54
+
55
+ All four faces are generated - regular, bold, italic, bold-italic - wherever the
56
+ Mac has a real one.
57
+
58
+ An earlier version built only regular and bold, on the measurement that italic
59
+ advances were "within 1% of regular". That came from a ten-family sample and does
60
+ not survive the full set: across 65 families, 33 have italic advances that differ
61
+ from regular, by 10.21% on average for Cochin, 10.14% for Baskerville, 9.33% for
62
+ Hoefler Text (90% on its worst character), 5.68% for Palatino and 5.03% for
63
+ Times. Synthesising those from the regular face is the same error as
64
+ synthesising bold was.
65
+
66
+ A style is skipped only when NOT building it lands on the same advances anyway,
67
+ which means asking what SKIA would synthesise from - not what CoreText did. The
68
+ two disagree on bold-italic: the Mac synthesises it from the BOLD face, Skia
69
+ picks the ITALIC face when one exists. Skipping on the Mac's answer left five
70
+ script families measuring their italic advances for bold-italic text, up to 120%
71
+ off on a character.
72
+
73
+ When a style's advances are identical to one already built, it is built from the
74
+ SAME donor file, so the two round to identical font units. Otherwise they would
75
+ measurably differ where the Mac has them exactly equal - the mirror of the
76
+ collapse this whole exercise removes, and just as usable by a page.
77
+
78
+ Usage (inside a container that has the clone fonts installed):
79
+ python3 build-metric-fonts.py \
80
+ --advances char-advances-macos-chrome151.json \
81
+ --out /out
82
+ """
83
+
84
+ import argparse
85
+ import json
86
+ import os
87
+ import re
88
+ import subprocess
89
+ import sys
90
+ import tempfile
91
+ from collections import defaultdict
92
+
93
+ from fontTools.feaLib.builder import addOpenTypeFeatures
94
+ from fontTools.pens.boundsPen import BoundsPen
95
+ from fontTools.pens.recordingPen import DecomposingRecordingPen
96
+ from fontTools.pens.t2CharStringPen import T2CharStringPen
97
+ from fontTools.pens.transformPen import TransformPen
98
+ from fontTools.pens.ttGlyphPen import TTGlyphPen
99
+ from fontTools.ttLib import TTFont
100
+ from fontTools.ttLib.scaleUpem import scale_upem
101
+ from fontTools.varLib import instancer
102
+
103
+ # The CSS generics are not families to build: they are whatever fontconfig
104
+ # resolves by default, which the alias file sets.
105
+ GENERICS = {"sans-serif", "serif", "monospace", "cursive", "fantasy"}
106
+
107
+ # Families the bundle reaches with a face that is genuinely the right shape and
108
+ # script, where substituting a Latin donor would be a regression.
109
+ SKIP = {"Apple Color Emoji", "Apple Symbols", "Apple Braille"}
110
+
111
+ # Faces to build under a second name, from another family's capture.
112
+ # `system-ui` never reaches the font cache under that spelling: for a macOS claim
113
+ # FontCache::SetSystemFontFamily points it at "BlinkMacSystemFont", and Chrome
114
+ # DCHECKs that "system-ui" itself never gets there. So the system face has to
115
+ # exist under the internal name as well, or unstyled UI text - most of the text
116
+ # on a modern page - falls back.
117
+ EXTRA_NAMES = {"BlinkMacSystemFont": "system-ui"}
118
+
119
+ # What each CSS generic resolves to on macOS. Used to tell a family that is
120
+ # genuinely installed from one that merely FELL BACK during the capture: if a
121
+ # family's advances are identical to a generic's, the Mac has no such font and
122
+ # answered with the default. Building a face for it would be a leak pointing the
123
+ # other way - the container would then resolve a family the claimed OS does not
124
+ # have. Measured on macOS 26.5.2, that catches ZapfDingbats and Bookman, both of
125
+ # which are in the binary's canvas allowlist and should not be.
126
+ GENERIC_TARGETS = {
127
+ "sans-serif": "Helvetica",
128
+ "serif": "Times",
129
+ "monospace": "Menlo",
130
+ "cursive": "Apple Chancery",
131
+ "fantasy": "Papyrus",
132
+ }
133
+
134
+ # A donor must cover EVERY character we have a Mac advance for, and must be able
135
+ # to carry each of those advances independently. Two separate failures made both
136
+ # halves of that non-negotiable:
137
+ #
138
+ # coverage - the scorer will otherwise pick whichever face shares the FEWEST
139
+ # codepoints with the target, because a small subset is trivially easy to
140
+ # fit. An early run handed fourteen families to Noto Sans Symbols, which
141
+ # covers 64 of the 199 captured characters. Even one missing character is a
142
+ # leak, not a rounding error: Chrome falls back to a DIFFERENT font for it,
143
+ # so the width comes from that font instead. Carlito-Bold lacks U+00A0, and
144
+ # that single gap put Chalkboard's no-break space 19.7% off the Mac.
145
+ #
146
+ # distinct glyphs - a donor that maps two characters to one glyph can only
147
+ # give them one advance. That is fine when the Mac agrees, but 21 of the
148
+ # measured macOS faces give U+00A0 a different advance from U+0020 (Geneva
149
+ # and Charter double it, Hoefler Text more than triples it, Skia halves it),
150
+ # and a shared glyph silently drops the difference.
151
+ #
152
+ # 148 of the 176 full-coverage donors put U+00A0 on its own glyph, so requiring
153
+ # both costs nothing in donor choice and needs no glyph surgery.
154
+ REQUIRE_FULL_COVERAGE = True
155
+
156
+
157
+ def log(msg):
158
+ print(f"[build-metric-fonts] {msg}", flush=True)
159
+
160
+
161
+ def candidate_files():
162
+ """Every installed font file, from fontconfig, newest-style paths first."""
163
+ try:
164
+ raw = subprocess.run(
165
+ ["fc-list", "--format", "%{file}\n"],
166
+ capture_output=True, text=True, check=True,
167
+ ).stdout
168
+ except (subprocess.CalledProcessError, FileNotFoundError) as err:
169
+ sys.exit(f"fc-list unavailable: {err}")
170
+ files = sorted({line.strip() for line in raw.splitlines() if line.strip()})
171
+ # Collections and bitmap-only faces are not usable donors here.
172
+ return [f for f in files if f.lower().endswith((".ttf", ".otf"))]
173
+
174
+
175
+ def face_style(font):
176
+ """"regular", "bold", "italic", "bolditalic", or None for anything else.
177
+
178
+ Read from the font's own tables rather than from its filename. The filename
179
+ test this replaces missed every "-BoldItalic" file, because a word-boundary
180
+ match for "bold" does not fire in "BoldItalic" - so the first run picked
181
+ Tinos-BoldItalic as the donor for Apple Chancery and Inter-SemiBoldItalic for
182
+ the system face, handing both the wrong proportions before any advance was
183
+ written.
184
+ """
185
+ try:
186
+ italic = bool(font["head"].macStyle & 0b10)
187
+ bold = bool(font["head"].macStyle & 0b1)
188
+ os2 = font.get("OS/2")
189
+ if os2 is not None:
190
+ italic = italic or bool(os2.fsSelection & 0b1)
191
+ bold = bold or bool(os2.fsSelection & 0b100000)
192
+ weight = os2.usWeightClass
193
+ # Light, black and other weights are not donors for any of the four
194
+ # faces we build: their proportions are a different cut.
195
+ if bold or weight >= 600:
196
+ if not 600 <= weight <= 800:
197
+ return None
198
+ bold = True
199
+ elif not 350 <= weight <= 450:
200
+ return None
201
+ if bold and italic:
202
+ return "bolditalic"
203
+ if bold:
204
+ return "bold"
205
+ return "italic" if italic else "regular"
206
+ except Exception:
207
+ return None
208
+
209
+
210
+ def load_donor(path):
211
+ """Read a candidate's per-codepoint advances, in em. None if unusable."""
212
+ try:
213
+ font = TTFont(path, fontNumber=0, lazy=True)
214
+ style = face_style(font)
215
+ if style is None:
216
+ font.close()
217
+ return None
218
+ upem = font["head"].unitsPerEm
219
+ cmap = font.getBestCmap()
220
+ hmtx = font["hmtx"]
221
+ if not cmap or not upem:
222
+ return None
223
+ advances = {}
224
+ glyphs = {}
225
+ for cp, glyph_name in cmap.items():
226
+ try:
227
+ advances[cp] = hmtx[glyph_name][0] / upem
228
+ except KeyError:
229
+ continue
230
+ glyphs[cp] = glyph_name
231
+ font.close()
232
+ # `glyphs` is kept so the matcher can tell whether this donor can carry
233
+ # two characters' advances independently; see REQUIRE_FULL_COVERAGE.
234
+ return {"style": style, "advances": advances, "glyphs": glyphs}
235
+ except Exception: # a broken or exotic file is simply not a donor
236
+ return None
237
+
238
+
239
+ def can_carry(target, donor):
240
+ """True when this donor can reproduce every one of the target's advances.
241
+
242
+ Rejects a donor that is missing any captured character, and one that maps two
243
+ characters needing DIFFERENT advances onto a single glyph - it could only
244
+ give them one. See REQUIRE_FULL_COVERAGE.
245
+ """
246
+ glyphs = donor["glyphs"]
247
+ required = {}
248
+ for cp, want in target.items():
249
+ if want <= 0:
250
+ continue
251
+ glyph = glyphs.get(cp)
252
+ if glyph is None:
253
+ return False
254
+ # Same rounding the writer uses, so this asks the question that actually
255
+ # matters: would the two land on different font units?
256
+ units = round(want * 1000)
257
+ if required.setdefault(glyph, units) != units:
258
+ return False
259
+ return True
260
+
261
+
262
+ def shape_distance(target, cand):
263
+ """Best-fit-scale RMS distance, normalised. Lower is a better donor."""
264
+ shared = [cp for cp, v in target.items() if cand.get(cp, 0) > 0 and v > 0]
265
+ if not shared:
266
+ return None
267
+ tc = sum(target[cp] * cand[cp] for cp in shared)
268
+ cc = sum(cand[cp] ** 2 for cp in shared)
269
+ if cc == 0:
270
+ return None
271
+ k = tc / cc
272
+ err = sum((k * cand[cp] - target[cp]) ** 2 for cp in shared)
273
+ mag = sum(target[cp] ** 2 for cp in shared)
274
+ return (err / mag) ** 0.5
275
+
276
+
277
+ def set_names(font, family, style="regular"):
278
+ """Rename the face to `family`, and to nothing else.
279
+
280
+ The donor's own names are dropped rather than edited: a leftover
281
+ typographic-family record (nameID 16) would let the donor be identified by
282
+ name even though every metric says otherwise, which is the same class of
283
+ contradiction this whole exercise removes.
284
+ """
285
+ name = font["name"]
286
+ subfamily = {"regular": "Regular", "bold": "Bold",
287
+ "italic": "Italic", "bolditalic": "Bold Italic"}[style]
288
+ suffix = "" if style == "regular" else f"-{subfamily.replace(' ', '')}"
289
+ postscript = re.sub(r"[^A-Za-z0-9]", "", family) + suffix
290
+ full = family if style == "regular" else f"{family} {subfamily}"
291
+ values = {
292
+ 1: family,
293
+ 2: subfamily,
294
+ # nameID 3 is the unique identifier. It is not reachable from a page, but
295
+ # it IS a plain string in a file we ship, so it says nothing a grep for
296
+ # "anti-detect" would find - the same rule the binary's log strings are
297
+ # held to. Family plus style is unique enough for fontconfig.
298
+ 3: full,
299
+ 4: full,
300
+ 6: postscript,
301
+ }
302
+ name.names = []
303
+ for name_id, value in values.items():
304
+ name.setName(value, name_id, 3, 1, 0x409) # Windows / Unicode BMP / en-US
305
+ name.setName(value, name_id, 1, 0, 0) # Mac / Roman / English
306
+
307
+
308
+ def set_kerning(font, pairs, upem):
309
+ """Replace the donor's kerning with the Mac's measured pairs.
310
+
311
+ A string's width is the sum of its advances PLUS the kerning of each adjacent
312
+ pair, and Chrome kerns by default - so a face with exact advances and the
313
+ donor's kern pairs still measures wrong. On the reference Mac the 26-glyph
314
+ sample string carries -10.63px of kerning at 64px in Futura and -7.09px in
315
+ Times; 24 of 34 families kern it by more than half a pixel.
316
+
317
+ The donor's own positioning is dropped rather than merged. GPOS carries the
318
+ donor's kern feature, and a leftover pair would add to the one written here -
319
+ the two tables do not overwrite each other, they accumulate.
320
+ """
321
+ # GSUB goes too, not just the positioning tables.
322
+ #
323
+ # The captured value for a pair is the whole width change Chrome applies
324
+ # between those two characters - kerning, but also any ligature or contextual
325
+ # substitution, since all of them show up in width(ab) - width(a) - width(b).
326
+ # So the capture already describes everything the Mac does to a pair, and any
327
+ # substitution the DONOR brings is a second, unmeasured effect on top.
328
+ #
329
+ # It is not hypothetical: measured in the container, system-ui had 2040
330
+ # non-zero pairs against the Mac's 938 and Futura 1550 against 392, while
331
+ # Times - whose donor carries almost no substitutions - had exactly the Mac's
332
+ # 94. Those extras cancelled most of the kerning we had just written in, so
333
+ # the sample string came out barely kerned at all.
334
+ for table in ("GPOS", "GSUB", "kern"):
335
+ if table in font:
336
+ del font[table]
337
+ if not pairs:
338
+ return 0
339
+
340
+ cmap = font.getBestCmap()
341
+ rules = []
342
+ for text, value in pairs.items():
343
+ if len(text) != 2:
344
+ continue
345
+ left = cmap.get(ord(text[0]))
346
+ right = cmap.get(ord(text[1]))
347
+ if left is None or right is None:
348
+ continue
349
+ units = int(round(value * upem))
350
+ if units:
351
+ rules.append((left, right, units))
352
+ if not rules:
353
+ return 0
354
+
355
+ # feaLib is the supported way to attach a kern feature; hand-assembling the
356
+ # GPOS subtables would mean owning coverage ordering and class definitions.
357
+ lines = ["languagesystem DFLT dflt;", "languagesystem latn dflt;", "feature kern {"]
358
+ for left, right, units in rules:
359
+ lines.append(f" pos {left} {right} {units};")
360
+ lines.append("} kern;")
361
+ with tempfile.NamedTemporaryFile("w", suffix=".fea", delete=False, encoding="utf-8") as handle:
362
+ handle.write("\n".join(lines) + "\n")
363
+ fea_path = handle.name
364
+ try:
365
+ addOpenTypeFeatures(font, fea_path, tables=["GPOS"])
366
+ finally:
367
+ os.unlink(fea_path)
368
+ return len(rules)
369
+
370
+
371
+ def infer_units_per_em(ink_row):
372
+ """The units-per-em the Mac's own copy of this face uses.
373
+
374
+ It is recoverable from the capture because every metric a Mac reports for a
375
+ real face is a whole number of that face's font units, so the em values are
376
+ exact multiples of 1/upem. Measured across the 260-face capture: 102 faces
377
+ are 2048/em and 29 are 1000/em.
378
+
379
+ It matters because the binary snaps each metric to size/unitsPerEm of the
380
+ font it RESOLVED, so a generated face inheriting its donor's 1000/em grid
381
+ would answer on a 1/1000 grid where the Mac's Helvetica answers on 1/2048 -
382
+ and a page that knows Helvetica is a 2048/em face on macOS can check that
383
+ directly. Matching the upem is what makes the C++ snap land on the same grid
384
+ the Mac uses.
385
+
386
+ Faces where neither candidate fits are the ones the Mac SYNTHESISES - its own
387
+ embolden and skew put the result off any grid - and 2048 is returned for them
388
+ because it is the commonest real value and the snap has to pick something.
389
+ """
390
+ if not ink_row:
391
+ return 2048
392
+ for candidate in (1000, 2048):
393
+ total = 0
394
+ on_grid = 0
395
+ for box in ink_row.values():
396
+ for value in box[1:]:
397
+ if value == 0:
398
+ continue
399
+ total += 1
400
+ units = value * candidate
401
+ # The capture is rounded to 1e-6 em, so allow a little slack.
402
+ if abs(units - round(units)) < 0.004:
403
+ on_grid += 1
404
+ if total and on_grid / total > 0.995:
405
+ return candidate
406
+ return 2048
407
+
408
+
409
+ def set_ink_boxes(font, ink, upem, cmap):
410
+ """Fit every glyph's OUTLINE to the ink box the Mac reports for it.
411
+
412
+ Writing advances fixes how far the pen moves and says nothing about where the
413
+ paint lands, and both are readable from one measureText call:
414
+ actualBoundingBoxLeft / Right / Ascent / Descent are the glyph's bounding box.
415
+ Measured three-way against real Chrome by text-raster-probe.mjs, advances were
416
+ exact while the container's ink boxes were out by up to 27.4% - Menlo ascent
417
+ -18.2%, Menlo left +27.4%, Helvetica left +19.6%, Times left -21.1% - because
418
+ the boxes were still the DONOR's. Verdana and Arial, whose donors happen to
419
+ have similar proportions, sat under 1.4%, which is what makes the error
420
+ per-family and therefore a family-by-family tell rather than a global offset.
421
+
422
+ The advance is untouched here: it lives in hmtx, the outline in glyf, so a
423
+ glyph can be moved and scaled inside an advance that stays exactly as
424
+ captured. That is what makes both surfaces satisfiable at once, and it is why
425
+ this is a per-glyph affine rather than a per-face scale - the Mac's error is
426
+ per glyph, and fit-advance-model.mjs already showed per-face correction does
427
+ not generalise.
428
+
429
+ Returns (patched, skipped, cleared, worst_scale) where worst_scale is the
430
+ largest single-axis stretch applied, so a donor that had to be distorted
431
+ beyond recognition is visible in the log rather than silently shipped.
432
+ """
433
+ # Both outline formats, because most donors are the second one. The URW and
434
+ # Nimbus faces the donor scorer prefers are .otf, so a glyf-only version of
435
+ # this transformed one face out of nine in the first smoke run and reported
436
+ # "ink 0" for the rest - it looked like a no-op rather than an unsupported
437
+ # format, which is the failure mode worth naming here.
438
+ glyf = font.get("glyf")
439
+ cff = font.get("CFF ")
440
+ charstrings = None
441
+ if glyf is None:
442
+ if cff is None or not ink:
443
+ return 0, 0, 0, 1.0
444
+ charstrings = cff.cff.topDictIndex[0].CharStrings
445
+ if not ink:
446
+ return 0, 0, 0, 1.0
447
+
448
+ hmtx = font["hmtx"]
449
+ glyph_set = font.getGlyphSet()
450
+
451
+ def write_outline(name, recording, transform):
452
+ """Replace one glyph's outline, in whichever format this font uses."""
453
+ if glyf is not None:
454
+ pen = TTGlyphPen(None)
455
+ recording.replay(TransformPen(pen, transform) if transform else pen)
456
+ glyf[name] = pen.glyph()
457
+ glyf[name].recalcBounds(glyf)
458
+ return glyf[name].xMin
459
+ existing = charstrings[name]
460
+ pen = T2CharStringPen(hmtx[name][0], None)
461
+ recording.replay(TransformPen(pen, transform) if transform else pen)
462
+ replacement = pen.getCharString(private=existing.private,
463
+ globalSubrs=existing.globalSubrs)
464
+ # Program rather than object replacement, and the bytecode dropped with
465
+ # it: a T2CharString compiles from its cached bytecode when it has one,
466
+ # so assigning only .program leaves the ORIGINAL outline in the saved
467
+ # file while every offline check reads the new one.
468
+ existing.program = replacement.program
469
+ existing.bytecode = None
470
+ bounds = BoundsPen(None)
471
+ recording.replay(TransformPen(bounds, transform) if transform else bounds)
472
+ return int(round(bounds.bounds[0])) if bounds.bounds else 0
473
+
474
+ # First codepoint wins, as with advances. A donor mapping two characters onto
475
+ # one glyph cannot give them different boxes any more than it can give them
476
+ # different advances, and can_carry() already rejects donors that do that for
477
+ # advances - which is the stricter test, since two characters sharing a glyph
478
+ # share its box on a Mac as well.
479
+ per_glyph = {}
480
+ for cp, box in ink.items():
481
+ name = cmap.get(cp)
482
+ if name is not None and name not in per_glyph:
483
+ per_glyph[name] = box
484
+
485
+ # Decompose EVERY glyph before writing ANY of them.
486
+ #
487
+ # DecomposingRecordingPen resolves a composite through the live glyph set, so
488
+ # writing as we went would let a composite - every accented Latin-1 letter is
489
+ # one - be decomposed against a base glyph this loop had already scaled, and
490
+ # then scaled a second time by its own factor. Recording first means every
491
+ # glyph is read from the pristine outlines.
492
+ recorded = {}
493
+ unreadable = 0
494
+ for name in per_glyph:
495
+ rec = DecomposingRecordingPen(glyph_set)
496
+ try:
497
+ glyph_set[name].draw(rec)
498
+ except Exception: # noqa: BLE001 - a donor glyph we cannot read is a skip
499
+ unreadable += 1
500
+ continue
501
+ recorded[name] = rec
502
+
503
+ patched = skipped = cleared = 0
504
+ skipped += unreadable
505
+ worst_scale = 1.0
506
+ for name, (_advance, left, right, ascent, descent) in per_glyph.items():
507
+ # The capture is in em and signed as the TextMetrics fields are:
508
+ # actualBoundingBoxLeft is positive LEFTWARDS, so xMin is its negation.
509
+ target = (-left * upem, -descent * upem, right * upem, ascent * upem)
510
+ target_blank = (target[2] - target[0]) <= 0 and (target[3] - target[1]) <= 0
511
+
512
+ rec = recorded.get(name)
513
+ if rec is None:
514
+ continue
515
+ if not rec.value:
516
+ # Blank donor glyph. Nothing to fit, and nothing to fit it to unless
517
+ # the Mac draws something here - in which case the donor simply
518
+ # cannot express this character and the advance still carries.
519
+ if not target_blank:
520
+ skipped += 1
521
+ continue
522
+ if target_blank:
523
+ # The Mac paints nothing and the donor paints something: a space that
524
+ # draws is visible ink no Mac produces, so remove it rather than
525
+ # scale it to nothing (a zero scale collapses to a degenerate
526
+ # contour, which some rasterisers still hint into a dot).
527
+ empty = DecomposingRecordingPen(glyph_set)
528
+ write_outline(name, empty, None)
529
+ hmtx[name] = (hmtx[name][0], 0)
530
+ cleared += 1
531
+ continue
532
+
533
+ # True bounds including curve extrema, computed from the pristine
534
+ # recording so it is identical for glyf and CFF donors. A control-point
535
+ # box would overstate a curved glyph and scale it too small.
536
+ bounds = BoundsPen(None)
537
+ rec.replay(bounds)
538
+ if bounds.bounds is None:
539
+ skipped += 1
540
+ continue
541
+ current = bounds.bounds
542
+ scale = []
543
+ offset = []
544
+ for axis in (0, 1):
545
+ span_now = current[axis + 2] - current[axis]
546
+ span_target = target[axis + 2] - target[axis]
547
+ if span_now > 0 and span_target > 0:
548
+ factor = span_target / span_now
549
+ else:
550
+ # A zero extent on one axis - an underscore has no width above
551
+ # the baseline, a hyphen no descent - cannot be scaled into a
552
+ # non-zero one. Translate that axis and leave its size alone.
553
+ factor = 1.0
554
+ scale.append(factor)
555
+ offset.append(target[axis] - current[axis] * factor)
556
+ worst_scale = max(worst_scale, factor, 1 / factor if factor > 0 else 1.0)
557
+
558
+ # Write, MEASURE WHAT WAS WRITTEN, and correct. The arithmetic above is
559
+ # exact - an affine maps a curve's extremum to the transformed extremum -
560
+ # but the file does not always agree with it, because the pen rounds
561
+ # coordinates to integers and re-encodes the contour on the way out.
562
+ # Measured on Verdana's 'u': the transform predicted yMax 1117 and the
563
+ # written glyph measured 1091, 26 units short, while 'n' and 'o' through
564
+ # the same code landed exactly. Whatever the pen did there, a single
565
+ # open-loop write cannot see it.
566
+ #
567
+ # So the target is treated as a fixed point and iterated to. Two or three
568
+ # passes cost a few pen replays per glyph and make the result a property
569
+ # of the written file rather than of the arithmetic. verify-metric-fonts.py
570
+ # then re-reads the saved font independently, so a failure to converge is
571
+ # caught rather than assumed away.
572
+ new_xmin = None
573
+ for _attempt in range(4):
574
+ new_xmin = write_outline(name, rec, (scale[0], 0, 0, scale[1], offset[0], offset[1]))
575
+ written = DecomposingRecordingPen(glyph_set)
576
+ glyph_set[name].draw(written)
577
+ check = BoundsPen(None)
578
+ written.replay(check)
579
+ if check.bounds is None:
580
+ break
581
+ residual = max(abs(check.bounds[axis] - target[axis]) for axis in range(4))
582
+ if residual <= 0.5:
583
+ break
584
+ for axis in (0, 1):
585
+ span_written = check.bounds[axis + 2] - check.bounds[axis]
586
+ span_target = target[axis + 2] - target[axis]
587
+ if span_written > 0 and span_target > 0:
588
+ scale[axis] *= span_target / span_written
589
+ # Recomputed from the PRISTINE bounds, not nudged from the last
590
+ # offset: the scale just changed, and an offset that was correct
591
+ # for the old scale is not correct for the new one.
592
+ offset[axis] = target[axis] - current[axis] * scale[axis]
593
+ worst_scale = max(worst_scale, scale[axis],
594
+ 1 / scale[axis] if scale[axis] > 0 else 1.0)
595
+ # lsb must agree with the outline it describes. Chrome derives the ink box
596
+ # from the contours, but a disagreeing lsb is malformed and shapers do
597
+ # read it, so it is written rather than left describing the old shape.
598
+ hmtx[name] = (hmtx[name][0], new_xmin)
599
+ patched += 1
600
+
601
+ return patched, skipped, cleared, worst_scale
602
+
603
+
604
+ def build(family, target, donor_path, out_dir, style="regular", pairs=None, ink=None):
605
+ font = TTFont(donor_path, fontNumber=0)
606
+
607
+ # A variable donor must be pinned first: its HVAR deltas would otherwise be
608
+ # applied on top of the advances we are about to write, and the face would
609
+ # measure as neither the Mac nor the donor.
610
+ if "fvar" in font:
611
+ axes = {a.axisTag: a.defaultValue for a in font["fvar"].axes}
612
+ font = instancer.instantiateVariableFont(font, axes, inplace=True, updateFontNames=False)
613
+
614
+ # Match the Mac face's units-per-em BEFORE anything is measured or written.
615
+ # Order matters twice over: the binary snaps every metric to size/upem of the
616
+ # resolved font, so the grid has to be the Mac's; and rescaling afterwards
617
+ # would re-round advances and ink boxes that had just been fitted, undoing
618
+ # the fit at the last step. A 2048 grid also halves the rounding error a
619
+ # 1000/em donor imposes on every value.
620
+ target_upem = infer_units_per_em(ink)
621
+ if font["head"].unitsPerEm != target_upem:
622
+ scale_upem(font, target_upem)
623
+
624
+ upem = font["head"].unitsPerEm
625
+ cmap = font.getBestCmap()
626
+ hmtx = font["hmtx"]
627
+
628
+ # can_carry() has already rejected donors that cannot express every advance,
629
+ # so a disagreement here means the donor filter and this writer are applying
630
+ # different rules and the face would silently ship a wrong advance. Count
631
+ # them; the caller asserts the count is zero.
632
+ # Outlines first, advances second. set_ink_boxes preserves the advance it
633
+ # finds and rewrites lsb; the advance writer below preserves the lsb it finds.
634
+ # Either order works, but this one leaves hhea.advanceWidthMax computed after
635
+ # both have run.
636
+ ink_patched, ink_skipped, ink_cleared, ink_worst = set_ink_boxes(font, ink, upem, cmap)
637
+
638
+ per_glyph = {}
639
+ conflicts = 0
640
+ missing = 0
641
+ for cp in sorted(target):
642
+ glyph_name = cmap.get(cp)
643
+ if glyph_name is None or glyph_name not in hmtx.metrics:
644
+ missing += 1
645
+ continue
646
+ units = int(round(target[cp] * upem))
647
+ if glyph_name in per_glyph:
648
+ if per_glyph[glyph_name] != units:
649
+ conflicts += 1
650
+ continue
651
+ per_glyph[glyph_name] = units
652
+
653
+ for glyph_name, units in per_glyph.items():
654
+ _, lsb = hmtx[glyph_name]
655
+ hmtx[glyph_name] = (max(units, 0), lsb)
656
+
657
+ # hhea must agree with hmtx or the face is malformed; a renderer that
658
+ # believes advanceWidthMax can lay out past the glyphs it then draws.
659
+ font["hhea"].advanceWidthMax = max(a for a, _ in hmtx.metrics.values())
660
+
661
+ kern_pairs = set_kerning(font, pairs, upem)
662
+
663
+ set_names(font, family, style)
664
+
665
+ # The style bits have to say what this face is, or fontconfig matches it for
666
+ # unstyled text too and Skia never synthesises anything - a page would get
667
+ # the bold advances for regular text.
668
+ is_bold = style in ("bold", "bolditalic")
669
+ is_italic = style in ("italic", "bolditalic")
670
+ font["head"].macStyle = (font["head"].macStyle & ~0b11) | (0b1 if is_bold else 0) | (0b10 if is_italic else 0)
671
+ if font.get("OS/2") is not None:
672
+ selection = font["OS/2"].fsSelection & ~0b1100001 # clear italic, bold, regular
673
+ if is_bold:
674
+ selection |= 0b100000
675
+ if is_italic:
676
+ selection |= 0b1
677
+ if not is_bold and not is_italic:
678
+ selection |= 0b1000000 # REGULAR
679
+ font["OS/2"].fsSelection = selection
680
+ font["OS/2"].usWeightClass = 700 if is_bold else 400
681
+ # A face that claims italic in its name table but reports a zero slant is
682
+ # incoherent, and fontconfig scores matches on the slant it reads here.
683
+ if is_italic and font.get("post") is not None and font["post"].italicAngle == 0:
684
+ font["post"].italicAngle = -12.0
685
+
686
+ file_suffix = {"regular": "Regular", "bold": "Bold",
687
+ "italic": "Italic", "bolditalic": "BoldItalic"}[style]
688
+ out_path = os.path.join(out_dir, f"{re.sub(r'[^A-Za-z0-9]', '', family)}-{file_suffix}.ttf")
689
+ font.save(out_path)
690
+ font.close()
691
+ return (out_path, len(per_glyph), conflicts, missing, kern_pairs,
692
+ {"patched": ink_patched, "skipped": ink_skipped,
693
+ "cleared": ink_cleared, "worstScale": round(ink_worst, 3),
694
+ "unitsPerEm": upem})
695
+
696
+
697
+ # Families that stay on a fontconfig alias, because a Latin donor cannot stand in
698
+ # for them: the CJK and script faces (where the donor would have no glyphs at
699
+ # all) and the emoji/symbol faces. The Mac advance capture covers Latin, so these
700
+ # are outside what this generator can build honestly.
701
+ STILL_ALIASED = {
702
+ "Apple Color Emoji": "Noto Color Emoji",
703
+ "Apple Symbols": "Noto Sans Symbols",
704
+ "Apple Braille": "DejaVu Sans",
705
+ "STIX Two Math": "DejaVu Serif",
706
+ "Apple SD Gothic Neo": "Noto Sans CJK KR",
707
+ "AppleGothic": "Noto Sans CJK KR",
708
+ "Heiti SC": "Noto Sans CJK SC",
709
+ "Heiti TC": "Noto Sans CJK TC",
710
+ "PingFang SC": "Noto Sans CJK SC",
711
+ "PingFang TC": "Noto Sans CJK TC",
712
+ "Hiragino Kaku Gothic ProN": "Noto Sans CJK JP",
713
+ "Hiragino Sans": "Noto Sans CJK JP",
714
+ "Hiragino Mincho ProN": "Noto Serif CJK JP",
715
+ "Songti SC": "Noto Serif CJK SC",
716
+ "Songti TC": "Noto Serif CJK TC",
717
+ "Sukhumvit Set": "Noto Sans Thai",
718
+ }
719
+
720
+ # What the CSS generics resolve to on macOS, stated explicitly rather than left
721
+ # to fontconfig's Linux defaults (which point at DejaVu, a family the binary's
722
+ # canvas allowlist then blocks - leaving generic text on the last-resort font).
723
+ GENERIC_RULES = GENERIC_TARGETS
724
+
725
+ # `-apple-system` is a CSS keyword rather than a font file even on a Mac; it has
726
+ # to reach the same face system-ui does.
727
+ NAME_ALIASES = {"-apple-system": "BlinkMacSystemFont"}
728
+
729
+
730
+ def write_fontconfig(path, built):
731
+ """Emit the alias file that goes with the generated faces.
732
+
733
+ It is short now, and that is the point: a family we BUILT needs no alias,
734
+ because it exists. What remains is the handful a Latin donor cannot stand in
735
+ for, plus the generics.
736
+ """
737
+ rules = []
738
+ for family, target in sorted(GENERIC_RULES.items()):
739
+ rules.append((family, target, "CSS generic; macOS resolves it here"))
740
+ for family, target in sorted(NAME_ALIASES.items()):
741
+ rules.append((family, target, "same face under its CSS keyword"))
742
+ for family, target in sorted(STILL_ALIASED.items()):
743
+ if family in built:
744
+ continue
745
+ rules.append((family, target, "no Latin donor can stand in; stays aliased"))
746
+
747
+ lines = [
748
+ '<?xml version="1.0"?>',
749
+ '<!DOCTYPE fontconfig SYSTEM "fonts.dtd">',
750
+ "<!--",
751
+ " macOS family substitutions that remain after the per-family metric fonts.",
752
+ "",
753
+ " Generated by build-metric-fonts.py. Do not edit by hand.",
754
+ "",
755
+ f" {len(built)} families are now REAL font files, installed under their own",
756
+ " macOS names and carrying that family's own per-character advances (see",
757
+ " build-metric-fonts.py for why). They need no alias, and must not have one:",
758
+ " a <match target=\"pattern\"> rewrite is authoritative and would send the",
759
+ " request back to a shared clone, which is the collapse this replaced -",
760
+ " 54 families measuring as 19 distinct faces where a Mac has 41.",
761
+ "",
762
+ " What is left here is what a Latin donor cannot honestly stand in for:",
763
+ " the CJK, symbol and emoji faces, plus the CSS generics.",
764
+ "",
765
+ " Deliberately absent: ZapfDingbats and Bookman. Both are in the binary's",
766
+ " canvas font allowlist, but measured on macOS 26.5.2 they fall back to",
767
+ " Times - the reference Mac does not have them - so they are left to fall",
768
+ " back here too.",
769
+ "-->",
770
+ "<fontconfig>",
771
+ "",
772
+ ]
773
+ for family, target, why in rules:
774
+ lines.append(f" <!-- {family}: {why} -->")
775
+ lines.append(' <match target="pattern">')
776
+ lines.append(f' <test name="family" compare="eq" ignore-blanks="true"><string>{family}</string></test>')
777
+ lines.append(f' <edit name="family" mode="assign" binding="same"><string>{target}</string></edit>')
778
+ lines.append(" </match>")
779
+ lines.append("")
780
+ lines.append("</fontconfig>")
781
+
782
+ with open(path, "w", encoding="utf-8") as handle:
783
+ handle.write("\n".join(lines) + "\n")
784
+ log(f"wrote {path} ({len(rules)} rules, down from 87)")
785
+
786
+
787
+ def main():
788
+ parser = argparse.ArgumentParser()
789
+ parser.add_argument("--advances", required=True)
790
+ parser.add_argument("--out", required=True)
791
+ parser.add_argument("--only", default="", help="comma-separated subset, for debugging")
792
+ parser.add_argument("--manifest", default="", help="write the donor choices here")
793
+ parser.add_argument("--conf", default="", help="write the remaining fontconfig aliases here")
794
+ parser.add_argument("--kerning", default="", help="per-pair kerning capture from the reference Mac")
795
+ parser.add_argument("--inkbox", default="",
796
+ help="per-character ink box capture (char-metrics-capture.mjs). "
797
+ "Without it the faces keep the DONOR's outlines, which "
798
+ "measured up to 27.4% off the Mac on actualBoundingBox*.")
799
+ args = parser.parse_args()
800
+
801
+ with open(args.advances, encoding="utf-8") as handle:
802
+ captured = json.load(handle)["advances"]
803
+
804
+ # Without this the faces carry the DONOR's kern pairs, which is a 0.2-1.0%
805
+ # error on any string with a kerning pair in it.
806
+ kerning = {}
807
+ if args.kerning:
808
+ with open(args.kerning, encoding="utf-8") as handle:
809
+ kerning = json.load(handle)["kerning"]
810
+ log(f"kerning capture: {len(kerning)} faces")
811
+ else:
812
+ log("WARNING: no --kerning capture; faces will have NO kerning at all")
813
+
814
+ # The outlines. Without this every ink extent stays the donor's, which is a
815
+ # per-family error a page reads from the same measureText call as the width.
816
+ ink_boxes = {}
817
+ if args.inkbox:
818
+ with open(args.inkbox, encoding="utf-8") as handle:
819
+ payload = json.load(handle)
820
+ fields = payload.get("fields")
821
+ # The order is load-bearing: set_ink_boxes unpacks these positionally, and
822
+ # silently reading ascent as right would distort every glyph in the bundle.
823
+ if fields != ["width", "left", "right", "ascent", "descent"]:
824
+ sys.exit(f"--inkbox field order is {fields}, expected "
825
+ "['width', 'left', 'right', 'ascent', 'descent']")
826
+ ink_boxes = payload["metrics"]
827
+ log(f"ink box capture: {len(ink_boxes)} faces")
828
+ else:
829
+ log("WARNING: no --inkbox capture; faces will keep the DONOR's outlines")
830
+
831
+ only = {s.strip() for s in args.only.split(",") if s.strip()}
832
+ # "Family" is the regular face; "Family|bold" is the bold one, captured
833
+ # separately because a Mac's bold is a real face with its own advances -
834
+ # 2.4% wider than regular for Optima, 13.5% for Georgia - and not a smear of
835
+ # the regular one. Italic is deliberately NOT captured or built: measured on
836
+ # the reference Mac, italic advances are within 1% of regular for every
837
+ # family checked (identical for Helvetica, Arial and Verdana), so the
838
+ # synthesised oblique Skia produces from the regular face is already right.
839
+ def clean(row):
840
+ return {int(cp): adv for cp, adv in row.items() if isinstance(adv, (int, float)) and adv > 0}
841
+
842
+ # A face built under a second name is captured under the first one.
843
+ def source_name(family):
844
+ return EXTRA_NAMES.get(family, family)
845
+
846
+ def row_for_style(family, style):
847
+ suffix = {"regular": "", "bold": "|bold", "italic": "|italic",
848
+ "bolditalic": "|bold italic"}[style]
849
+ return captured.get(f"{source_name(family)}{suffix}")
850
+
851
+ targets = {
852
+ family: clean(row)
853
+ for family, row in captured.items()
854
+ if not row.get("unset") and "|" not in family
855
+ and family not in GENERICS and family not in SKIP
856
+ and (not only or family in only)
857
+ }
858
+ # Drop the families that only fell back during the capture (see
859
+ # GENERIC_TARGETS). Compared on the raw capture, before the int() keys above.
860
+ generic_rows = {
861
+ generic: captured[generic]
862
+ for generic in GENERIC_TARGETS
863
+ if generic in captured and not captured[generic].get("unset")
864
+ }
865
+ absent = []
866
+ for family in list(targets):
867
+ row = captured.get(family)
868
+ for generic, generic_row in generic_rows.items():
869
+ if row == generic_row and family != GENERIC_TARGETS[generic]:
870
+ absent.append(f"{family} (= {generic})")
871
+ del targets[family]
872
+ break
873
+ if absent:
874
+ log(f"absent on the reference Mac, not built: {', '.join(absent)}")
875
+
876
+ for name, source in EXTRA_NAMES.items():
877
+ if source in targets and (not only or name in only):
878
+ targets[name] = targets[source]
879
+ log(f"{len(targets)} families to build")
880
+
881
+ os.makedirs(args.out, exist_ok=True)
882
+
883
+ donors = {}
884
+ for path in candidate_files():
885
+ advances = load_donor(path)
886
+ if advances:
887
+ donors[path] = advances
888
+ log(f"{len(donors)} candidate donor files")
889
+
890
+ manifest = {}
891
+ used = defaultdict(list)
892
+ for family, target in sorted(targets.items()):
893
+ # style -> (donor path, captured row), for the faces actually written.
894
+ built_styles = {}
895
+ for style in ("regular", "bold", "italic", "bolditalic"):
896
+ if style != "regular":
897
+ captured_as = {"bold": "|bold", "italic": "|italic",
898
+ "bolditalic": "|bold italic"}[style]
899
+ row = captured.get(f"{source_name(family)}{captured_as}")
900
+ if not row or row.get("unset"):
901
+ continue
902
+
903
+ # Skip only when NOT building the file would land on the same
904
+ # advances anyway - which means asking what SKIA would synthesise
905
+ # from, not what CoreText did.
906
+ #
907
+ # The two engines disagree, and the disagreement is the whole
908
+ # point. For bold-italic the Mac synthesises from the BOLD face,
909
+ # so its bold-italic advances equal its bold ones; Skia picks the
910
+ # ITALIC face when one exists. Skipping on the Mac's answer left
911
+ # five script families - Marker Felt, Chalkboard SE, Chalkduster,
912
+ # Herculanum, Webdings - measuring their italic advances for
913
+ # bold-italic text, up to 120% off on a character.
914
+ if style == "bolditalic":
915
+ synth = "|italic" if "italic" in built_styles else (
916
+ "|bold" if "bold" in built_styles else "")
917
+ else:
918
+ synth = ""
919
+ if row == captured.get(f"{source_name(family)}{synth}"):
920
+ continue
921
+ target = clean(row)
922
+
923
+ # When this style's advances are identical to one already built,
924
+ # build it from the SAME donor file. Two donors with different
925
+ # unitsPerEm round the same em value to different font units, which
926
+ # would make the two faces measurably differ where the Mac has them
927
+ # exactly equal - the mirror of the collapse this whole exercise
928
+ # removes.
929
+ twin = next((st for st, (_, r) in built_styles.items() if r == row_for_style(family, style)), None)
930
+ if twin:
931
+ pool = {built_styles[twin][0]: donors[built_styles[twin][0]]}
932
+ else:
933
+ # Otherwise match against donors of the SAME style: a regular
934
+ # donor emboldened or slanted would carry regular proportions
935
+ # under a bold or italic name.
936
+ pool = {p: d for p, d in donors.items()
937
+ if d["style"] == style and can_carry(target, d)}
938
+ if not pool:
939
+ # Falling back across styles is better than shipping no face at
940
+ # all, but it must still be a donor that can carry the metrics.
941
+ pool = {p: d for p, d in donors.items() if can_carry(target, d)}
942
+ if not pool:
943
+ log(f" {family} ({style}): no donor can carry these advances - skipped")
944
+ continue
945
+
946
+ best_path, best_distance = None, None
947
+ for path, donor in pool.items():
948
+ distance = shape_distance(target, donor["advances"])
949
+ if distance is None:
950
+ continue
951
+ if best_distance is None or distance < best_distance:
952
+ best_path, best_distance = path, distance
953
+ if best_path is None:
954
+ log(f" {family} ({style}): NO DONOR - skipped")
955
+ continue
956
+ key = f"{family} {style}"
957
+ kern_key = {"regular": "", "bold": "|bold", "italic": "|italic",
958
+ "bolditalic": "|bold italic"}[style]
959
+ face_pairs = kerning.get(f"{source_name(family)}{kern_key}", {})
960
+ if face_pairs.get("unset"):
961
+ face_pairs = {}
962
+ ink_key = {"regular": "", "bold": "|bold", "italic": "|italic",
963
+ "bolditalic": "|bold italic"}[style]
964
+ face_ink = ink_boxes.get(f"{source_name(family)}{ink_key}", {})
965
+ if face_ink.get("unset"):
966
+ face_ink = {}
967
+ face_ink = {int(cp): box for cp, box in face_ink.items()
968
+ if isinstance(box, list) and len(box) == 5}
969
+ out_path, patched, conflicts, missing, kern_pairs, ink_stats = build(
970
+ family, target, best_path, args.out, style, face_pairs, face_ink)
971
+ # can_carry() screened the donor, so anything here is a bug in the
972
+ # screen rather than a property of the font. Loud, not silent: a
973
+ # dropped advance is exactly the kind of miss that measures fine on
974
+ # the sample string and leaks on everything else.
975
+ if conflicts or missing:
976
+ sys.exit(f"{key}: donor {os.path.basename(best_path)} passed can_carry() "
977
+ f"but produced {conflicts} conflict(s) and {missing} missing glyph(s)")
978
+ manifest[key] = {
979
+ "donor": os.path.basename(best_path),
980
+ "shapeDistance": round(best_distance, 4),
981
+ "glyphsPatched": patched,
982
+ "kernPairs": kern_pairs,
983
+ "inkBoxes": ink_stats,
984
+ "unitsPerEm": ink_stats.pop("unitsPerEm", None),
985
+ "file": os.path.basename(out_path),
986
+ }
987
+ built_styles[style] = (best_path, row_for_style(family, style))
988
+ used[os.path.basename(best_path)].append(family)
989
+ log(f" {family:<24} {style:<8} <- {os.path.basename(best_path):<34} "
990
+ f"Δ{best_distance * 100:5.1f}% {patched} glyphs {kern_pairs} kern pairs "
991
+ f"ink {ink_stats['patched']}"
992
+ + (f" (+{ink_stats['cleared']} cleared)" if ink_stats["cleared"] else "")
993
+ + (f" (-{ink_stats['skipped']} skipped)" if ink_stats["skipped"] else "")
994
+ + f" x{ink_stats['worstScale']}")
995
+
996
+ built_families = {key.rsplit(" ", 1)[0] for key in manifest}
997
+ if args.conf:
998
+ write_fontconfig(args.conf, built_families)
999
+
1000
+ if args.manifest:
1001
+ with open(args.manifest, "w", encoding="utf-8") as handle:
1002
+ json.dump(manifest, handle, indent=2, sort_keys=True)
1003
+ handle.write("\n")
1004
+ log(f"wrote {args.manifest}")
1005
+
1006
+ log(f"built {len(manifest)} faces for {len(built_families)} families from {len(used)} distinct donors")
1007
+
1008
+
1009
+ if __name__ == "__main__":
1010
+ main()