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,880 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ import contextlib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from typing import Any, ClassVar
11
+ import urllib.request
12
+ import warnings
13
+ import zipfile
14
+
15
+ from texsmith.core.fragments.base import BaseFragment, FragmentPiece
16
+ from texsmith.core.templates.manifest import TemplateAttributeSpec, TemplateError
17
+ from texsmith.core.user_dir import get_user_dir
18
+ from texsmith.fonts.cache import FontCache
19
+ from texsmith.fonts.constants import style_suffix
20
+ from texsmith.fonts.downloader import NotoFontDownloader
21
+ from texsmith.fonts.logging import FontPipelineLogger
22
+
23
+
24
+ _FAMILY_CHOICES: dict[str, str] = {
25
+ "lm": "lm",
26
+ "lm-sans": "lm-sans",
27
+ "bonum": "bonum",
28
+ "libertinus": "libertinus",
29
+ "pagella": "pagella",
30
+ "termes": "termes",
31
+ "schola": "schola",
32
+ "heros": "heros",
33
+ "heros-otf": "heros",
34
+ "adventor": "adventor",
35
+ "adventor-otf": "adventor",
36
+ "cursor": "cursor",
37
+ "cursor-otf": "cursor",
38
+ "plex": "plex",
39
+ "pennstander": "pennstander",
40
+ }
41
+
42
+ _CTAN_DEPENDENCIES: dict[str, dict[str, str]] = {
43
+ "bonum": {
44
+ "package": "bonum-otf",
45
+ "url": "https://mirrors.ctan.org/fonts/bonum-otf.zip",
46
+ },
47
+ "pagella": {
48
+ "package": "pagella-otf",
49
+ "url": "https://mirrors.ctan.org/fonts/pagella-otf.zip",
50
+ },
51
+ "termes": {
52
+ "package": "termes-otf",
53
+ "url": "https://mirrors.ctan.org/fonts/termes-otf.zip",
54
+ },
55
+ "schola": {
56
+ "package": "schola-otf",
57
+ "url": "https://mirrors.ctan.org/fonts/schola-otf.zip",
58
+ },
59
+ "heros": {
60
+ "package": "heros-otf",
61
+ "url": "https://mirrors.ctan.org/fonts/heros-otf.zip",
62
+ },
63
+ "adventor": {
64
+ "package": "adventor-otf",
65
+ "url": "https://mirrors.ctan.org/fonts/tex-gyre.zip",
66
+ },
67
+ "cursor": {
68
+ "package": "cursor-otf",
69
+ "url": "https://mirrors.ctan.org/fonts/tex-gyre.zip",
70
+ },
71
+ "plex": {
72
+ "package": "plex-otf",
73
+ "url": "https://mirrors.ctan.org/fonts/plex-otf.zip",
74
+ },
75
+ "pennstander": {
76
+ "package": "pennstander-otf",
77
+ "url": "https://mirrors.ctan.org/fonts/pennstander-otf.zip",
78
+ },
79
+ }
80
+
81
+ _PLEX_URL = "https://github.com/IBM/plex/releases/download/v6.0.0/TrueType.zip"
82
+ _OPENMOJI_URL = "https://github.com/hfg-gmuend/openmoji/releases/latest/download/openmoji-font.zip"
83
+ _OPENMOJI_FILE_CANDIDATES = {
84
+ "OpenMoji-black-glyf.ttf",
85
+ "OpenMoji-black-glyf/OpenMoji-black-glyf.ttf",
86
+ "fonts/OpenMoji-black-glyf.ttf",
87
+ }
88
+ _NOTO_COLOR_EMOJI_URL = (
89
+ "https://github.com/googlefonts/noto-emoji/raw/refs/heads/main/fonts/NotoColorEmoji.ttf"
90
+ )
91
+ _SKIP_GROUPS = {"latin", "common", "punctuation", "other"}
92
+ _FALLBACK_ALIASES: dict[str, dict[str, object]] = {
93
+ # Prefer widely available Noto families when coverage picks display variants.
94
+ "cyrillics": {"name": "NotoSans", "styles": ["regular", "bold"], "extension": ".otf"},
95
+ "diacritics": {"name": "NotoSans", "styles": ["regular", "bold"], "extension": ".otf"},
96
+ "devanagari": {
97
+ "name": "NotoSansDevanagari",
98
+ "styles": ["regular", "bold"],
99
+ "extension": ".otf",
100
+ "dir": "NotoSansDevanagari",
101
+ },
102
+ "chinese": {"name": "NotoSansSC", "styles": ["regular", "bold"], "extension": ".otf"},
103
+ "symbols": {
104
+ "name": "OpenMojiBlack",
105
+ "styles": ["regular"],
106
+ "extension": ".ttf",
107
+ },
108
+ }
109
+
110
+
111
+ def _resolve_emoji_mode(context: Mapping[str, Any]) -> str:
112
+ """Infer requested emoji mode from template or press settings."""
113
+ candidates: list[Any] = []
114
+ fonts_cfg = context.get("fonts")
115
+ if isinstance(context.get("emoji"), str):
116
+ candidates.append(context.get("emoji"))
117
+ if isinstance(context.get("emoji_mode"), str):
118
+ candidates.append(context.get("emoji_mode"))
119
+ if isinstance(fonts_cfg, Mapping) and isinstance(fonts_cfg.get("emoji"), str):
120
+ candidates.append(fonts_cfg.get("emoji"))
121
+ for value in candidates:
122
+ lowered = str(value).strip().lower()
123
+ if lowered in {"artifact", "symbola", "color", "black", "twemoji"}:
124
+ return lowered
125
+ return "black"
126
+
127
+
128
+ def _normalise_family(raw_value: Any) -> str:
129
+ if isinstance(raw_value, str):
130
+ candidate = raw_value.strip().lower()
131
+ if candidate:
132
+ mapped = _FAMILY_CHOICES.get(candidate)
133
+ if mapped:
134
+ return mapped
135
+ warnings.warn(f"Unknown font family '{raw_value}', falling back to 'lm'.", stacklevel=3)
136
+ return "lm"
137
+
138
+
139
+ def _sty_available(sty_name: str) -> bool:
140
+ kpse = shutil.which("kpsewhich")
141
+ if not kpse:
142
+ return False
143
+ try:
144
+ result = subprocess.run(
145
+ [kpse, sty_name],
146
+ check=False,
147
+ capture_output=True,
148
+ text=True,
149
+ )
150
+ except OSError:
151
+ return False
152
+ return result.returncode == 0 and bool(result.stdout.strip())
153
+
154
+
155
+ def _download_archive(url: str, destination: Path) -> None:
156
+ destination.parent.mkdir(parents=True, exist_ok=True)
157
+ if destination.exists():
158
+ return
159
+ try:
160
+ with urllib.request.urlopen(url) as response, destination.open("wb") as handle:
161
+ shutil.copyfileobj(response, handle)
162
+ except OSError as exc:
163
+ raise TemplateError(f"Failed to download '{url}': {exc}") from exc
164
+
165
+
166
+ def _extract_sty_from_archive(archive: Path, sty_name: str, target: Path) -> Path:
167
+ with zipfile.ZipFile(archive) as zf:
168
+ for entry in zf.infolist():
169
+ if entry.is_dir():
170
+ continue
171
+ if Path(entry.filename).name != sty_name:
172
+ continue
173
+ target.parent.mkdir(parents=True, exist_ok=True)
174
+ with zf.open(entry) as src, target.open("wb") as dst:
175
+ shutil.copyfileobj(src, dst)
176
+ return target
177
+ raise TemplateError(f"Could not locate '{sty_name}' inside '{archive.name}'.")
178
+
179
+
180
+ def _write_stub_package(package_name: str, family: str, target: Path) -> Path:
181
+ fonts: dict[str, str] = {
182
+ "bonum": "TeX Gyre Bonum",
183
+ "pagella": "TeX Gyre Pagella",
184
+ "termes": "TeX Gyre Termes",
185
+ "schola": "TeX Gyre Schola",
186
+ "heros": "TeX Gyre Heros",
187
+ "adventor": "TeX Gyre Adventor",
188
+ "cursor": "TeX Gyre Cursor",
189
+ "plex": "IBM Plex Serif",
190
+ "pennstander": "Latin Modern Roman",
191
+ }
192
+ font_name = fonts.get(family, family)
193
+ lines = [
194
+ f"\\ProvidesPackage{{{package_name}}}[Auto-generated stub]",
195
+ "\\RequirePackage{fontspec}",
196
+ f"\\setmainfont{{{font_name}}}",
197
+ ]
198
+ if family in {"heros", "adventor"}:
199
+ lines.append(f"\\setsansfont{{{font_name}}}")
200
+ if family == "cursor":
201
+ lines.append(f"\\setmonofont{{{font_name}}}")
202
+ if family in {"bonum", "pagella", "termes"}:
203
+ lines.append(f"\\setmathfont{{{font_name} Math}}")
204
+ if family == "pennstander":
205
+ lines.append("\\setsansfont{Latin Modern Sans}")
206
+ lines.append("\\setmonofont{Latin Modern Mono}")
207
+ target.parent.mkdir(parents=True, exist_ok=True)
208
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
209
+ return target
210
+
211
+
212
+ def _ensure_noto_color_emoji(cache: FontCache) -> Path | None:
213
+ """Download the Noto Color Emoji font (TTF) into the cache."""
214
+ dest = cache.path("NotoColorEmoji.ttf")
215
+ if dest.exists():
216
+ return dest
217
+ try:
218
+ _download_archive(_NOTO_COLOR_EMOJI_URL, dest)
219
+ except Exception as exc: # pragma: no cover - network edge
220
+ warnings.warn(f"Unable to download Noto Color Emoji font: {exc}", stacklevel=2)
221
+ return None
222
+ return dest if dest.exists() else None
223
+
224
+
225
+ def _ensure_openmoji_black(cache: FontCache) -> Path | None:
226
+ """Download and extract the OpenMoji black glyph font into the cache."""
227
+ dest = cache.path("OpenMoji-black-glyf.ttf")
228
+ if dest.exists():
229
+ return dest
230
+ with cache.tempdir() as tmp_dir:
231
+ archive = tmp_dir / "openmoji.zip"
232
+ try:
233
+ _download_archive(_OPENMOJI_URL, archive)
234
+ with zipfile.ZipFile(archive) as zf:
235
+ candidate = next(
236
+ (
237
+ name
238
+ for name in zf.namelist()
239
+ if name.lower().endswith("openmoji-black-glyf.ttf")
240
+ or name in _OPENMOJI_FILE_CANDIDATES
241
+ ),
242
+ None,
243
+ )
244
+ if candidate is None:
245
+ raise FileNotFoundError("OpenMoji black glyph font not found in archive.") # noqa: TRY301
246
+ extracted = zf.extract(candidate, tmp_dir)
247
+ extracted_path = Path(extracted)
248
+ dest.parent.mkdir(parents=True, exist_ok=True)
249
+ shutil.copy2(extracted_path, dest)
250
+ return dest
251
+ except Exception as exc: # pragma: no cover - network edge
252
+ warnings.warn(f"Unable to download OpenMoji font: {exc}", stacklevel=2)
253
+ return None
254
+ return dest if dest.exists() else None
255
+
256
+
257
+ def _write_otf_package(
258
+ *,
259
+ package_name: str,
260
+ family: str,
261
+ target: Path,
262
+ fonts_path: Path,
263
+ font_prefix: str,
264
+ ) -> Path:
265
+ path_option = fonts_path.relative_to(target.parent).as_posix()
266
+ fonts = {
267
+ "regular": f"{font_prefix}-regular.otf",
268
+ "bold": f"{font_prefix}-bold.otf",
269
+ "italic": f"{font_prefix}-italic.otf",
270
+ "bold_italic": f"{font_prefix}-bolditalic.otf",
271
+ }
272
+ lines = [
273
+ f"\\ProvidesPackage{{{package_name}}}[Auto-generated OTF wrapper for {family}]",
274
+ "\\RequirePackage{fontspec}",
275
+ f"\\setmainfont{{{fonts['regular']}}}[%",
276
+ f" Path={path_option}/,%",
277
+ " Ligatures=TeX,%",
278
+ f" BoldFont={fonts['bold']},%",
279
+ f" ItalicFont={fonts['italic']},%",
280
+ f" BoldItalicFont={fonts['bold_italic']},%",
281
+ "]%",
282
+ f"\\setsansfont{{{fonts['regular']}}}[%",
283
+ f" Path={path_option}/,%",
284
+ " Ligatures=TeX,%",
285
+ f" BoldFont={fonts['bold']},%",
286
+ f" ItalicFont={fonts['italic']},%",
287
+ f" BoldItalicFont={fonts['bold_italic']},%",
288
+ "]%",
289
+ f"\\setmonofont{{{fonts['regular']}}}[%",
290
+ f" Path={path_option}/,%",
291
+ f" BoldFont={fonts['bold']},%",
292
+ f" ItalicFont={fonts['italic']},%",
293
+ f" BoldItalicFont={fonts['bold_italic']},%",
294
+ "]%",
295
+ ]
296
+ target.parent.mkdir(parents=True, exist_ok=True)
297
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
298
+ return target
299
+
300
+
301
+ def _ensure_plex_fonts(output_dir: Path) -> None:
302
+ fonts_root = output_dir / "fonts" / "plex-otf"
303
+ temp_dir = Path(tempfile.mkdtemp(prefix="texsmith-plex-"))
304
+ archive_path = temp_dir / "TrueType.zip"
305
+
306
+ try:
307
+ _download_archive(_PLEX_URL, archive_path)
308
+ with zipfile.ZipFile(archive_path) as zf:
309
+ zf.extractall(temp_dir)
310
+ except Exception as exc: # pragma: no cover - network edge
311
+ warnings.warn(f"Unable to fetch IBM Plex fonts: {exc}", stacklevel=2)
312
+ shutil.rmtree(temp_dir, ignore_errors=True)
313
+ return
314
+
315
+ wanted = {
316
+ "IBMPlexSerif": [
317
+ "IBMPlexSerif-Regular.ttf",
318
+ "IBMPlexSerif-Bold.ttf",
319
+ "IBMPlexSerif-Italic.ttf",
320
+ "IBMPlexSerif-BoldItalic.ttf",
321
+ ],
322
+ "IBMPlexSans": [
323
+ "IBMPlexSans-Regular.ttf",
324
+ "IBMPlexSans-Bold.ttf",
325
+ "IBMPlexSans-Italic.ttf",
326
+ "IBMPlexSans-BoldItalic.ttf",
327
+ ],
328
+ "IBMPlexMono": [
329
+ "IBMPlexMono-Regular.ttf",
330
+ "IBMPlexMono-Bold.ttf",
331
+ "IBMPlexMono-Italic.ttf",
332
+ "IBMPlexMono-BoldItalic.ttf",
333
+ ],
334
+ }
335
+
336
+ folder_map = {
337
+ "IBMPlexSerif": "IBM-Plex-Serif",
338
+ "IBMPlexSans": "IBM-Plex-Sans",
339
+ "IBMPlexMono": "IBM-Plex-Mono",
340
+ }
341
+
342
+ for family_dir, files in wanted.items():
343
+ folder = folder_map.get(family_dir, family_dir)
344
+ for name in files:
345
+ source = temp_dir / "TrueType" / folder / name
346
+ if not source.exists():
347
+ continue
348
+ dest = fonts_root / name
349
+ dest.parent.mkdir(parents=True, exist_ok=True)
350
+ shutil.copy2(source, dest)
351
+
352
+ shutil.rmtree(temp_dir, ignore_errors=True)
353
+
354
+
355
+ def _ensure_ctan_sty(family: str, output_dir: Path) -> Path | None:
356
+ dependency = _CTAN_DEPENDENCIES.get(family)
357
+ if not dependency:
358
+ return None
359
+
360
+ # Pennstander archive is often unavailable on CTAN mirrors; fall back to a stub.
361
+ if family == "pennstander":
362
+ target = output_dir / "pennstander-otf.sty"
363
+ return _write_stub_package("pennstander-otf", family, target)
364
+
365
+ package_name = dependency["package"]
366
+ sty_name = f"{package_name}.sty"
367
+
368
+ output_dir.mkdir(parents=True, exist_ok=True)
369
+ fonts_dir = output_dir / "fonts" / package_name
370
+ target_path = output_dir / sty_name
371
+
372
+ download_error: Exception | None = None
373
+ temp_dir = Path(tempfile.mkdtemp(prefix=f"texsmith-{family}-"))
374
+ archive_path = temp_dir / f"{package_name}.zip"
375
+
376
+ try:
377
+ _download_archive(dependency["url"], archive_path)
378
+ with zipfile.ZipFile(archive_path) as zf:
379
+ zf.extractall(temp_dir)
380
+ except Exception as exc: # pragma: no cover - network/path edge cases
381
+ download_error = exc
382
+
383
+ sty_candidates = list(temp_dir.rglob(sty_name))
384
+ if not sty_candidates:
385
+ sty_candidates = list(temp_dir.rglob("*.sty"))
386
+
387
+ if sty_candidates:
388
+ source = sty_candidates[0]
389
+ shutil.copy2(source, target_path)
390
+
391
+ for file in temp_dir.rglob("*"):
392
+ if file.is_dir():
393
+ continue
394
+ if file.suffix.lower() in {".otf", ".ttf"}:
395
+ destination = fonts_dir / file.name
396
+ destination.parent.mkdir(parents=True, exist_ok=True)
397
+ shutil.copy2(file, destination)
398
+ if family == "plex":
399
+ _ensure_plex_fonts(output_dir)
400
+ shutil.rmtree(temp_dir, ignore_errors=True)
401
+ return target_path
402
+
403
+ if family in {"adventor", "cursor"}:
404
+ fonts = list(temp_dir.rglob(f"**/texgyre{family}*-regular.otf"))
405
+ if not fonts:
406
+ fonts = list(temp_dir.rglob(f"**/{family}*-regular.otf"))
407
+ if fonts:
408
+ font_dir = fonts_dir
409
+ font_dir.mkdir(parents=True, exist_ok=True)
410
+ for file in temp_dir.rglob("*.otf"):
411
+ shutil.copy2(file, font_dir / Path(file).name)
412
+
413
+ prefix = fonts[0].stem.replace("-regular", "")
414
+ _write_otf_package(
415
+ package_name=package_name,
416
+ family=family,
417
+ target=target_path,
418
+ fonts_path=font_dir,
419
+ font_prefix=prefix,
420
+ )
421
+ shutil.rmtree(temp_dir, ignore_errors=True)
422
+ return target_path
423
+
424
+ shutil.rmtree(temp_dir, ignore_errors=True)
425
+
426
+ stub_path = _write_stub_package(package_name, family, target_path)
427
+ if download_error is not None: # pragma: no cover - logging path
428
+ warnings.warn(
429
+ f"Unable to prepare CTAN package for font '{family}': {download_error}",
430
+ stacklevel=2,
431
+ )
432
+ if family == "plex":
433
+ _ensure_plex_fonts(output_dir)
434
+ return stub_path
435
+
436
+
437
+ def _resolve_output_dir(context: Mapping[str, Any]) -> Path:
438
+ for key in ("output_dir", "build_dir", "output_root"):
439
+ raw_path = context.get(key)
440
+ if raw_path:
441
+ try:
442
+ candidate = Path(str(raw_path)).expanduser().resolve()
443
+ except OSError:
444
+ continue
445
+ return candidate
446
+ return Path("build").resolve()
447
+
448
+
449
+ def _slugify(value: str) -> str:
450
+ slug = "".join(ch for ch in value if ch.isalnum())
451
+ if not slug:
452
+ return "script"
453
+ if slug[0].isdigit():
454
+ slug = f"s{slug}"
455
+ return slug.lower()
456
+
457
+
458
+ def _candidate_font_roots(output_dir: Path) -> list[Path]:
459
+ roots: list[Path] = []
460
+ roots.append((output_dir / "fonts").resolve())
461
+ sandbox_fonts = Path(__file__).resolve().parents[4] / "sandbox" / "fonts"
462
+ roots.append(sandbox_fonts)
463
+ user_fonts = get_user_dir().data_dir("fonts", create=False)
464
+ roots.append(user_fonts)
465
+ with contextlib.suppress(Exception):
466
+ cache = FontCache()
467
+ roots.append(cache.root)
468
+ roots.append(cache.path("fonts"))
469
+ return roots
470
+
471
+
472
+ def _find_font_file(name: str, style: str, ext: str, roots: list[Path]) -> Path | None:
473
+ filename = f"{name}-{style_suffix(style)}{ext}"
474
+ for root in roots:
475
+ candidate = root / filename
476
+ if candidate.exists():
477
+ return candidate
478
+ return None
479
+
480
+
481
+ def _prepare_fallback_context(context: Mapping[str, Any], *, output_dir: Path) -> dict[str, Any]:
482
+ """Build fallback metadata for the ts-fonts fragment."""
483
+ fonts_section = context.get("fonts") if isinstance(context.get("fonts"), Mapping) else {}
484
+ fallback_summary = (
485
+ fonts_section.get("fallback_summary") if isinstance(fonts_section, Mapping) else []
486
+ )
487
+ script_usage = fonts_section.get("script_usage") if isinstance(fonts_section, Mapping) else []
488
+ emoji_mode = _resolve_emoji_mode(context)
489
+
490
+ def _entry_score(
491
+ has_bold: bool, style_count: int, count: int | None, *, usage_match: bool = False
492
+ ) -> tuple[int, int, int, int]:
493
+ """Higher score wins: usage-aligned fonts first, then bold support, style breadth, usage count."""
494
+ return (1 if usage_match else 0, 1 if has_bold else 0, style_count, count or 0)
495
+
496
+ usage_index: dict[str, Mapping[str, Any]] = {}
497
+ for entry in script_usage or []:
498
+ if not isinstance(entry, Mapping):
499
+ continue
500
+ slug = str(entry.get("slug") or "").lower()
501
+ group = str(entry.get("group") or "").lower()
502
+ if slug:
503
+ usage_index[slug] = entry
504
+ if group:
505
+ usage_index[group] = entry
506
+
507
+ roots = _candidate_font_roots(output_dir)
508
+ downloader = NotoFontDownloader(cache=FontCache(), logger=FontPipelineLogger())
509
+ emoji_cache = downloader.cache
510
+ roots.append(downloader.fonts_dir)
511
+
512
+ entries_by_slug: dict[str, dict[str, Any]] = {}
513
+ package_options: set[str] = set()
514
+ slug_classes: dict[str, set[str]] = {}
515
+ lua_regular: set[str] = set()
516
+ lua_bold: set[str] = set()
517
+ missing_commands: set[str] = set()
518
+ package_options.add("Latin")
519
+
520
+ for entry in fallback_summary or []:
521
+ if not isinstance(entry, Mapping):
522
+ continue
523
+ group = entry.get("group") or entry.get("class")
524
+ if not isinstance(group, str) or not group.strip():
525
+ continue
526
+ group_lower = group.lower()
527
+ if group_lower in _SKIP_GROUPS:
528
+ continue
529
+ class_name = entry.get("class") or group
530
+ slug_base = _slugify(group)
531
+ slug_class = _slugify(class_name) if isinstance(class_name, str) else slug_base
532
+ slug = slug_class if slug_class != slug_base else slug_base
533
+ # Prefer the slug recorded in script usage to keep commands aligned with detectors.
534
+ if slug_class != slug_base and (slug_base in usage_index or group_lower in usage_index):
535
+ slug = slug_base
536
+ usage = usage_index.get(slug) or usage_index.get(slug_base) or usage_index.get(group_lower)
537
+ font_command = ""
538
+ text_command = ""
539
+ font_name = None
540
+ usage_font = None
541
+ if isinstance(usage, Mapping):
542
+ font_command = str(usage.get("font_command") or "")
543
+ text_command = str(usage.get("text_command") or "")
544
+ font_name = usage.get("font_name") if isinstance(usage.get("font_name"), str) else None
545
+ usage_font = font_name
546
+ if not font_command:
547
+ font_command = f"{slug}font"
548
+ if not text_command:
549
+ text_command = f"text{slug}"
550
+ font_meta = entry.get("font") if isinstance(entry.get("font"), Mapping) else {}
551
+ alias = _FALLBACK_ALIASES.get(group_lower)
552
+ if isinstance(font_meta, Mapping):
553
+ font_name = font_meta.get("name") or font_name
554
+ if not font_name:
555
+ continue
556
+ styles = []
557
+ if isinstance(font_meta, Mapping):
558
+ styles = [str(style).lower() for style in font_meta.get("styles", []) if style]
559
+ count = entry.get("count")
560
+ ext = font_meta.get("extension") if isinstance(font_meta, Mapping) else ".otf"
561
+ ext = ext if isinstance(ext, str) and ext.startswith(".") else ".otf"
562
+ # Resolve emoji font preferences separately because they are not part of the Noto OTF set.
563
+ emoji_path: Path | None = None
564
+ if group_lower == "symbols" or "emoji" in font_name.lower():
565
+ preferred_color = emoji_mode == "color"
566
+ # Prefer OpenMoji (monochrome) for broader engine compatibility,
567
+ # even when color is requested. Fall back to NotoColorEmoji only
568
+ # when OpenMoji is unavailable.
569
+ emoji_path = _ensure_openmoji_black(emoji_cache)
570
+ font_name = "OpenMojiBlack" if emoji_path is not None else font_name
571
+ if emoji_path is None and preferred_color:
572
+ emoji_path = _ensure_noto_color_emoji(emoji_cache)
573
+ if emoji_path is not None:
574
+ font_name = "NotoColorEmoji"
575
+ if emoji_path is None or not emoji_path.exists():
576
+ warnings.warn(
577
+ "Emoji font unavailable; using raw characters for emoji glyphs.",
578
+ stacklevel=2,
579
+ )
580
+ missing_commands.add("texsmithEmoji")
581
+ continue
582
+ styles = ["regular"]
583
+ ext = ".ttf"
584
+ slug = "emoji"
585
+ font_command = "texsmithEmojiFont"
586
+ text_command = "texsmithEmoji"
587
+ usage_index.setdefault(
588
+ "emoji",
589
+ {
590
+ "slug": "emoji",
591
+ "font_command": font_command,
592
+ "text_command": text_command,
593
+ "font_name": font_name,
594
+ },
595
+ )
596
+ usage = usage_index.get("emoji") or usage
597
+ else:
598
+ # Ensure required font files exist locally, downloading into the cache when missing.
599
+ downloader.ensure(
600
+ font_name=font_name,
601
+ styles=styles or ["regular", "bold"],
602
+ extension=ext,
603
+ dir_base=font_meta.get("dir") if isinstance(font_meta, Mapping) else None,
604
+ )
605
+
606
+ destination_root = (output_dir / "fonts").resolve()
607
+ destination_root.mkdir(parents=True, exist_ok=True)
608
+ sources: dict[str, Path] = {}
609
+ for style in styles or ["regular", "bold"]:
610
+ if emoji_path is not None:
611
+ src = emoji_path
612
+ else:
613
+ src = downloader.fonts_dir / f"{font_name}-{style_suffix(style)}{ext}"
614
+ if src.exists():
615
+ sources[style] = src
616
+ dest = destination_root / src.name
617
+ if not dest.exists():
618
+ shutil.copy2(src, dest)
619
+
620
+ upright_file = sources.get("regular") or _find_font_file(font_name, "regular", ext, roots)
621
+ bold_file = (
622
+ sources.get("bold")
623
+ if "bold" in styles
624
+ else _find_font_file(font_name, "bold", ext, roots)
625
+ if "bold" in styles
626
+ else None
627
+ )
628
+ if upright_file is None:
629
+ candidate = downloader.fonts_dir / f"{font_name}-{style_suffix('regular')}{ext}"
630
+ if candidate.exists():
631
+ upright_file = candidate
632
+ if bold_file is None and "bold" in styles:
633
+ candidate = downloader.fonts_dir / f"{font_name}-{style_suffix('bold')}{ext}"
634
+ if candidate.exists():
635
+ bold_file = candidate
636
+ style_count = len(styles) if styles else 1
637
+ if upright_file is None:
638
+ alias = _FALLBACK_ALIASES.get(group_lower)
639
+ if alias:
640
+ alt_name = alias.get("name")
641
+ alt_ext = alias.get("extension", ext)
642
+ alt_styles = alias.get("styles", styles)
643
+ if isinstance(alt_name, str):
644
+ alt_dir = alias.get("dir")
645
+ alt_ext = (
646
+ alt_ext if isinstance(alt_ext, str) and alt_ext.startswith(".") else ".otf"
647
+ )
648
+ alt_styles = (
649
+ [str(s).lower() for s in alt_styles]
650
+ if isinstance(alt_styles, (list, tuple, set))
651
+ else styles
652
+ )
653
+ downloader.ensure(
654
+ font_name=alt_name,
655
+ styles=alt_styles or styles or ["regular", "bold"],
656
+ extension=alt_ext,
657
+ dir_base=str(alt_dir) if isinstance(alt_dir, str) else None,
658
+ )
659
+ font_name = alt_name
660
+ ext = alt_ext
661
+ styles = alt_styles
662
+ upright_file = _find_font_file(font_name, "regular", ext, roots)
663
+ bold_file = (
664
+ _find_font_file(font_name, "bold", ext, roots) if "bold" in styles else None
665
+ )
666
+ if upright_file is None:
667
+ candidate = (
668
+ downloader.fonts_dir / f"{font_name}-{style_suffix('regular')}{ext}"
669
+ )
670
+ if candidate.exists():
671
+ upright_file = candidate
672
+ if bold_file is None and "bold" in styles:
673
+ candidate = (
674
+ downloader.fonts_dir / f"{font_name}-{style_suffix('bold')}{ext}"
675
+ )
676
+ if candidate.exists():
677
+ bold_file = candidate
678
+ style_count = len(styles) if styles else 1
679
+ if upright_file is None:
680
+ warnings.warn(
681
+ f"Fallback font '{font_name}' not found on disk; skipping script '{group}'.",
682
+ stacklevel=2,
683
+ )
684
+ missing_commands.add(text_command)
685
+ continue
686
+
687
+ dest_upright = destination_root / upright_file.name
688
+ if not dest_upright.exists():
689
+ shutil.copy2(upright_file, dest_upright)
690
+
691
+ dest_bold: Path | None = None
692
+ if bold_file is not None:
693
+ dest_bold = destination_root / bold_file.name
694
+ if not dest_bold.exists():
695
+ shutil.copy2(bold_file, dest_bold)
696
+
697
+ existing = entries_by_slug.get(slug)
698
+ candidate_payload = {
699
+ "group": group,
700
+ "class": class_name,
701
+ "slug": slug,
702
+ "font_command": font_command,
703
+ "text_command": text_command,
704
+ "font_name": font_name,
705
+ "count": count if isinstance(count, (int, float)) else None,
706
+ "has_bold": bool(dest_bold),
707
+ "upright": dest_upright.stem,
708
+ "bold": dest_bold.stem if dest_bold else None,
709
+ "extension": ext,
710
+ "style_count": style_count,
711
+ }
712
+ usage_match = bool(usage_font) and font_name and usage_font.lower() == font_name.lower()
713
+ candidate_score = _entry_score(
714
+ bool(dest_bold), style_count, candidate_payload["count"], usage_match=usage_match
715
+ )
716
+ if existing is None:
717
+ entries_by_slug[slug] = candidate_payload
718
+ else:
719
+ existing_count = existing.get("count", 0)
720
+ combined_count = int(existing_count or 0) + (
721
+ int(count) if isinstance(count, (int, float)) else 0
722
+ )
723
+ candidate_payload["count"] = combined_count if combined_count else None
724
+ existing_usage_match = (
725
+ bool(usage_font)
726
+ and existing.get("font_name")
727
+ and str(existing.get("font_name")).lower() == str(usage_font).lower()
728
+ )
729
+ existing_score = _entry_score(
730
+ bool(existing.get("has_bold")),
731
+ int(existing.get("style_count") or 1),
732
+ existing.get("count"),
733
+ usage_match=existing_usage_match,
734
+ )
735
+ if candidate_score > existing_score:
736
+ entries_by_slug[slug] = candidate_payload
737
+ else:
738
+ if combined_count:
739
+ existing["count"] = combined_count
740
+ if dest_bold is not None and not existing.get("has_bold"):
741
+ existing["has_bold"] = True
742
+ existing["bold"] = dest_bold.stem
743
+ existing["style_count"] = max(
744
+ int(existing.get("style_count") or 1), style_count
745
+ )
746
+ lua_bold.add(dest_bold.name)
747
+
748
+ package_options.add(str(class_name))
749
+ slug_classes.setdefault(slug, set()).add(str(class_name))
750
+ entry_ref = entries_by_slug[slug]
751
+ lua_regular.add(f"{entry_ref['upright']}{entry_ref['extension']}")
752
+ if entry_ref.get("has_bold") and entry_ref.get("bold"):
753
+ lua_bold.add(f"{entry_ref['bold']}{entry_ref['extension']}")
754
+ else:
755
+ lua_bold.add(f"{entry_ref['upright']}{entry_ref['extension']}")
756
+
757
+ if slug_classes:
758
+ package_options.update(
759
+ cls_name for classes in slug_classes.values() for cls_name in classes
760
+ )
761
+
762
+ transitions: list[str] = []
763
+ for slug, classes in sorted(slug_classes.items()):
764
+ entry = entries_by_slug.get(slug)
765
+ if not entry:
766
+ continue
767
+ font_command = entry.get("font_command")
768
+ if not font_command:
769
+ continue
770
+ for class_name in sorted(classes):
771
+ transitions.append(
772
+ f"\\setTransitionsFor{{{class_name}}}{{\\{font_command}}}{{\\texsmithFallbackFamily}}"
773
+ )
774
+
775
+ return {
776
+ "entries": sorted(
777
+ entries_by_slug.values(),
778
+ key=lambda e: e.get("group") or e.get("class") or "",
779
+ ),
780
+ "package_options": sorted(package_options),
781
+ "transitions": transitions,
782
+ "lua_regular": sorted(lua_regular),
783
+ "lua_bold": sorted(lua_bold),
784
+ "missing_commands": sorted(
785
+ missing_commands
786
+ | {
787
+ str(entry.get("text_command") or f"text{entry.get('slug')}")
788
+ for entry in usage_index.values()
789
+ if entry.get("slug") and entry.get("slug") not in entries_by_slug
790
+ }
791
+ ),
792
+ }
793
+
794
+
795
+ @dataclass(frozen=True)
796
+ class FontsConfig:
797
+ family: str
798
+ output_dir: Path
799
+ fallback: dict[str, Any] = field(default_factory=dict)
800
+
801
+ @classmethod
802
+ def from_context(cls, context: Mapping[str, Any]) -> FontsConfig:
803
+ fonts_section = context.get("fonts")
804
+ if isinstance(fonts_section, Mapping):
805
+ raw_family = fonts_section.get("family")
806
+ else:
807
+ raw_family = context.get("fonts_family")
808
+ family = _normalise_family(raw_family)
809
+ output_dir = _resolve_output_dir(context)
810
+ fallback = _prepare_fallback_context(context, output_dir=output_dir)
811
+ return cls(family=family, output_dir=output_dir, fallback=fallback)
812
+
813
+ def inject_into(self, context: dict[str, Any]) -> None:
814
+ context["fonts_family"] = self.family
815
+ fonts_section = context.get("fonts")
816
+ merged = dict(fonts_section) if isinstance(fonts_section, Mapping) else {}
817
+ merged["family"] = self.family
818
+ if self.fallback and self.fallback.get("entries"):
819
+ merged["fallback"] = self.fallback
820
+ context["fonts"] = merged
821
+
822
+ try:
823
+ _ensure_ctan_sty(self.family, self.output_dir)
824
+ except Exception as exc: # pragma: no cover - network/path edge cases
825
+ warnings.warn(
826
+ f"Unable to prepare CTAN package for font '{self.family}': {exc}",
827
+ stacklevel=2,
828
+ )
829
+
830
+
831
+ class FontsFragment(BaseFragment[FontsConfig]):
832
+ name: ClassVar[str] = "ts-fonts"
833
+ description: ClassVar[str] = "Font selection driven by fonts.family."
834
+ pieces: ClassVar[list[FragmentPiece]] = [
835
+ FragmentPiece(
836
+ template_path=Path(__file__).with_name("ts-fonts.jinja.sty"),
837
+ kind="package",
838
+ slot="extra_packages",
839
+ )
840
+ ]
841
+ attributes: ClassVar[dict[str, TemplateAttributeSpec]] = {
842
+ "fonts_family": TemplateAttributeSpec(
843
+ default="lm",
844
+ type="string",
845
+ allow_empty=False,
846
+ choices=sorted(set(_FAMILY_CHOICES.keys())),
847
+ sources=[
848
+ "fonts.family",
849
+ "fonts_family",
850
+ "font_family",
851
+ ],
852
+ )
853
+ }
854
+ config_cls: ClassVar[type[FontsConfig]] = FontsConfig
855
+ source: ClassVar[Path] = Path(__file__).with_name("ts-fonts.jinja.sty")
856
+ context_defaults: ClassVar[dict[str, Any]] = {"extra_packages": ""}
857
+
858
+ def build_config(
859
+ self, context: Mapping[str, Any], overrides: Mapping[str, Any] | None = None
860
+ ) -> FontsConfig:
861
+ _ = overrides
862
+ return self.config_cls.from_context(context)
863
+
864
+ def inject(
865
+ self,
866
+ config: FontsConfig,
867
+ context: dict[str, Any],
868
+ overrides: Mapping[str, Any] | None = None,
869
+ ) -> None:
870
+ _ = overrides
871
+ config.inject_into(context)
872
+
873
+ def should_render(self, config: FontsConfig) -> bool:
874
+ _ = config
875
+ return True
876
+
877
+
878
+ fragment = FontsFragment()
879
+
880
+ __all__ = ["FontsConfig", "FontsFragment", "fragment"]