texsmith 0.0.2.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,275 @@
1
+ """Build a coverage dataset for Noto fonts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import json
7
+ from pathlib import Path
8
+ import pickle
9
+ import re
10
+ import urllib.parse
11
+ import urllib.request
12
+
13
+ from texsmith.fonts.cache import FontCache
14
+ from texsmith.fonts.logging import FontPipelineLogger
15
+
16
+
17
+ GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts"
18
+ NOTOFONTS_STATE_URL = (
19
+ "https://raw.githubusercontent.com/notofonts/notofonts.github.io/main/state.json"
20
+ )
21
+ GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2?family={}&display=swap"
22
+ USER_AGENT = (
23
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
25
+ )
26
+
27
+ COVERAGE_CACHE_VERSION = 1
28
+
29
+
30
+ def _http_get(url: str) -> str:
31
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
32
+ with urllib.request.urlopen(req) as response:
33
+ return response.read().decode("utf-8")
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class NotoCoverage:
38
+ """A Noto font family and its Unicode coverage ranges."""
39
+
40
+ family: str
41
+ ranges: tuple[tuple[int, int], ...]
42
+ file_base: str
43
+ dir_base: str
44
+ styles: tuple[str, ...]
45
+
46
+ @classmethod
47
+ def from_mapping(cls, data: dict) -> NotoCoverage:
48
+ """Create a NotoCoverage from a mapping."""
49
+ return cls(
50
+ family=data["family"],
51
+ ranges=tuple((int(r[0]), int(r[1])) for r in data.get("ranges", [])),
52
+ file_base=data.get("file_base") or "".join(ch for ch in data["family"] if ch.isalnum()),
53
+ dir_base=data.get("dir_base")
54
+ or data.get("file_base")
55
+ or "".join(ch for ch in data["family"] if ch.isalnum()),
56
+ styles=tuple(data.get("otf_styles", []) or ()),
57
+ )
58
+
59
+ def to_mapping(self) -> dict:
60
+ """Convert the NotoCoverage to a mapping."""
61
+ return {
62
+ "family": self.family,
63
+ "ranges": [list(r) for r in self.ranges],
64
+ "file_base": self.file_base,
65
+ "dir_base": self.dir_base,
66
+ "otf_styles": list(self.styles),
67
+ }
68
+
69
+
70
+ class NotoCoverageBuilder:
71
+ """Rebuild the Noto coverage dataset or reuse a cached copy."""
72
+
73
+ def __init__(
74
+ self,
75
+ *,
76
+ cache: FontCache | None = None,
77
+ logger: FontPipelineLogger | None = None,
78
+ seed_paths: list[Path] | None = None,
79
+ ) -> None:
80
+ self.cache = cache or FontCache()
81
+ self.logger = logger or FontPipelineLogger()
82
+ self.seed_paths = seed_paths or []
83
+
84
+ @property
85
+ def cache_path(self) -> Path:
86
+ """Return the path to the coverage cache file."""
87
+ return self.cache.path("noto_coverage_db.pkl")
88
+
89
+ def _candidate_paths(self) -> list[Path]:
90
+ candidates = [
91
+ self.cache_path,
92
+ *self.seed_paths,
93
+ ]
94
+ unique: list[Path] = []
95
+ seen: set[Path] = set()
96
+ for candidate in candidates:
97
+ if candidate in seen:
98
+ continue
99
+ seen.add(candidate)
100
+ unique.append(candidate)
101
+ return unique
102
+
103
+ def _load_dataset(self, candidate: Path) -> list[NotoCoverage] | None:
104
+ if not candidate.exists():
105
+ return None
106
+ try:
107
+ raw = pickle.loads(candidate.read_bytes())
108
+ except Exception:
109
+ raw = None
110
+ if (
111
+ isinstance(raw, dict)
112
+ and raw.get("version") == COVERAGE_CACHE_VERSION
113
+ and isinstance(raw.get("data"), list)
114
+ ):
115
+ try:
116
+ return [NotoCoverage.from_mapping(entry) for entry in raw["data"]]
117
+ except Exception:
118
+ return None
119
+ try:
120
+ payload = json.loads(candidate.read_text(encoding="utf-8"))
121
+ except Exception:
122
+ return None
123
+ if isinstance(payload, list):
124
+ try:
125
+ return [NotoCoverage.from_mapping(entry) for entry in payload]
126
+ except Exception:
127
+ return None
128
+ return None
129
+
130
+ def load_cached(self) -> tuple[list[NotoCoverage], Path] | None:
131
+ for candidate in self._candidate_paths():
132
+ data = self._load_dataset(candidate)
133
+ if data is None:
134
+ continue
135
+ if candidate != self.cache_path:
136
+ self._save_cache(data, quiet=True)
137
+ return data, candidate
138
+ return None
139
+
140
+ def _fetch_family_list(self) -> list[str]:
141
+ self.logger.info("Fetching the complete Noto family list from Google Fonts...")
142
+ raw = _http_get(GOOGLE_FONTS_METADATA_URL)
143
+ if raw.startswith(")]}'"):
144
+ raw = raw.split("\n", 1)[1]
145
+ payload = json.loads(raw)
146
+ families = [
147
+ entry["family"]
148
+ for entry in payload.get("familyMetadataList", [])
149
+ if entry["family"].startswith("Noto ")
150
+ ]
151
+ families.sort()
152
+ self.logger.notice(f"{len(families)} families detected.")
153
+ return families
154
+
155
+ def _normalize_style(self, style: str) -> str | None:
156
+ cleaned = re.sub(r"[^a-z]", "", style.lower())
157
+ mapping = {
158
+ "regular": "regular",
159
+ "italic": "italic",
160
+ "bold": "bold",
161
+ "bolditalic": "bolditalic",
162
+ }
163
+ return mapping.get(cleaned)
164
+
165
+ def _fetch_otf_styles(self) -> dict[str, dict]:
166
+ try:
167
+ state = json.loads(_http_get(NOTOFONTS_STATE_URL))
168
+ except Exception as exc:
169
+ self.logger.warning("Unable to fetch OTF styles (%s); continuing without styles.", exc)
170
+ return {}
171
+
172
+ index: dict[str, dict] = {}
173
+ for entry in state.values():
174
+ if not isinstance(entry, dict):
175
+ continue
176
+ for family, meta in entry.get("families", {}).items():
177
+ styles: set[str] = set()
178
+ file_base = None
179
+ dir_base = None
180
+ for path in meta.get("files", []):
181
+ if "/otf/" not in path:
182
+ continue
183
+ filename = path.split("/")[-1]
184
+ if not filename.lower().endswith(".otf"):
185
+ continue
186
+ dir_base = path.split("/")[1] if len(path.split("/")) > 1 else None
187
+ base_part, sep, style_part = filename.rpartition("-")
188
+ if not sep:
189
+ continue
190
+ style_name = style_part.rsplit(".", 1)[0]
191
+ normalized = self._normalize_style(style_name)
192
+ if normalized:
193
+ styles.add(normalized)
194
+ if not file_base:
195
+ file_base = base_part
196
+ family_key = "".join(ch for ch in family if ch.isalnum())
197
+ if styles:
198
+ index[family] = {
199
+ "styles": sorted(styles),
200
+ "file_base": file_base or family_key,
201
+ "dir_base": dir_base or family_key,
202
+ }
203
+ index[family_key] = index[family]
204
+ return index
205
+
206
+ def _fetch_ranges(self, family: str) -> list[tuple[int, int]] | None:
207
+ safe_name = urllib.parse.quote(family)
208
+ url = GOOGLE_FONTS_CSS.format(safe_name)
209
+ try:
210
+ css = _http_get(url)
211
+ except Exception:
212
+ return None
213
+ matches = re.findall(r"unicode-range:\s*([^;]+);", css)
214
+ ranges: list[tuple[int, int]] = []
215
+ for match in matches:
216
+ parts = match.replace("unicode-range:", "").strip().split(",")
217
+ for part in parts:
218
+ part = part.strip().replace("U+", "")
219
+ if "-" in part:
220
+ start_str, end_str = part.split("-", 1)
221
+ ranges.append((int(start_str, 16), int(end_str, 16)))
222
+ elif part and "?" not in part:
223
+ val = int(part, 16)
224
+ ranges.append((val, val))
225
+ return ranges or None
226
+
227
+ def _save_cache(self, data: list[NotoCoverage], *, quiet: bool = False) -> None:
228
+ payload = {
229
+ "version": COVERAGE_CACHE_VERSION,
230
+ "data": [entry.to_mapping() for entry in data],
231
+ }
232
+ try:
233
+ self.cache_path.write_bytes(pickle.dumps(payload))
234
+ if not quiet:
235
+ self.logger.info("Noto coverage cache written to %s", self.cache_path)
236
+ except Exception:
237
+ self.logger.warning("Unable to write the Noto coverage cache.")
238
+
239
+ def build(self) -> list[NotoCoverage]:
240
+ cached = self.load_cached()
241
+ if cached:
242
+ data, source = cached
243
+ self.logger.notice("Noto coverage loaded from %s", source)
244
+ return data
245
+
246
+ families = self._fetch_family_list()
247
+ styles_index = self._fetch_otf_styles()
248
+ dataset: list[NotoCoverage] = []
249
+ with self.logger.progress("Scanning Noto families", total=len(families)) as advance:
250
+ for family in families:
251
+ ranges = self._fetch_ranges(family)
252
+ advance()
253
+ if not ranges:
254
+ continue
255
+ key = "".join(ch for ch in family if ch.isalnum())
256
+ styles_meta = styles_index.get(family) or styles_index.get(key) or {}
257
+ dataset.append(
258
+ NotoCoverage(
259
+ family=family,
260
+ ranges=tuple(ranges),
261
+ file_base=styles_meta.get("file_base", key),
262
+ dir_base=styles_meta.get("dir_base", key),
263
+ styles=tuple(styles_meta.get("styles", [])),
264
+ )
265
+ )
266
+ self._save_cache(dataset)
267
+ return dataset
268
+
269
+
270
+ __all__ = [
271
+ "GOOGLE_FONTS_METADATA_URL",
272
+ "NOTOFONTS_STATE_URL",
273
+ "NotoCoverage",
274
+ "NotoCoverageBuilder",
275
+ ]
@@ -0,0 +1,103 @@
1
+ """Helpers to fetch Noto font files on demand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import urllib.request
7
+
8
+ from texsmith.fonts.cache import FontCache
9
+ from texsmith.fonts.constants import (
10
+ CJK_ALIASES,
11
+ filename_base,
12
+ guess_cjk_path,
13
+ style_suffix,
14
+ )
15
+ from texsmith.fonts.logging import FontPipelineLogger
16
+
17
+
18
+ class NotoFontDownloader:
19
+ """Download individual Noto font files into the font cache."""
20
+
21
+ def __init__(
22
+ self,
23
+ *,
24
+ cache: FontCache | None = None,
25
+ logger: FontPipelineLogger | None = None,
26
+ ):
27
+ self.cache = cache or FontCache()
28
+ self.logger = logger or FontPipelineLogger()
29
+ self.fonts_dir = self.cache.path("fonts")
30
+
31
+ def _download(self, url: str, dest: Path) -> bool:
32
+ try:
33
+ with urllib.request.urlopen(url) as response:
34
+ data = response.read()
35
+ except Exception:
36
+ return False
37
+ if len(data) < 1024:
38
+ return False
39
+ dest.parent.mkdir(parents=True, exist_ok=True)
40
+ try:
41
+ dest.write_bytes(data)
42
+ except Exception:
43
+ return False
44
+ return True
45
+
46
+ def _download_font_file(self, filename: str, dir_base: str | None, dest: Path) -> bool:
47
+ base = filename_base(filename)
48
+ alias = CJK_ALIASES.get(base)
49
+ if alias:
50
+ real_base, rel_dir = alias
51
+ real_filename = filename.replace(base, real_base, 1)
52
+ cjk_url = f"https://raw.githubusercontent.com/notofonts/noto-cjk/main/{rel_dir}{real_filename}"
53
+ if self._download(cjk_url, dest):
54
+ return True
55
+
56
+ dir_part = dir_base or base
57
+ url_candidates = [
58
+ f"https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/{dir_part}/unhinted/otf/{filename}",
59
+ f"https://raw.githubusercontent.com/notofonts/notofonts.github.io/master/fonts/{dir_part}/unhinted/otf/{filename}",
60
+ f"https://raw.githubusercontent.com/notofonts/notofonts.github.io/master/fonts/{dir_part}/full/otf/{filename}",
61
+ ]
62
+
63
+ if "CJK" in filename or alias:
64
+ guess = guess_cjk_path(filename)
65
+ if guess:
66
+ url_candidates.append(
67
+ f"https://raw.githubusercontent.com/notofonts/noto-cjk/main/{guess}{filename}"
68
+ )
69
+
70
+ for url in url_candidates:
71
+ if self._download(url, dest):
72
+ return True
73
+ return dest.exists()
74
+
75
+ def ensure(
76
+ self,
77
+ *,
78
+ font_name: str,
79
+ styles: list[str] | tuple[str, ...] | set[str] | None,
80
+ extension: str,
81
+ dir_base: str | None,
82
+ ) -> None:
83
+ styles = list(styles or ["regular", "bold"])
84
+ for style in styles:
85
+ suffix = style_suffix(style)
86
+ filename = f"{font_name}-{suffix}{extension}"
87
+ dest = self.fonts_dir / filename
88
+ if dest.exists():
89
+ continue
90
+ if self._download_font_file(filename, dir_base, dest):
91
+ self.logger.info("Downloaded font %s", filename)
92
+ continue
93
+ # Last-resort fallback to plain NotoSans to avoid missing files.
94
+ if font_name != "NotoSans":
95
+ fallback_filename = f"NotoSans-{suffix}{extension}"
96
+ fallback_dest = self.fonts_dir / fallback_filename
97
+ if fallback_dest.exists():
98
+ continue
99
+ if self._download_font_file(fallback_filename, "noto-sans", fallback_dest):
100
+ self.logger.info("Downloaded fallback font %s", fallback_filename)
101
+
102
+
103
+ __all__ = ["NotoFontDownloader"]