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.
- package/browser/fonts/macos-bundle/41-macos-remaining.conf +157 -0
- package/browser/fonts/macos-bundle/build-metric-fonts.py +1010 -0
- package/browser/fonts/macos-bundle/generated/AmericanTypewriter-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AmericanTypewriter-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AndaleMono-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AndaleMono-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AndaleMono-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AndaleMono-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AppleChancery-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AppleChancery-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AppleChancery-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AppleChancery-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Arial-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Arial-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Arial-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Arial-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialBlack-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialBlack-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialBlack-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialBlack-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialNarrow-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialNarrow-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialNarrow-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ArialNarrow-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Avenir-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Avenir-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Avenir-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Avenir-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNext-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNext-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNext-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNext-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/AvenirNextCondensed-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Baskerville-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Baskerville-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Baskerville-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Baskerville-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BigCaslon-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BigCaslon-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BigCaslon-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BigCaslon-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BlinkMacSystemFont-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Oldstyle-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Bodoni72Smallcaps-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BradleyHand-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/BrushScriptMT-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkboard-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkboard-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkboard-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkboard-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ChalkboardSE-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ChalkboardSE-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ChalkboardSE-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ChalkboardSE-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkduster-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkduster-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkduster-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Chalkduster-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Charter-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Charter-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Charter-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Charter-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Cochin-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Cochin-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Cochin-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Cochin-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ComicSansMS-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ComicSansMS-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ComicSansMS-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/ComicSansMS-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Copperplate-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Copperplate-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Courier-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Courier-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Courier-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Courier-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/CourierNew-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Didot-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Didot-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Didot-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Didot-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Futura-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Futura-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Futura-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Futura-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Geneva-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Georgia-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Georgia-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Georgia-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Georgia-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/GillSans-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/GillSans-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/GillSans-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/GillSans-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Helvetica-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Helvetica-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Helvetica-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Helvetica-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HelveticaNeue-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HelveticaNeue-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HelveticaNeue-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HelveticaNeue-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Herculanum-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Herculanum-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Herculanum-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Herculanum-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HoeflerText-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HoeflerText-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HoeflerText-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/HoeflerText-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Impact-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Impact-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Impact-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Impact-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/LucidaGrande-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/LucidaGrande-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/MarkerFelt-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/MarkerFelt-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/MarkerFelt-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/MarkerFelt-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Menlo-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Monaco-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Monaco-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Monaco-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Monaco-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Noteworthy-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Noteworthy-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Optima-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Optima-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Optima-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Optima-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Palatino-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Palatino-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Palatino-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Palatino-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Papyrus-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Papyrus-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Papyrus-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Papyrus-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/PartyLET-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/PartyLET-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/PartyLET-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/PartyLET-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Phosphate-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/README.md +132 -0
- package/browser/fonts/macos-bundle/generated/Rockwell-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Rockwell-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Rockwell-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Rockwell-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SavoyeLET-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SavoyeLET-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SavoyeLET-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SavoyeLET-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Seravek-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Seravek-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Seravek-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Seravek-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SignPainter-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SignPainter-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Skia-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Skia-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Skia-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Skia-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SnellRoundhand-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/SnellRoundhand-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Tahoma-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Tahoma-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Times-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Times-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Times-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Times-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TimesNewRoman-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TimesNewRoman-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TimesNewRoman-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TimesNewRoman-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Trattatello-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TrebuchetMS-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TrebuchetMS-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TrebuchetMS-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/TrebuchetMS-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Verdana-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Verdana-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Verdana-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Verdana-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Webdings-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Webdings-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Webdings-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Webdings-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Zapfino-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Zapfino-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Zapfino-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/Zapfino-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/manifest.json +2928 -0
- package/browser/fonts/macos-bundle/generated/systemui-Bold.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/systemui-BoldItalic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/systemui-Italic.ttf +0 -0
- package/browser/fonts/macos-bundle/generated/systemui-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/install.sh +66 -13
- package/browser/fonts/macos-bundle/vendor/README.md +30 -0
- package/browser/fonts/macos-bundle/vendor/anton/Anton-Regular.ttf +0 -0
- package/browser/fonts/macos-bundle/vendor/anton/OFL.txt +93 -0
- package/browser/fonts/macos-bundle/vendor/gelasio/Gelasio-Italic-VariableFont_wght.ttf +0 -0
- package/browser/fonts/macos-bundle/vendor/gelasio/Gelasio-VariableFont_wght.ttf +0 -0
- package/browser/fonts/macos-bundle/vendor/gelasio/OFL.txt +93 -0
- package/browser/fonts/macos-bundle/verify-metric-fonts.py +171 -0
- package/dist/browser-plugin.d.ts.map +1 -1
- package/dist/browser-plugin.js +39 -10
- package/dist/browser-plugin.js.map +1 -1
- package/dist/browser-process/anti-detect-config.d.ts +18 -3
- package/dist/browser-process/anti-detect-config.d.ts.map +1 -1
- package/dist/browser-process/anti-detect-config.js +103 -8
- package/dist/browser-process/anti-detect-config.js.map +1 -1
- package/dist/browser-process/browser.d.ts.map +1 -1
- package/dist/browser-process/browser.js +67 -2
- package/dist/browser-process/browser.js.map +1 -1
- package/dist/browser-process/fingerprint-config.d.ts.map +1 -1
- package/dist/browser-process/fingerprint-config.js +44 -1
- package/dist/browser-process/fingerprint-config.js.map +1 -1
- package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js +18 -18
- package/dist/fingerprinting/hardware-realism.d.ts +0 -9
- package/dist/fingerprinting/hardware-realism.d.ts.map +1 -1
- package/dist/fingerprinting/hardware-realism.js +21 -22
- package/dist/fingerprinting/hardware-realism.js.map +1 -1
- package/dist/fingerprinting/non-apify-fingerprint-generator.js +11 -11
- package/dist/fingerprinting/non-apify-fingerprint-generator.js.map +1 -1
- package/dist/fingerprinting/non-apify-profiles.json +1 -1
- package/dist/page.d.ts +5 -0
- package/dist/page.d.ts.map +1 -1
- package/dist/page.js +40 -2
- package/dist/page.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- 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()
|