@dreb/dashboard 2.69.0 → 2.70.1

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 (152) hide show
  1. package/README.md +59 -12
  2. package/dist/static/assets/{index-BjOfc5z9.css → index-Cy4rVoNL.css} +1 -1
  3. package/dist/static/assets/{index-yAj_RMi4.js → index-DRZOI-oP.js} +31 -31
  4. package/dist/static/assets/latin-sans-01-italic-BhF1UYrj.woff2 +0 -0
  5. package/dist/static/assets/latin-sans-01-normal-CooN22VI.woff2 +0 -0
  6. package/dist/static/assets/latin-sans-02-italic-C8rT2UfJ.woff2 +0 -0
  7. package/dist/static/assets/latin-sans-02-normal-P44cpLki.woff2 +0 -0
  8. package/dist/static/assets/latin-sans-03-italic-Bs32tvxo.woff2 +0 -0
  9. package/dist/static/assets/latin-sans-03-normal-CBpqxQrR.woff2 +0 -0
  10. package/dist/static/assets/latin-sans-04-italic-BtY0OYmr.woff2 +0 -0
  11. package/dist/static/assets/latin-sans-04-normal-DKPYYJx0.woff2 +0 -0
  12. package/dist/static/assets/latin-sans-05-italic-Lbn_zcOK.woff2 +0 -0
  13. package/dist/static/assets/latin-sans-05-normal-DteEDDXt.woff2 +0 -0
  14. package/dist/static/assets/latin-sans-06-italic-400-LXRWnOst.woff2 +0 -0
  15. package/dist/static/assets/latin-sans-06-italic-700-CNcmneFG.woff2 +0 -0
  16. package/dist/static/assets/latin-sans-06-normal-400-DqF7muwQ.woff2 +0 -0
  17. package/dist/static/assets/latin-sans-06-normal-500-pg5pv_G5.woff2 +0 -0
  18. package/dist/static/assets/latin-sans-06-normal-600-B9FJW0wc.woff2 +0 -0
  19. package/dist/static/assets/latin-sans-06-normal-700-DLrIUglk.woff2 +0 -0
  20. package/dist/static/assets/latin-sans-07-italic-400-CKtsaEFf.woff2 +0 -0
  21. package/dist/static/assets/latin-sans-07-italic-700-k1lfeYID.woff2 +0 -0
  22. package/dist/static/assets/latin-sans-07-normal-400-D4SjDwqg.woff2 +0 -0
  23. package/dist/static/assets/latin-sans-07-normal-500-BFwDlRnY.woff2 +0 -0
  24. package/dist/static/assets/latin-sans-07-normal-600-CN8-zqeB.woff2 +0 -0
  25. package/dist/static/assets/latin-sans-07-normal-700-Dprje8Xw.woff2 +0 -0
  26. package/dist/static/assets/latin-sans-08-italic-BMkCSNgQ.woff2 +0 -0
  27. package/dist/static/assets/latin-sans-08-normal-DzFrvL_j.woff2 +0 -0
  28. package/dist/static/assets/latin-sans-09-italic-t-yha8w2.woff2 +0 -0
  29. package/dist/static/assets/latin-sans-09-normal-C2OGqjfM.woff2 +0 -0
  30. package/dist/static/assets/latin-sans-10-italic-Z6B1G-ai.woff2 +0 -0
  31. package/dist/static/assets/latin-sans-10-normal-CpQbNR0Q.woff2 +0 -0
  32. package/dist/static/assets/latin-sans-11-italic-C_Pex3JK.woff2 +0 -0
  33. package/dist/static/assets/latin-sans-11-normal-BtZ1IUa_.woff2 +0 -0
  34. package/dist/static/assets/latin-sans-12-italic-B2hqvCq9.woff2 +0 -0
  35. package/dist/static/assets/latin-sans-12-normal-DSM_yaqE.woff2 +0 -0
  36. package/dist/static/assets/latin-sans-13-italic-DbTXKBey.woff2 +0 -0
  37. package/dist/static/assets/latin-sans-13-normal-DxBb_JQb.woff2 +0 -0
  38. package/dist/static/assets/latin-sans-14-italic-BOSrMsy6.woff2 +0 -0
  39. package/dist/static/assets/latin-sans-14-normal-T5SN0sLV.woff2 +0 -0
  40. package/dist/static/assets/latin-sans-15-italic-CNYz9lxS.woff2 +0 -0
  41. package/dist/static/assets/latin-sans-15-normal-BusplIXX.woff2 +0 -0
  42. package/dist/static/assets/latin-serif-01-italic-CxlKyYic.woff2 +0 -0
  43. package/dist/static/assets/latin-serif-01-normal-CCi_ZJCz.woff2 +0 -0
  44. package/dist/static/assets/latin-serif-02-italic-DqxHPpL7.woff2 +0 -0
  45. package/dist/static/assets/latin-serif-02-normal-BoIJZXUc.woff2 +0 -0
  46. package/dist/static/assets/latin-serif-03-italic-aNfn46eZ.woff2 +0 -0
  47. package/dist/static/assets/latin-serif-03-normal-Dnv056xP.woff2 +0 -0
  48. package/dist/static/assets/latin-serif-04-italic-z_iGYlqB.woff2 +0 -0
  49. package/dist/static/assets/latin-serif-04-normal-BWPbT48b.woff2 +0 -0
  50. package/dist/static/assets/latin-serif-05-italic-RkiabTLe.woff2 +0 -0
  51. package/dist/static/assets/latin-serif-05-normal-_cooV6mm.woff2 +0 -0
  52. package/dist/static/assets/latin-serif-06-italic-EJ1bzRrK.woff2 +0 -0
  53. package/dist/static/assets/latin-serif-06-normal-CwlGg9c6.woff2 +0 -0
  54. package/dist/static/assets/latin-serif-07-italic-400-BjNq7ALS.woff2 +0 -0
  55. package/dist/static/assets/latin-serif-07-italic-700-CwxpgNgi.woff2 +0 -0
  56. package/dist/static/assets/latin-serif-07-normal-400-Df_lulhg.woff2 +0 -0
  57. package/dist/static/assets/latin-serif-07-normal-700-DhRu3wdH.woff2 +0 -0
  58. package/dist/static/assets/latin-serif-08-italic-BEBSBUZq.woff2 +0 -0
  59. package/dist/static/assets/latin-serif-08-normal-DQLAeWGa.woff2 +0 -0
  60. package/dist/static/assets/latin-serif-09-italic-DueR2NAN.woff2 +0 -0
  61. package/dist/static/assets/latin-serif-09-normal-B1srgB-T.woff2 +0 -0
  62. package/dist/static/assets/latin-serif-10-italic-DtCvNHhm.woff2 +0 -0
  63. package/dist/static/assets/latin-serif-10-normal-Js9YVFbE.woff2 +0 -0
  64. package/dist/static/assets/latin-serif-11-italic-nBfW756x.woff2 +0 -0
  65. package/dist/static/assets/latin-serif-11-normal-BXj0TPQv.woff2 +0 -0
  66. package/dist/static/assets/latin-serif-12-italic-545M7MUI.woff2 +0 -0
  67. package/dist/static/assets/latin-serif-12-normal-BLglyY_d.woff2 +0 -0
  68. package/dist/static/assets/latin-serif-13-italic-400-BUW3WXDN.woff2 +0 -0
  69. package/dist/static/assets/latin-serif-13-italic-700-C--yg4-q.woff2 +0 -0
  70. package/dist/static/assets/latin-serif-13-normal-400-mO2OaU9f.woff2 +0 -0
  71. package/dist/static/assets/latin-serif-13-normal-600-ffdFLwa9.woff2 +0 -0
  72. package/dist/static/assets/latin-serif-13-normal-700-DT2oIs53.woff2 +0 -0
  73. package/dist/static/assets/latin-serif-14-italic-400-DIDaqr_1.woff2 +0 -0
  74. package/dist/static/assets/latin-serif-14-italic-700-CocI1SHE.woff2 +0 -0
  75. package/dist/static/assets/latin-serif-14-normal-400-cfxfxgAo.woff2 +0 -0
  76. package/dist/static/assets/latin-serif-14-normal-700-NvBlleHn.woff2 +0 -0
  77. package/dist/static/assets/latin-serif-15-italic-BJwMM4-K.woff2 +0 -0
  78. package/dist/static/assets/latin-serif-15-normal-BNSD3l2U.woff2 +0 -0
  79. package/dist/static/index.html +3 -3
  80. package/dist/static/sw.js +1 -1
  81. package/package.json +7 -2
  82. package/scripts/build-font-catalog.py +702 -0
  83. package/src/client/assets/fonts/ATKINSON-PROVENANCE.md +4 -4
  84. package/src/client/assets/fonts/FIRACODE-PROVENANCE.md +3 -3
  85. package/src/client/assets/fonts/IOSEVKA-PROVENANCE.md +3 -3
  86. package/src/client/assets/fonts/OPENDYSLEXIC-PROVENANCE.md +4 -4
  87. package/src/client/assets/fonts/PROVENANCE.md +4 -3
  88. package/src/client/assets/fonts/expanded/PROVENANCE.md +1068 -0
  89. package/src/client/assets/fonts/expanded/catalog.json +8822 -0
  90. package/src/client/assets/fonts/expanded/licenses/arimo/OFL.txt +93 -0
  91. package/src/client/assets/fonts/expanded/licenses/arvo/OFL.txt +93 -0
  92. package/src/client/assets/fonts/expanded/licenses/bitter/OFL.txt +94 -0
  93. package/src/client/assets/fonts/expanded/licenses/bodoni-moda/OFL.txt +93 -0
  94. package/src/client/assets/fonts/expanded/licenses/cormorant-garamond/OFL.txt +93 -0
  95. package/src/client/assets/fonts/expanded/licenses/crimson-text/OFL.txt +93 -0
  96. package/src/client/assets/fonts/expanded/licenses/dm-sans/OFL.txt +93 -0
  97. package/src/client/assets/fonts/expanded/licenses/eb-garamond/OFL.txt +93 -0
  98. package/src/client/assets/fonts/expanded/licenses/fraunces/OFL.txt +93 -0
  99. package/src/client/assets/fonts/expanded/licenses/google-sans/OFL.txt +93 -0
  100. package/src/client/assets/fonts/expanded/licenses/google-sans/TRADEMARKS.md +17 -0
  101. package/src/client/assets/fonts/expanded/licenses/inter/OFL.txt +93 -0
  102. package/src/client/assets/fonts/expanded/licenses/lato/OFL.txt +93 -0
  103. package/src/client/assets/fonts/expanded/licenses/libre-baskerville/OFL.txt +93 -0
  104. package/src/client/assets/fonts/expanded/licenses/lora/OFL.txt +93 -0
  105. package/src/client/assets/fonts/expanded/licenses/merriweather/OFL.txt +93 -0
  106. package/src/client/assets/fonts/expanded/licenses/montserrat/OFL.txt +93 -0
  107. package/src/client/assets/fonts/expanded/licenses/newsreader/OFL.txt +93 -0
  108. package/src/client/assets/fonts/expanded/licenses/noto-sans/OFL.txt +93 -0
  109. package/src/client/assets/fonts/expanded/licenses/noto-serif/OFL.txt +93 -0
  110. package/src/client/assets/fonts/expanded/licenses/nunito/OFL.txt +93 -0
  111. package/src/client/assets/fonts/expanded/licenses/nunito-sans/OFL.txt +93 -0
  112. package/src/client/assets/fonts/expanded/licenses/open-sans/OFL.txt +92 -0
  113. package/src/client/assets/fonts/expanded/licenses/playfair-display/OFL.txt +93 -0
  114. package/src/client/assets/fonts/expanded/licenses/poppins/OFL.txt +93 -0
  115. package/src/client/assets/fonts/expanded/licenses/pt-serif/OFL.txt +93 -0
  116. package/src/client/assets/fonts/expanded/licenses/raleway/OFL.txt +93 -0
  117. package/src/client/assets/fonts/expanded/licenses/roboto/OFL.txt +93 -0
  118. package/src/client/assets/fonts/expanded/licenses/roboto-condensed/OFL.txt +93 -0
  119. package/src/client/assets/fonts/expanded/licenses/rubik/OFL.txt +93 -0
  120. package/src/client/assets/fonts/expanded/licenses/source-serif-4/OFL.txt +93 -0
  121. package/src/client/assets/fonts/expanded/metadata/arimo.pb +54 -0
  122. package/src/client/assets/fonts/expanded/metadata/arvo.pb +49 -0
  123. package/src/client/assets/fonts/expanded/metadata/bitter.pb +52 -0
  124. package/src/client/assets/fonts/expanded/metadata/bodoni-moda.pb +61 -0
  125. package/src/client/assets/fonts/expanded/metadata/cormorant-garamond.pb +52 -0
  126. package/src/client/assets/fonts/expanded/metadata/crimson-text.pb +97 -0
  127. package/src/client/assets/fonts/expanded/metadata/dm-sans.pb +53 -0
  128. package/src/client/assets/fonts/expanded/metadata/eb-garamond.pb +55 -0
  129. package/src/client/assets/fonts/expanded/metadata/fraunces.pb +52 -0
  130. package/src/client/assets/fonts/expanded/metadata/google-sans.pb +69 -0
  131. package/src/client/assets/fonts/expanded/metadata/inter.pb +56 -0
  132. package/src/client/assets/fonts/expanded/metadata/lato.pb +359 -0
  133. package/src/client/assets/fonts/expanded/metadata/libre-baskerville.pb +53 -0
  134. package/src/client/assets/fonts/expanded/metadata/lora.pb +55 -0
  135. package/src/client/assets/fonts/expanded/metadata/merriweather.pb +291 -0
  136. package/src/client/assets/fonts/expanded/metadata/montserrat.pb +376 -0
  137. package/src/client/assets/fonts/expanded/metadata/newsreader.pb +230 -0
  138. package/src/client/assets/fonts/expanded/metadata/noto-sans.pb +924 -0
  139. package/src/client/assets/fonts/expanded/metadata/noto-serif.pb +845 -0
  140. package/src/client/assets/fonts/expanded/metadata/nunito-sans.pb +71 -0
  141. package/src/client/assets/fonts/expanded/metadata/nunito.pb +52 -0
  142. package/src/client/assets/fonts/expanded/metadata/open-sans.pb +291 -0
  143. package/src/client/assets/fonts/expanded/metadata/playfair-display.pb +40 -0
  144. package/src/client/assets/fonts/expanded/metadata/poppins.pb +176 -0
  145. package/src/client/assets/fonts/expanded/metadata/pt-serif.pb +50 -0
  146. package/src/client/assets/fonts/expanded/metadata/raleway.pb +38 -0
  147. package/src/client/assets/fonts/expanded/metadata/roboto-condensed.pb +50 -0
  148. package/src/client/assets/fonts/expanded/metadata/roboto.pb +57 -0
  149. package/src/client/assets/fonts/expanded/metadata/rubik.pb +53 -0
  150. package/src/client/assets/fonts/expanded/metadata/source-serif-4.pb +44 -0
  151. package/src/client/assets/fonts/expanded/selection-metadata.json +3860 -0
  152. package/src/client/assets/fonts/expanded/sources.lock.json +686 -0
@@ -0,0 +1,702 @@
1
+ #!/usr/bin/env python3
2
+ """Development-only acquisition/build/verification of PR540's Latin font catalog.
3
+
4
+ From the repository root (Python 3.14 used for the checked-in build):
5
+ python3 -m venv /tmp/dreb-fonts-venv
6
+ /tmp/dreb-fonts-venv/bin/pip install fonttools==4.63.0 brotli==1.2.0
7
+ /tmp/dreb-fonts-venv/bin/python packages/dashboard/scripts/build-font-catalog.py
8
+ /tmp/dreb-fonts-venv/bin/python packages/dashboard/scripts/build-font-catalog.py --verify
9
+
10
+ Normal builds verify every download against sources.lock.json and verify generated
11
+ binary hashes against catalog.json BEFORE writing anything. --verify is offline.
12
+ --bootstrap creates the initial source lock ONLY when it does not already exist.
13
+ --refresh-acquisition requires --backup-dir outside the repo and an existing lock;
14
+ shared source hashes must still match, and obsolete generated assets are removed
15
+ only after acquisition succeeds. Both acquisition modes require
16
+ --selection-snapshot /tmp/pr540-font-work/popularity.json (approved SHA/date).
17
+ Never fetch changing popularity ordering. --cache-dir may point to a temporary
18
+ download cache; cached bytes are hash-checked. --offline rebuild never downloads.
19
+ No Python or acquisition runs at runtime.
20
+ """
21
+
22
+ import argparse
23
+ import hashlib
24
+ import io
25
+ import json
26
+ import logging
27
+ import re
28
+ import shutil
29
+ import sys
30
+ import tempfile
31
+ import time
32
+ import urllib.parse
33
+ import urllib.request
34
+ from importlib.metadata import version
35
+ from pathlib import Path
36
+
37
+ from fontTools import subset
38
+ from fontTools.ttLib import TTFont
39
+ from fontTools.varLib.instancer import instantiateVariableFont
40
+
41
+ COMMIT = "9710da1eacb3be272583c3224dcb70f9da6eadbb"
42
+ BASE = f"https://raw.githubusercontent.com/google/fonts/{COMMIT}/"
43
+ POPULARITY_URL = "https://fonts.google.com/metadata/fonts"
44
+ SELECTION_DATE = "2026-10-02"
45
+ EXISTING_BYTES = 1_395_464
46
+ ROOT = Path(__file__).resolve().parents[1]
47
+ ASSETS = ROOT / "src/client/assets/fonts/expanded"
48
+ CSS = ROOT / "src/client/styles/font-catalog.css"
49
+ SANS = ["Roboto", "Open Sans", "Google Sans", "Inter", "Montserrat", "Poppins",
50
+ "Lato", "Roboto Condensed", "Arimo", "Noto Sans", "DM Sans", "Raleway",
51
+ "Nunito", "Nunito Sans", "Rubik"]
52
+ SERIF = ["Playfair Display", "Merriweather", "Lora", "Noto Serif",
53
+ "Cormorant Garamond", "Libre Baskerville", "PT Serif", "EB Garamond",
54
+ "Fraunces", "Bitter", "Source Serif 4", "Newsreader", "Crimson Text",
55
+ "Arvo", "Bodoni Moda"]
56
+ POPULARITY_SHA256 = "0c4c0412a092525c7e82bcef498c74993b4ec983b915cd321aa3545f09c60b00"
57
+ REPLACEMENT_RANKS = {"Rubik": 29, "Crimson Text": 133, "Arvo": 143, "Bodoni Moda": 156}
58
+ REQUESTED = set(range(0x250)) | set(range(0x300, 0x370)) | set(range(0x2000, 0x27C0))
59
+ IDENTITY_IDS = {1, 2, 3, 4, 6, 16, 17, 18, 20, 21, 22, 25}
60
+ NOTICE_IDS = {0, 7, 13, 14}
61
+
62
+
63
+ def require(condition, message):
64
+ if not condition:
65
+ raise RuntimeError(message)
66
+
67
+
68
+ def sha(data):
69
+ return hashlib.sha256(data).hexdigest()
70
+
71
+
72
+ def encoded_url(path):
73
+ return BASE + urllib.parse.quote(path, safe="/")
74
+
75
+
76
+ def families():
77
+ for kind, labels, group in [("Sans", SANS, "Sans-serif"), ("Serif", SERIF, "Serif")]:
78
+ for index, label in enumerate(labels, 1):
79
+ slug = re.sub(r"(?<=[a-zA-Z])(?=4)", "-", label.lower()).replace(" ", "-")
80
+ folder = "ofl/" + label.lower().replace(" ", "")
81
+ # PT Serif's RFNs include the word Serif; keep derivative names free
82
+ # of every reserved word, not only the complete upstream family.
83
+ derivative_kind = "Text" if label == "PT Serif" else kind
84
+ yield {"id": slug, "label": label, "group": group,
85
+ "family": f"Dreb {derivative_kind} {index:02}",
86
+ "stem": f"latin-{kind.lower()}-{index:02}", "folder": folder}
87
+
88
+
89
+ def download(url):
90
+ for attempt in range(4):
91
+ try:
92
+ with urllib.request.urlopen(url, timeout=120) as response:
93
+ return response.read()
94
+ except (OSError, TimeoutError):
95
+ if attempt == 3:
96
+ raise
97
+ time.sleep(2 ** attempt)
98
+
99
+
100
+ class Sources:
101
+ def __init__(self, bootstrap, cache, refresh=False, offline=False):
102
+ self.bootstrap = bootstrap
103
+ self.refresh = refresh
104
+ self.offline = offline
105
+ self.cache = cache
106
+ lock = ASSETS / "sources.lock.json"
107
+ if bootstrap:
108
+ require(not lock.exists(), "Refusing to bootstrap over an existing source lock")
109
+ self.records = {}
110
+ else:
111
+ require(lock.exists(), "Missing source lock; initial acquisition requires --bootstrap")
112
+ contents = json.loads(lock.read_text())
113
+ require(contents["commit"] == COMMIT, "Source revision mismatch")
114
+ self.records = contents["sources"]
115
+ self.used = set()
116
+ self.outputs = {}
117
+
118
+ def get(self, path, local=None):
119
+ url = encoded_url(path)
120
+ expected = self.records.get(path)
121
+ require(self.bootstrap or self.refresh or expected is not None, f"Unpinned source: {path}")
122
+ target = self.cache / path
123
+ # Newly acquired sources must come from the pinned URL, never from an
124
+ # untrusted/unpinned pre-existing cache entry. Shared sources stay pinned.
125
+ if expected is None or not target.exists():
126
+ require(not self.offline, f"Offline source missing/unpinned: {path}")
127
+ data = download(url)
128
+ if expected is None and target.exists():
129
+ require(target.read_bytes() == data, f"Unpinned cache differs from pinned upstream: {path}")
130
+ else:
131
+ data = target.read_bytes()
132
+ record = {"url": url, "sha256": sha(data), "bytes": len(data)}
133
+ if expected is not None:
134
+ require(record == expected, f"Source hash/URL/size mismatch: {path}")
135
+ else:
136
+ self.records[path] = record
137
+ target.parent.mkdir(parents=True, exist_ok=True)
138
+ target.write_bytes(data)
139
+ self.used.add(path)
140
+ if local:
141
+ self.outputs[ASSETS / local] = data
142
+ return data, record
143
+
144
+ def lock(self):
145
+ if not self.refresh:
146
+ require(self.used == set(self.records), "Unused/missing sources in lock")
147
+ return {"commit": COMMIT, "sources": {path: self.records[path] for path in sorted(self.used)}}
148
+
149
+
150
+ def json_bytes(value):
151
+ return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode()
152
+
153
+
154
+ def ranges(points):
155
+ result = []
156
+ for point in sorted(points):
157
+ if result and point == result[-1][1] + 1:
158
+ result[-1][1] = point
159
+ else:
160
+ result.append([point, point])
161
+ return [f"U+{start:04X}" if start == end else f"U+{start:04X}-{end:04X}"
162
+ for start, end in result]
163
+
164
+
165
+ def expand_ranges(values):
166
+ result = set()
167
+ for value in values:
168
+ match = re.fullmatch(r"U\+([0-9A-F]{4,6})(?:-([0-9A-F]{4,6}))?", value)
169
+ require(match is not None, f"Invalid coverage range: {value}")
170
+ start = int(match[1], 16)
171
+ end = int(match[2], 16) if match[2] else start
172
+ result.update(range(start, end + 1))
173
+ return result
174
+
175
+
176
+ def parse_faces(metadata):
177
+ # The font blocks at this pinned revision have no nested messages.
178
+ result = []
179
+ for block in re.findall(r"^fonts \{\n(.*?)^\}", metadata, flags=re.M | re.S):
180
+ def field(key):
181
+ match = re.search(rf'^ {key}: "([^"]+)"$', block, flags=re.M)
182
+ require(match is not None, f"Missing {key} in font metadata")
183
+ return match[1]
184
+ weight = re.search(r"^ weight: (\d+)$", block, flags=re.M)
185
+ require(weight is not None, "Missing weight in metadata")
186
+ result.append({"filename": field("filename"), "style": field("style"),
187
+ "weight": int(weight[1])})
188
+ require(result, "No font faces in metadata")
189
+ return result
190
+
191
+
192
+ def selected_faces(faces):
193
+ selected = []
194
+ for style in ("normal", "italic"):
195
+ candidates = [face for face in faces if face["style"] == style]
196
+ variable = [face for face in candidates if "[" in face["filename"]]
197
+ if variable:
198
+ require(len(variable) == len(candidates) == 1,
199
+ f"Ambiguous {style} variable face")
200
+ require("wght" in variable[0]["filename"], f"No {style} weight axis in metadata")
201
+ selected.extend(variable)
202
+ else:
203
+ by_weight = {face["weight"]: face for face in candidates}
204
+ require(len(by_weight) == len(candidates), f"Ambiguous static {style} weights")
205
+ require({400, 700} <= by_weight.keys(), f"Missing real {style} 400/700")
206
+ weights = [400] + ([weight for weight in (500, 600) if weight in by_weight]
207
+ if style == "normal" else []) + [700]
208
+ selected.extend(by_weight[weight] for weight in weights)
209
+ return selected
210
+
211
+
212
+ def validate_source_face(font, face):
213
+ require(bool(font["OS/2"].fsSelection & 1) == (face["style"] == "italic"),
214
+ f"Upstream TTF style contradicts pinned metadata: {face['filename']}")
215
+ if "fvar" in font:
216
+ require("[" in face["filename"], f"Upstream variable TTF contradicts static metadata: {face['filename']}")
217
+ axes = {axis.axisTag: axis for axis in font["fvar"].axes}
218
+ require("wght" in axes and axes["wght"].minValue <= 400 and axes["wght"].maxValue >= 700,
219
+ f"Upstream TTF cannot provide real 400/700: {face['filename']}")
220
+ else:
221
+ require("[" not in face["filename"] and font["OS/2"].usWeightClass == face["weight"],
222
+ f"Upstream TTF weight contradicts pinned metadata: {face['filename']}")
223
+
224
+
225
+ def validate_core_styles(faces, label):
226
+ for style in ("normal", "italic"):
227
+ for weight in (400, 700):
228
+ matches = [face for face in faces if face["style"] == style and
229
+ int(face["weight"].split()[0]) <= weight <= int(face["weight"].split()[-1])]
230
+ require(len(matches) == 1, f"Missing/ambiguous real {style} {weight}: {label}")
231
+
232
+
233
+ def name_values(family, style, weight):
234
+ suffix = {"400": "Regular", "500": "Medium", "600": "SemiBold", "700": "Bold"}.get(weight, "Regular")
235
+ if style == "italic":
236
+ suffix = "Italic" if suffix == "Regular" else suffix + " Italic"
237
+ ps = family.replace(" ", "")
238
+ ps_suffix = suffix.replace(" ", "")
239
+ return {1: family, 2: suffix, 3: f"{ps}-{ps_suffix};DrebLatin-v1", 4: f"{family} {suffix}",
240
+ 6: f"{ps}-{ps_suffix}", 16: family, 17: suffix, 18: f"{family} {suffix}",
241
+ 20: f"{ps}-{ps_suffix}", 21: family, 22: suffix, 25: ps}
242
+
243
+
244
+ def rename(font, family, style, weight):
245
+ names = font["name"]
246
+ replacements = name_values(family, style, weight)
247
+ # No custom upstream names survive in variable axes/STAT/instance identifiers.
248
+ custom_ids = {record.nameID for record in names.names if record.nameID >= 256}
249
+ for name_id in custom_ids:
250
+ replacements[name_id] = f"{family} Attribute {name_id}"
251
+ if "fvar" in font:
252
+ for axis in font["fvar"].axes:
253
+ replacements[axis.axisNameID] = f"{family} Weight"
254
+ next_id = max({255} | {record.nameID for record in names.names}) + 1
255
+ for index, instance in enumerate(font["fvar"].instances, 1):
256
+ # Some upstream instances share ID 2 (Regular); give every instance
257
+ # its own ID so it cannot overwrite the required base identity.
258
+ instance.subfamilyNameID = next_id
259
+ replacements[next_id] = f"{family} Instance {index:02}"
260
+ next_id += 1
261
+ instance.postscriptNameID = next_id
262
+ replacements[next_id] = family.replace(" ", "") + f"-Instance{index:02}"
263
+ next_id += 1
264
+ names.names = [record for record in names.names if record.nameID not in replacements]
265
+ for name_id, text in sorted(replacements.items()):
266
+ # One Unicode and one Windows Unicode English record, no stale localized identities.
267
+ names.setName(text, name_id, 0, 4, 0)
268
+ names.setName(text, name_id, 3, 1, 0x409)
269
+ # These sources are TrueType outlines, not CFF with another family-name store.
270
+ require("CFF " not in font and "CFF2" not in font, "Unexpected CFF name store")
271
+ return replacements
272
+
273
+
274
+ def validate_names(font, family, style, weight):
275
+ expected = name_values(family, style, weight)
276
+ for name_id, value in expected.items():
277
+ records = [record.toUnicode() for record in font["name"].names if record.nameID == name_id]
278
+ require(records and set(records) == {value}, f"Incorrect neutral name ID {name_id}: {family}")
279
+ for record in font["name"].names:
280
+ if record.nameID >= 256:
281
+ require(record.toUnicode().startswith((family, family.replace(" ", ""))),
282
+ f"Non-neutral variation identifier: {family}, {record.nameID}")
283
+ if "fvar" in font:
284
+ for instance in font["fvar"].instances:
285
+ require(font["name"].getDebugName(instance.subfamilyNameID).startswith(family),
286
+ "Unrenamed variable instance")
287
+ if instance.postscriptNameID != 0xFFFF:
288
+ require(font["name"].getDebugName(instance.postscriptNameID).startswith(family.replace(" ", "")),
289
+ "Unrenamed variable PostScript instance")
290
+
291
+
292
+ def outline_signature(font, glyph_name):
293
+ glyph = font["glyf"][glyph_name]
294
+ coordinates, endpoints, flags = glyph.getCoordinates(font["glyf"])
295
+ # WOFF2 may normalize encoding flags, but on/off-curve points, coordinates,
296
+ # contour endpoints and hint bytecode must remain identical.
297
+ hints = bytes(glyph.program.getBytecode()) if hasattr(glyph, "program") else b""
298
+ return (tuple(coordinates), tuple(endpoints), tuple(flag & 1 for flag in flags), hints)
299
+
300
+
301
+ def transform(data, family, face):
302
+ font = TTFont(io.BytesIO(data), recalcTimestamp=False)
303
+ validate_source_face(font, face)
304
+ source_points = set(font.getBestCmap())
305
+ notices = {(record.nameID, record.toUnicode()) for record in font["name"].names
306
+ if record.nameID in NOTICE_IDS}
307
+ axis_details = []
308
+ weight = str(face["weight"])
309
+ if "fvar" in font:
310
+ limits = {}
311
+ for axis in font["fvar"].axes:
312
+ upstream = [axis.minValue, axis.defaultValue, axis.maxValue]
313
+ if axis.axisTag == "wght":
314
+ require(axis.minValue <= 400 and axis.maxValue >= 700, f"No upstream 400–700: {family}")
315
+ limits[axis.axisTag] = (400, max(400, min(axis.defaultValue, 700)), 700)
316
+ weight = "400 700"
317
+ else:
318
+ limits[axis.axisTag] = axis.defaultValue
319
+ retained = limits[axis.axisTag]
320
+ axis_details.append({"tag": axis.axisTag, "upstreamMinDefaultMax": upstream,
321
+ "retained": list(retained) if isinstance(retained, tuple) else retained})
322
+ font = instantiateVariableFont(font, limits, inplace=True, optimize=True)
323
+ # fontTools 4.63.0 drops empty gvar entries while instancing, but its
324
+ # subsetter expects an entry for every surviving glyph (e.g. filledbox
325
+ # in Roboto). Restore empty entries; this changes no outline/delta.
326
+ if "gvar" in font:
327
+ for glyph in font.getGlyphOrder():
328
+ if glyph not in font["gvar"].variations:
329
+ font["gvar"].variations[glyph] = []
330
+ # Materialize the evaluated upstream instance in TrueType's integer
331
+ # coordinate encoding before comparing outlines (clamping a default below
332
+ # 400 can evaluate fractional coordinates, e.g. Montserrat).
333
+ normalized = io.BytesIO()
334
+ font.save(normalized, reorderTables=True)
335
+ font = TTFont(io.BytesIO(normalized.getvalue()), recalcTimestamp=False)
336
+ # Keep all layout features and unchanged surviving outlines/hints. Instancing
337
+ # evaluates upstream variations; it does not redesign the source letterforms.
338
+ outlines = {glyph: outline_signature(font, glyph) for glyph in font.getGlyphOrder()}
339
+ options = subset.Options()
340
+ options.notdef_outline = True
341
+ options.layout_features = ["*"]
342
+ options.name_IDs = ["*"]
343
+ options.name_languages = ["*"]
344
+ options.name_legacy = True
345
+ options.glyph_names = True
346
+ options.recalc_timestamp = False
347
+ subsetter = subset.Subsetter(options=options)
348
+ subsetter.populate(unicodes=sorted(REQUESTED))
349
+ subsetter.subset(font)
350
+ rename(font, family, face["style"], weight)
351
+ font.recalcTimestamp = False
352
+ font.flavor = "woff2"
353
+ output = io.BytesIO()
354
+ retained_order = font.getGlyphOrder()[:]
355
+ font.save(output, reorderTables=True)
356
+ result = output.getvalue()
357
+ verified = TTFont(io.BytesIO(result), recalcTimestamp=False)
358
+ points = set(verified.getBestCmap())
359
+ require(points == source_points & REQUESTED, f"Subset coverage mismatch: {family}")
360
+ require(notices <= {(record.nameID, record.toUnicode()) for record in verified["name"].names},
361
+ f"Lost copyright/trademark/license notice: {family}")
362
+ validate_names(verified, family, face["style"], weight)
363
+ require(len(verified.getGlyphOrder()) == len(retained_order), "Glyph count changed during WOFF2 encoding")
364
+ # post format 3 sources regenerate synthetic glyph names on reload, so use
365
+ # stable glyph indices rather than requiring those synthetic names to match.
366
+ for glyph, original_glyph in zip(verified.getGlyphOrder(), retained_order):
367
+ require(outline_signature(verified, glyph) == outlines[original_glyph],
368
+ f"Surviving outline/hint changed: {family}, {original_glyph}")
369
+ if "fvar" in verified:
370
+ axes = verified["fvar"].axes
371
+ require(len(axes) == 1 and axes[0].axisTag == "wght" and
372
+ axes[0].minValue == 400 and axes[0].maxValue == 700,
373
+ f"Unexpected retained axes: {family}")
374
+ require(weight == f"{axes[0].minValue:g} {axes[0].maxValue:g}", "Weight declaration mismatch")
375
+ else:
376
+ require(" " not in weight and int(weight) == verified["OS/2"].usWeightClass,
377
+ f"Static weight mismatch: {family}")
378
+ require(bool(verified["OS/2"].fsSelection & 1) == (face["style"] == "italic"),
379
+ f"Style mismatch: {family}")
380
+ return result, weight, points, axis_details, verified["maxp"].numGlyphs
381
+
382
+
383
+ def stylesheet(catalog):
384
+ lines = ["/* Generated by scripts/build-font-catalog.py; do not edit. */", ""]
385
+ for entry in catalog:
386
+ for face in entry["faces"]:
387
+ lines.extend(["@font-face {", f" font-family: '{entry['family']}';",
388
+ f" src: url('../assets/fonts/expanded/{face['file']}') format('woff2');",
389
+ f" font-style: {face['style']};", f" font-weight: {face['weight']};",
390
+ " font-display: swap;", "}", ""])
391
+ lines.extend([f"[data-font='{entry['id']}'] {{",
392
+ f" --mono-font: '{entry['family']}', 'IBM Plex Mono', 'Courier New', monospace;",
393
+ "}", ""])
394
+ return ("\n".join(lines).rstrip() + "\n").encode()
395
+
396
+
397
+ def selection_snapshot(path):
398
+ require(path is not None, "Acquisition requires --selection-snapshot pointing to the approved saved response")
399
+ data = path.read_bytes()
400
+ require(sha(data) == POPULARITY_SHA256, "Not the original approved 2026-10-02 popularity snapshot")
401
+ metadata = json.loads(data)
402
+ indexed = {entry["family"]: entry for entry in metadata["familyMetadataList"]}
403
+ selected = []
404
+ for family in families():
405
+ key = family["label"]
406
+ require(key in indexed, f"Approved family missing from popularity metadata: {key}")
407
+ if key in REPLACEMENT_RANKS:
408
+ require(indexed[key]["popularity"] == REPLACEMENT_RANKS[key], f"Replacement popularity mismatch: {key}")
409
+ selected.append({"id": family["id"], **indexed[key]})
410
+ return {"selectionDate": SELECTION_DATE, "sourceUrl": POPULARITY_URL,
411
+ "responseSha256": sha(data), "note": "Approved 15 sans + 15 serif selection in settled order; popularity is upstream metadata, not a runtime fetch or a claim of strict rank sorting.",
412
+ "selectedFamilyMetadata": selected}
413
+
414
+
415
+ def provenance(catalog, details, source_lock, selection):
416
+ total = sum(face["bytes"] for entry in catalog for face in entry["faces"])
417
+ lines = ["# Expanded Latin font catalog — PR540", "",
418
+ "## Source and approved selection", "",
419
+ f"All fonts, metadata and license/trademark files come from google/fonts commit `{COMMIT}`.",
420
+ f"Approved selection: 15 sans-serif followed by 15 serif families, in catalog order, based on Google Fonts metadata on {SELECTION_DATE}.",
421
+ f"Popularity source: {POPULARITY_URL}; `selection-metadata.json` preserves the selected upstream records and the full response SHA-256 `{selection['responseSha256']}`.",
422
+ "This is a build-time snapshot, not a runtime fetch; the settled order is not asserted to be a strict global popularity ranking.",
423
+ "Original family labels in this source manifest identify upstream designs and imply no endorsement. RFN-restricted derivatives use their neutral family as the primary Dashboard menu/trigger name, with the original family only in a clearly secondary Based on source description. Typeahead may search that source attribution without changing the derivative identity.", "",
424
+ "## Reproduction and verification", "", "```sh",
425
+ "python3 -m venv /tmp/dreb-fonts-venv",
426
+ "/tmp/dreb-fonts-venv/bin/pip install fonttools==4.63.0 brotli==1.2.0",
427
+ "/tmp/dreb-fonts-venv/bin/python packages/dashboard/scripts/build-font-catalog.py",
428
+ "/tmp/dreb-fonts-venv/bin/python packages/dashboard/scripts/build-font-catalog.py --verify",
429
+ "```", "",
430
+ "Python 3.14 is required. Acquisition uses the original saved `/tmp/pr540-font-work/popularity.json` response via `--selection-snapshot`, with its approved SHA-256/date, never a live ordering fetch. Initial `--bootstrap` is allowed only without a source lock. This revision used `--refresh-acquisition --backup-dir <new-directory-outside-repo>`: previous assets, CSS and script are backed up before refresh; shared source records remain hash-checked, new sources are fetched from the same pinned commit (any pre-existing unpinned cache bytes must match), and obsolete binaries/notices/metadata/source entries are removed only after successful acquisition. Subsequent builds verify every source SHA-256/size/URL against `sources.lock.json`, including cached TTFs, metadata and notices, and compare all generated outputs before writing. Rebuild offline with `--offline --cache-dir /tmp/pr540-font-work/sources`. `--verify` independently checks bundled binaries, neutral names, all four real core styles, retained axes, exact coverage, local source metadata/notices, selection snapshot, exact file/source inventory and generated CSS offline. No Python dependency at browser/server runtime.", "",
431
+ "## Derivative processing and naming", "",
432
+ "Subset requested codepoints: `U+0000-024F,U+0300-036F,U+2000-27BF` (Basic Latin, Latin-1, Latin Extended A/B, common combining marks, punctuation, currency, mathematical operators, arrows, technical/enclosed symbols, geometric shapes, status symbols and dingbats). Only actually present codepoints survive. No giant CJK/Devanagari subsets are retained.",
433
+ "Every selected family must supply four real upstream core styles: normal 400, normal 700, italic 400 and italic 700. Eligibility is enforced against pinned METADATA.pb selection and actual TTF styles/weights/axes, and again against generated WOFF2s offline. No upright-only exemption or synthetic important style is permitted. Dedicated non-Latin variants remain excluded.",
434
+ "Rejected Oswald and Roboto Slab lack italic faces; Instrument Serif and DM Serif Display lack bold faces. The settled roster replaces them with Rubik (snapshot popularity rank 29), Crimson Text (133), Arvo (143) and Bodoni Moda (156). The approved 15+15 order is preserved, rather than re-sorting against changing live metadata.",
435
+ "All layout features, surviving glyph outlines (including .notdef) and hints are retained; variable instancing evaluates the upstream outlines at upstream default non-weight axes. No letterforms were redesigned. Both variable normal and variable italic retain exactly wght 400–700 (default clamped into that range); all other axes freeze at actual upstream TTF defaults, not registry defaults. Static normal includes 400, 700 and any available 500/600; static italic includes only 400 and 700. Browsers can fetch one normal variable face for regular 400 where available; extra static and italic faces remain lazy @font-face resources.",
436
+ "The evaluated upstream instance is materialized in TrueType integer coordinates before subsetting. Every surviving glyph's coordinates, contour endpoints, on/off-curve flags and hint bytecode are checked unchanged after WOFF2 encoding. For fontTools 4.63.0's instancer/subsetter mismatch, missing empty gvar entries are restored before subsetting (no outline or delta changes). Source files with post format 3 use glyph indices for this comparison because synthetic glyph names can change on reload.",
437
+ "All derivative name IDs 1/2/3/4/6/16/17/25 (also 18/20/21/22), custom variation/STAT names, instance names and instance PostScript names are rewritten to neutral Dreb Sans/Serif 01–15 families (PT Serif instead uses Dreb Text 07 to avoid the reserved word Serif) and matching neutral PostScript identifiers. Localized upstream identities are removed. Copyright, trademark and license notice name IDs 0/7/13/14 are retained unchanged. Source timestamps are preserved with recalculation disabled. WOFF2 encoding uses the pinned Brotli version.",
438
+ "`catalog.json` coverage is the exact verified intersection of cmap codepoints across a family's faces; per-face actual ranges below preserve any differences. Coverage ranges do not promise every requested codepoint. Missing codepoints resolve through `'IBM Plex Mono', 'Courier New', monospace` and then browser fallback; no glyphs or faces are fabricated.", "",
439
+ "## Licenses and caveats", "",
440
+ "All 30 families use SIL OFL 1.1, checked from their actual pinned upstream OFL.txt contents; each verbatim OFL is bundled in `licenses/<id>/OFL.txt`. These modified subsets use neutral names even where an upstream Reserved Font Name exists. Notices retain original attribution, as required; RFNs and Google Sans trademarks are not used as derivative font identifiers. OFL derivatives remain OFL and may not be sold by themselves.",
441
+ "This provenance identifies the modified subset/instancing/renaming/container conversion. Google Sans includes verbatim TRADEMARKS.md in addition to OFL.txt; its original label is attribution only and conveys no Google endorsement or trademark rights.", "",
442
+ "## Binary payload", "",
443
+ f"Existing bundled binaries (untouched): **{EXISTING_BYTES:,} bytes**.",
444
+ f"Regular-400 browser faces: **{sum(entry['regular400Bytes'] for entry in catalog):,} bytes** across 30 families (includes the complete 400–700 normal variable resource where applicable).",
445
+ f"Source-complete bundled additions (all required styles plus available static normal 500/600): **{total:,} bytes**, {sum(len(entry['faces']) for entry in catalog)} WOFF2 faces across {len(catalog)} families.",
446
+ f"Combined font binary payload: **{EXISTING_BYTES + total:,} bytes**. Metadata, source lock, notices, provenance and CSS are additional non-font bytes; source TTFs stay in a temporary development cache and are not bundled.", ""]
447
+ for entry in catalog:
448
+ family_details = details[entry["id"]]
449
+ lines.extend([f"## {entry['label']} → {entry['family']}", "",
450
+ f"ID: `{entry['id']}`; group: {entry['group']}; bytes: {sum(face['bytes'] for face in entry['faces']):,}.",
451
+ f"Upstream metadata: [{family_details['metadataPath']}]({encoded_url(family_details['metadataPath'])}) (bundled verbatim as `metadata/{entry['id']}.pb`).",
452
+ "License/notice files: " + ", ".join(f"`{file}`" for file in entry["licenseFiles"]) + "."])
453
+ lines.append(f"Regular-400 face payload: **{entry['regular400Bytes']:,} bytes**; all four real core styles verified.")
454
+ lines.append("")
455
+ for face, detail in zip(entry["faces"], family_details["faces"]):
456
+ lines.extend([f"### `{face['file']}`", "",
457
+ f"Style: {face['style']}; weight: `{face['weight']}`; bytes: {face['bytes']:,}; glyphs: {detail['glyphs']}; cmap codepoints: {len(detail['points'])}.",
458
+ f"Source: {face['sourceUrl']}",
459
+ f"Source SHA-256: `{face['sourceSha256']}`; source bytes: {detail['sourceBytes']:,}.",
460
+ f"WOFF2 SHA-256: `{face['sha256']}`.",
461
+ "Axes (upstream min/default/max → retained): " + (json.dumps(detail["axes"]) if detail["axes"] else "static upstream face") + ".",
462
+ "Actual verified cmap coverage: `" + ",".join(ranges(detail["points"])) + "`.",
463
+ "Missing requested status glyphs (browser fallback): " + (", ".join(f"U+{point:04X}" for point in [0x25CF, 0x25C6, 0x25CB, 0x2715, 0x21BB] if point not in detail["points"]) or "none") + ".", ""])
464
+ lines.extend(["## Pinned non-binary sources", "",
465
+ "All acquired source records (including TTFs) are hash/size/URL pinned in `sources.lock.json`. Verbatim bundled metadata and notices:", ""])
466
+ for path, record in source_lock["sources"].items():
467
+ if not path.endswith(".ttf"):
468
+ lines.append(f"- [{path}]({record['url']}): {record['bytes']} bytes; SHA-256 `{record['sha256']}`.")
469
+ return ("\n".join(lines) + "\n").encode()
470
+
471
+
472
+ def build(bootstrap, cache, refresh=False, snapshot=None, offline=False):
473
+ sources = Sources(bootstrap, cache, refresh, offline)
474
+ if bootstrap or refresh:
475
+ selection = selection_snapshot(snapshot)
476
+ else:
477
+ selection = json.loads((ASSETS / "selection-metadata.json").read_text())
478
+ require(selection["selectionDate"] == SELECTION_DATE and
479
+ [item["id"] for item in selection["selectedFamilyMetadata"]] == [item["id"] for item in families()],
480
+ "Selection snapshot mismatch")
481
+ outputs = sources.outputs
482
+ catalog = []
483
+ details = {}
484
+ for family in families():
485
+ metadata_path = family["folder"] + "/METADATA.pb"
486
+ metadata, _ = sources.get(metadata_path, f"metadata/{family['id']}.pb")
487
+ selected = selected_faces(parse_faces(metadata.decode()))
488
+ licenses = ["OFL.txt"]
489
+ if family["label"] == "Google Sans":
490
+ licenses.append("TRADEMARKS.md")
491
+ license_files = []
492
+ for filename in licenses:
493
+ local = f"licenses/{family['id']}/{filename}"
494
+ notice, _ = sources.get(f"{family['folder']}/{filename}", local)
495
+ if filename == "OFL.txt":
496
+ require(b"SIL OPEN FONT LICENSE Version 1.1" in notice and
497
+ b"PREAMBLE" in notice, f"Not actual upstream OFL 1.1: {family['label']}")
498
+ license_files.append(local)
499
+ entry = {key: family[key] for key in ("id", "label", "group", "family")}
500
+ entry["faces"] = []
501
+ entry["licenseFiles"] = license_files
502
+ face_details = []
503
+ for face in selected:
504
+ data, record = sources.get(f"{family['folder']}/{face['filename']}")
505
+ binary, weight, points, axes, glyphs = transform(data, family["family"], face)
506
+ suffix = f"-{weight}" if "[" not in face["filename"] else ""
507
+ filename = f"{family['stem']}-{face['style']}{suffix}.woff2"
508
+ outputs[ASSETS / filename] = binary
509
+ entry["faces"].append({"file": filename, "style": face["style"], "weight": weight,
510
+ "sha256": sha(binary), "bytes": len(binary),
511
+ "sourceUrl": record["url"], "sourceSha256": record["sha256"],
512
+ "sourceBytes": record["bytes"], "axes": axes,
513
+ "coverage": ranges(points), "glyphs": glyphs})
514
+ face_details.append({"points": points, "axes": axes, "glyphs": glyphs,
515
+ "sourceBytes": record["bytes"]})
516
+ validate_core_styles(entry["faces"], entry["label"])
517
+ entry["regular400Bytes"] = sum(face["bytes"] for face in entry["faces"]
518
+ if face["style"] == "normal" and face["weight"].split()[0] == "400")
519
+ entry["totalBytes"] = sum(face["bytes"] for face in entry["faces"])
520
+ entry["coverage"] = ranges(set.intersection(*(detail["points"] for detail in face_details)))
521
+ catalog.append(entry)
522
+ details[family["id"]] = {"metadataPath": metadata_path, "faces": face_details}
523
+ print(f"{family['label']}: {len(entry['faces'])} faces, {sum(face['bytes'] for face in entry['faces']):,} bytes", flush=True)
524
+ lock = sources.lock()
525
+ selection_bytes = json_bytes(selection)
526
+ selection_hash = sha(selection_bytes)
527
+ if not bootstrap and not refresh:
528
+ previous_lock = json.loads((ASSETS / "sources.lock.json").read_text())
529
+ require(selection_hash == previous_lock["selectionSha256"], "Selection snapshot hash mismatch")
530
+ lock["selectionSha256"] = selection_hash
531
+ outputs[ASSETS / "sources.lock.json"] = json_bytes(lock)
532
+ outputs[ASSETS / "selection-metadata.json"] = selection_bytes
533
+ outputs[ASSETS / "catalog.json"] = json_bytes(catalog)
534
+ outputs[ASSETS / "PROVENANCE.md"] = provenance(catalog, details, lock, selection)
535
+ outputs[CSS] = stylesheet(catalog)
536
+ if not bootstrap and not refresh:
537
+ previous = json.loads((ASSETS / "catalog.json").read_text())
538
+ require(catalog == previous, "Rebuilt catalog/hash differs; no files written")
539
+ for path, data in outputs.items():
540
+ require(path.read_bytes() == data, f"Non-deterministic rebuild: {path}")
541
+ if refresh:
542
+ # Acquisition has completed and all outputs are validated in memory.
543
+ # Obsolete files are removed only now, after the required external backup.
544
+ for path in ASSETS.rglob("*"):
545
+ if path.is_file() and path not in outputs:
546
+ path.unlink()
547
+ for path in sorted(ASSETS.rglob("*"), reverse=True):
548
+ if path.is_dir() and not any(path.iterdir()):
549
+ path.rmdir()
550
+ for path, data in outputs.items():
551
+ path.parent.mkdir(parents=True, exist_ok=True)
552
+ path.write_bytes(data)
553
+ verify()
554
+
555
+
556
+ def verify():
557
+ catalog = json.loads((ASSETS / "catalog.json").read_text())
558
+ lock = json.loads((ASSETS / "sources.lock.json").read_text())
559
+ require(lock["commit"] == COMMIT, "Source revision mismatch")
560
+ require(sha((ASSETS / "selection-metadata.json").read_bytes()) == lock["selectionSha256"],
561
+ "Selection snapshot hash mismatch")
562
+ selection = json.loads((ASSETS / "selection-metadata.json").read_text())
563
+ require(selection["selectionDate"] == SELECTION_DATE and selection["responseSha256"] == POPULARITY_SHA256
564
+ and selection["sourceUrl"] == POPULARITY_URL, "Wrong approved popularity snapshot")
565
+ require([(item["id"], item["family"]) for item in selection["selectedFamilyMetadata"]] ==
566
+ [(item["id"], item["label"]) for item in families()], "Selection order/identity mismatch")
567
+ for item in selection["selectedFamilyMetadata"]:
568
+ if item["family"] in REPLACEMENT_RANKS:
569
+ require(item["popularity"] == REPLACEMENT_RANKS[item["family"]], "Wrong replacement rank")
570
+ require(len(catalog) == 30, "Missing approved families")
571
+ used_sources = set()
572
+ expected_paths = {"catalog.json", "sources.lock.json", "selection-metadata.json", "PROVENANCE.md"}
573
+ for entry, family in zip(catalog, families()):
574
+ for key in ("id", "label", "group", "family"):
575
+ require(entry[key] == family[key], f"Approved catalog order/identity mismatch: {key}")
576
+ metadata_file = f"metadata/{entry['id']}.pb"
577
+ expected_paths.add(metadata_file)
578
+ metadata = (ASSETS / metadata_file).read_bytes()
579
+ metadata_path = family["folder"] + "/METADATA.pb"
580
+ used_sources.add(metadata_path)
581
+ require(sha(metadata) == lock["sources"][metadata_path]["sha256"] and
582
+ len(metadata) == lock["sources"][metadata_path]["bytes"], "Metadata hash/size mismatch")
583
+ selected = selected_faces(parse_faces(metadata.decode()))
584
+ require(len(selected) == len(entry["faces"]), f"Missing/extra faces: {entry['label']}")
585
+ validate_core_styles(entry["faces"], entry["label"])
586
+ require(entry["totalBytes"] == sum(face["bytes"] for face in entry["faces"]) and
587
+ entry["regular400Bytes"] == sum(face["bytes"] for face in entry["faces"]
588
+ if face["style"] == "normal" and face["weight"].split()[0] == "400"),
589
+ "Incorrect payload totals")
590
+ required_notices = ["OFL.txt"]
591
+ if family["label"] == "Google Sans":
592
+ required_notices.append("TRADEMARKS.md")
593
+ require(entry["licenseFiles"] == [f"licenses/{entry['id']}/{name}" for name in required_notices],
594
+ "Missing/misclassified license or trademark notice")
595
+ all_points = []
596
+ for face, upstream in zip(entry["faces"], selected):
597
+ expected_paths.add(face["file"])
598
+ require(face["file"].startswith(family["stem"] + "-"), "Non-neutral filename")
599
+ data = (ASSETS / face["file"]).read_bytes()
600
+ require(sha(data) == face["sha256"] and len(data) == face["bytes"], f"Binary hash/size mismatch: {face['file']}")
601
+ require(face["style"] == upstream["style"], "Nonexistent source style")
602
+ source_path = family["folder"] + "/" + upstream["filename"]
603
+ used_sources.add(source_path)
604
+ source = lock["sources"][source_path]
605
+ require(face["sourceSha256"] == source["sha256"] and face["sourceUrl"] == source["url"]
606
+ and face["sourceBytes"] == source["bytes"], "Incorrect source reference")
607
+ font = TTFont(io.BytesIO(data), recalcTimestamp=False)
608
+ require(font.flavor == "woff2", "Wrong font container")
609
+ validate_names(font, entry["family"], face["style"], face["weight"])
610
+ points = set(font.getBestCmap())
611
+ require(points <= REQUESTED, "Non-Latin-focused codepoints leaked")
612
+ require(face["coverage"] == ranges(points) and face["glyphs"] == font["maxp"].numGlyphs,
613
+ "False per-face coverage/glyph count")
614
+ all_points.append(points)
615
+ if "fvar" in font:
616
+ axes = font["fvar"].axes
617
+ require("[" in upstream["filename"] and len(axes) == 1 and axes[0].axisTag == "wght",
618
+ "Unexpected variable axes")
619
+ require(axes[0].minValue == 400 and axes[0].maxValue == 700 and
620
+ face["weight"] == "400 700", "False weight range")
621
+ source_axes = {axis["tag"]: axis for axis in face["axes"]}
622
+ require("wght" in source_axes, "Missing source weight axis provenance")
623
+ for tag, axis in source_axes.items():
624
+ low, default, high = axis["upstreamMinDefaultMax"]
625
+ require(low <= default <= high, "Invalid upstream axis range")
626
+ require(axis["retained"] == ([400, max(400, min(default, 700)), 700]
627
+ if tag == "wght" else default), "Incorrect retained source axes")
628
+ if tag == "wght":
629
+ require(low <= 400 and high >= 700 and axes[0].defaultValue == max(400, min(default, 700)),
630
+ "Upstream weight axis cannot provide real core styles")
631
+ else:
632
+ require("[" not in upstream["filename"] and not face["axes"] and
633
+ face["weight"] == str(upstream["weight"]) == str(font["OS/2"].usWeightClass),
634
+ "False static weight")
635
+ require(bool(font["OS/2"].fsSelection & 1) == (face["style"] == "italic"), "False italic declaration")
636
+ require(expand_ranges(entry["coverage"]) == set.intersection(*all_points), "False coverage ranges")
637
+ require(entry["coverage"] == ranges(set.intersection(*all_points)), "Unsorted/noncompact coverage ranges")
638
+ for filename in entry["licenseFiles"]:
639
+ expected_paths.add(filename)
640
+ source_path = family["folder"] + "/" + Path(filename).name
641
+ used_sources.add(source_path)
642
+ source = lock["sources"][source_path]
643
+ data = (ASSETS / filename).read_bytes()
644
+ require(sha(data) == source["sha256"] and len(data) == source["bytes"], "License/notice hash mismatch")
645
+ if filename.endswith("OFL.txt"):
646
+ require(b"SIL OPEN FONT LICENSE Version 1.1" in data and b"PREAMBLE" in data,
647
+ "Not actual upstream OFL 1.1")
648
+ require(used_sources == set(lock["sources"]), "Unused/missing source entries")
649
+ for path, record in lock["sources"].items():
650
+ require(record["url"] == encoded_url(path), f"Unpinned source URL: {path}")
651
+ actual_paths = {path.relative_to(ASSETS).as_posix() for path in ASSETS.rglob("*") if path.is_file()}
652
+ require(actual_paths == expected_paths, f"Unexpected/missing generated assets: {actual_paths ^ expected_paths}")
653
+ require(CSS.read_bytes() == stylesheet(catalog), "Generated CSS mismatch")
654
+ total = sum(face["bytes"] for entry in catalog for face in entry["faces"])
655
+ regular_total = sum(entry["regular400Bytes"] for entry in catalog)
656
+ print(f"Verified {len(catalog)} families, {sum(len(entry['faces']) for entry in catalog)} faces; "
657
+ f"regular-400 faces {regular_total:,} B; source-complete additions {total:,} B; "
658
+ f"existing + additions {EXISTING_BYTES + total:,} B")
659
+
660
+
661
+ def main():
662
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
663
+ parser.add_argument("--bootstrap", action="store_true")
664
+ parser.add_argument("--refresh-acquisition", action="store_true")
665
+ parser.add_argument("--backup-dir", type=Path)
666
+ parser.add_argument("--selection-snapshot", type=Path)
667
+ parser.add_argument("--verify", action="store_true")
668
+ parser.add_argument("--offline", action="store_true", help="Rebuild using only hash-pinned cache sources")
669
+ parser.add_argument("--cache-dir", type=Path)
670
+ args = parser.parse_args()
671
+ require(sum((args.bootstrap, args.refresh_acquisition, args.verify)) <= 1,
672
+ "--bootstrap, --refresh-acquisition and --verify are mutually exclusive")
673
+ require(not args.offline or (args.cache_dir and not args.bootstrap and not args.refresh_acquisition),
674
+ "--offline rebuild requires --cache-dir and an existing lock")
675
+ require(version("fonttools") == "4.63.0" and version("brotli") == "1.2.0", "Use pinned fonttools==4.63.0 and brotli==1.2.0")
676
+ require(sys.version_info[:2] == (3, 14), "Use the pinned Python 3.14 build environment")
677
+ logging.basicConfig(level=logging.ERROR)
678
+ if args.bootstrap or args.refresh_acquisition:
679
+ selection_snapshot(args.selection_snapshot) # Validate before any backup or acquisition.
680
+ if args.refresh_acquisition:
681
+ require((ASSETS / "sources.lock.json").exists(), "Refresh requires an existing lock")
682
+ require(args.backup_dir is not None, "Refresh requires --backup-dir outside the repository")
683
+ backup = args.backup_dir.resolve()
684
+ require(not backup.is_relative_to(ROOT.parents[1]) and not backup.exists(),
685
+ "Backup must be a new directory outside the repository")
686
+ backup.mkdir(parents=True)
687
+ shutil.copytree(ASSETS, backup / "expanded")
688
+ shutil.copy2(CSS, backup / CSS.name)
689
+ shutil.copy2(Path(__file__), backup / Path(__file__).name)
690
+ print(f"Acquisition refresh backed up to {backup}", flush=True)
691
+ if args.verify:
692
+ verify()
693
+ elif args.cache_dir:
694
+ args.cache_dir.mkdir(parents=True, exist_ok=True)
695
+ build(args.bootstrap, args.cache_dir, args.refresh_acquisition, args.selection_snapshot, args.offline)
696
+ else:
697
+ with tempfile.TemporaryDirectory(prefix="dreb-fonts-") as directory:
698
+ build(args.bootstrap, Path(directory), args.refresh_acquisition, args.selection_snapshot)
699
+
700
+
701
+ if __name__ == "__main__":
702
+ main()