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,366 @@
1
+ """Bundled Tectonic (and helper) acquisition and selection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import platform
8
+ import shutil
9
+ import stat
10
+ import subprocess
11
+ import sys
12
+ import tarfile
13
+ import tempfile
14
+ from urllib.error import URLError
15
+ from urllib.request import urlopen
16
+ import zipfile
17
+
18
+ from rich.console import Console
19
+
20
+ from texsmith.core.user_dir import get_user_dir
21
+
22
+
23
+ TECTONIC_VERSION = "0.15.0"
24
+ BIBER_VERSION = "2.17"
25
+ MAKEGLOSSARIES_URL = "https://mirrors.ctan.org/macros/latex/contrib/glossaries.zip"
26
+ _BASE_URL = (
27
+ "https://github.com/tectonic-typesetting/tectonic/releases/download/"
28
+ f"tectonic%40{TECTONIC_VERSION}/tectonic-{TECTONIC_VERSION}-"
29
+ )
30
+ _BIBER_BASE_URL = (
31
+ "https://sourceforge.net/projects/biblatex-biber/files/biblatex-biber/"
32
+ f"{BIBER_VERSION}/binaries/"
33
+ )
34
+
35
+
36
+ class BundledToolError(RuntimeError):
37
+ """Raised when a bundled tool cannot be prepared."""
38
+
39
+
40
+ class TectonicAcquisitionError(BundledToolError):
41
+ """Raised when the bundled Tectonic binary cannot be prepared."""
42
+
43
+
44
+ class BiberAcquisitionError(BundledToolError):
45
+ """Raised when the bundled Biber binary cannot be prepared."""
46
+
47
+
48
+ class MakeglossariesAcquisitionError(BundledToolError):
49
+ """Raised when the makeglossaries helper cannot be prepared."""
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class TectonicSelection:
54
+ """Resolved Tectonic binary and its origin."""
55
+
56
+ path: Path
57
+ source: str # "bundled" or "system"
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class HelperSelection:
62
+ """Resolved auxiliary tool selection (system or bundled)."""
63
+
64
+ path: Path
65
+ source: str # "bundled" or "system"
66
+
67
+
68
+ def select_tectonic_binary(
69
+ use_system: bool = False, *, console: Console | None = None
70
+ ) -> TectonicSelection:
71
+ """Return the Tectonic binary to invoke, downloading when needed."""
72
+ if use_system:
73
+ system_path = shutil.which("tectonic")
74
+ if system_path is None:
75
+ raise TectonicAcquisitionError(
76
+ "Tectonic is not available on PATH (use --engine lualatex/xelatex "
77
+ "or omit --system to download the bundled binary)."
78
+ )
79
+ return TectonicSelection(path=Path(system_path), source="system")
80
+
81
+ bundled_path = _ensure_bundled_binary(console=console)
82
+ return TectonicSelection(path=bundled_path, source="bundled")
83
+
84
+
85
+ def _ensure_bundled_binary(*, console: Console | None) -> Path:
86
+ install_dir = _install_dir()
87
+ binary_name = "tectonic.exe" if _is_windows() else "tectonic"
88
+ target = install_dir / binary_name
89
+
90
+ if target.exists() and target.is_file():
91
+ if _binary_matches_version(target):
92
+ _log(console, f"Using bundled Tectonic at {target}")
93
+ return target
94
+ _log(console, f"Refreshing bundled Tectonic in {install_dir}")
95
+ target.unlink(missing_ok=True)
96
+
97
+ arch, archive_ext = _detect_architecture()
98
+ url = f"{_BASE_URL}{arch}{archive_ext}"
99
+ _log(console, f"Downloading Tectonic {TECTONIC_VERSION} for {arch}…")
100
+
101
+ try:
102
+ with tempfile.TemporaryDirectory(prefix="texsmith-tectonic-") as tmpdir:
103
+ archive_path = Path(tmpdir) / f"tectonic{archive_ext}"
104
+ _download_file(url, archive_path)
105
+ extracted_root = Path(tmpdir) / "extracted"
106
+ extracted_root.mkdir(parents=True, exist_ok=True)
107
+ _extract_archive(archive_path, extracted_root)
108
+ candidate = _find_binary(extracted_root, binary_name)
109
+ _log(console, f"Installing bundled Tectonic into {install_dir}")
110
+ install_dir.mkdir(parents=True, exist_ok=True)
111
+ source_dir = candidate.parent
112
+ if _is_windows():
113
+ # Windows builds ship DLL sidecars; copy them alongside the binary.
114
+ for item in source_dir.iterdir():
115
+ destination = install_dir / item.name
116
+ if destination.exists():
117
+ if destination.is_dir():
118
+ shutil.rmtree(destination)
119
+ else:
120
+ destination.unlink()
121
+ if item.is_dir():
122
+ shutil.copytree(item, destination)
123
+ else:
124
+ shutil.copy2(item, destination)
125
+ target = install_dir / binary_name
126
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
127
+ else:
128
+ shutil.move(str(candidate), target)
129
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
130
+ except (OSError, URLError, zipfile.BadZipFile, tarfile.TarError) as exc:
131
+ raise TectonicAcquisitionError(f"Unable to download bundled Tectonic: {exc}") from exc
132
+
133
+ return target
134
+
135
+
136
+ def select_biber_binary(*, console: Console | None = None) -> Path:
137
+ """Return the bundled Biber binary, downloading when needed."""
138
+ install_dir = _install_dir()
139
+ binary_name = "biber.exe" if _is_windows() else "biber"
140
+ target = install_dir / binary_name
141
+
142
+ if target.exists() and target.is_file():
143
+ if _binary_matches_version(target, expected=BIBER_VERSION):
144
+ _log(console, f"Using bundled Biber at {target}", tool="biber")
145
+ return target
146
+ _log(console, f"Refreshing bundled Biber in {install_dir}", tool="biber")
147
+ target.unlink(missing_ok=True)
148
+
149
+ archive_url, archive_ext = _detect_biber_archive()
150
+ _log(console, f"Downloading Biber {BIBER_VERSION} ({archive_url})…", tool="biber")
151
+
152
+ try:
153
+ with tempfile.TemporaryDirectory(prefix="texsmith-biber-") as tmpdir:
154
+ archive_path = Path(tmpdir) / f"biber{archive_ext}"
155
+ _download_file(archive_url, archive_path)
156
+ extracted_root = Path(tmpdir) / "extracted"
157
+ extracted_root.mkdir(parents=True, exist_ok=True)
158
+ _extract_archive(archive_path, extracted_root)
159
+ candidate = _find_binary(extracted_root, binary_name, error_cls=BiberAcquisitionError)
160
+ _log(console, f"Installing bundled Biber into {install_dir}", tool="biber")
161
+ shutil.move(str(candidate), target)
162
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
163
+ except (OSError, URLError, zipfile.BadZipFile, tarfile.TarError) as exc:
164
+ raise BiberAcquisitionError(f"Unable to download bundled Biber: {exc}") from exc
165
+
166
+ return target
167
+
168
+
169
+ def select_makeglossaries(*, console: Console | None = None) -> HelperSelection:
170
+ """Return a makeglossaries command, downloading the Perl helper if absent."""
171
+ existing = shutil.which("makeglossaries")
172
+ if existing:
173
+ return HelperSelection(path=Path(existing), source="system")
174
+ bundled = _ensure_makeglossaries(console=console)
175
+ return HelperSelection(path=bundled, source="bundled")
176
+
177
+
178
+ def _ensure_makeglossaries(*, console: Console | None) -> Path:
179
+ install_dir = _install_dir()
180
+ target = install_dir / "makeglossaries"
181
+
182
+ if target.exists() and target.is_file():
183
+ _log(console, f"Using bundled makeglossaries at {target}", tool="makeglossaries")
184
+ return target
185
+
186
+ _log(console, f"Downloading makeglossaries helper into {install_dir}", tool="makeglossaries")
187
+
188
+ try:
189
+ with tempfile.TemporaryDirectory(prefix="texsmith-makeglossaries-") as tmpdir:
190
+ archive_path = Path(tmpdir) / "glossaries.zip"
191
+ _download_file(MAKEGLOSSARIES_URL, archive_path)
192
+ extracted_root = Path(tmpdir) / "extracted"
193
+ extracted_root.mkdir(parents=True, exist_ok=True)
194
+ _extract_archive(archive_path, extracted_root)
195
+ candidate = _find_binary(
196
+ extracted_root, "makeglossaries", error_cls=MakeglossariesAcquisitionError
197
+ )
198
+ shutil.move(str(candidate), target)
199
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
200
+ except (OSError, URLError, zipfile.BadZipFile, tarfile.TarError) as exc:
201
+ raise MakeglossariesAcquisitionError(
202
+ f"Unable to download makeglossaries helper: {exc}"
203
+ ) from exc
204
+
205
+ return target
206
+
207
+
208
+ def _install_dir() -> Path:
209
+ return get_user_dir().data_dir("bin")
210
+
211
+
212
+ def _download_file(url: str, destination: Path) -> None:
213
+ with urlopen(url) as response, destination.open("wb") as handle:
214
+ shutil.copyfileobj(response, handle)
215
+
216
+
217
+ def _extract_archive(archive_path: Path, destination: Path) -> None:
218
+ suffixes = archive_path.suffixes
219
+ if suffixes and suffixes[-1] == ".zip":
220
+ with zipfile.ZipFile(archive_path) as archive:
221
+ archive.extractall(destination)
222
+ return
223
+ mode = "r:gz" if suffixes[-2:] == [".tar", ".gz"] or suffixes[-1:] == [".tgz"] else "r:*"
224
+ with tarfile.open(archive_path, mode) as archive:
225
+ try:
226
+ archive.extractall(destination, filter="data")
227
+ except TypeError: # Python < 3.12 compatibility (no filter kw)
228
+ archive.extractall(destination)
229
+
230
+
231
+ def _find_binary(
232
+ root: Path, name: str, *, error_cls: type[BundledToolError] = TectonicAcquisitionError
233
+ ) -> Path:
234
+ for candidate in root.rglob(name):
235
+ if candidate.is_file():
236
+ return candidate
237
+ raise error_cls(f"{name} binary not found in archive contents for {root}")
238
+
239
+
240
+ def _binary_matches_version(binary: Path, *, expected: str = TECTONIC_VERSION) -> bool:
241
+ return _probe_version(binary, expected=expected)
242
+
243
+
244
+ def _probe_version(binary: Path, *, expected: str) -> bool:
245
+ try:
246
+ process = subprocess.run(
247
+ [str(binary), "--version"],
248
+ check=False,
249
+ capture_output=True,
250
+ text=True,
251
+ timeout=10,
252
+ )
253
+ except OSError:
254
+ return False
255
+ if process.returncode != 0:
256
+ return False
257
+ output = (process.stdout or "") + (process.stderr or "")
258
+ return expected in output
259
+
260
+
261
+ def _detect_architecture() -> tuple[str, str]:
262
+ """Return (triple, archive_ext) compatible with official Tectonic releases."""
263
+ system, machine, is_musl = _platform_details()
264
+
265
+ if system == "Darwin":
266
+ ostype = "apple-darwin"
267
+ elif system == "Windows":
268
+ ostype = "pc-windows-msvc"
269
+ elif system == "Linux":
270
+ libc = "musl" if is_musl else "gnu"
271
+ ostype = f"unknown-linux-{libc}"
272
+ else:
273
+ raise TectonicAcquisitionError(f"Unsupported platform: {system}")
274
+
275
+ if machine in {"x86_64", "x86-64", "amd64", "x64"}:
276
+ cputype = "x86_64"
277
+ elif machine in {"i386", "i486", "i686", "i786", "x86"}:
278
+ cputype = "i686"
279
+ elif machine in {"aarch64", "arm64"}:
280
+ cputype = "aarch64"
281
+ elif machine in {"armv7l", "armv7"}:
282
+ cputype = "armv7"
283
+ if system == "Linux" and not is_musl:
284
+ ostype = f"{ostype}eabihf"
285
+ else:
286
+ raise TectonicAcquisitionError(f"Unsupported CPU architecture: {machine}")
287
+
288
+ ext = ".zip" if system == "Windows" else ".tar.gz"
289
+ return f"{cputype}-{ostype}", ext
290
+
291
+
292
+ def _detect_biber_archive() -> tuple[str, str]:
293
+ """Return (url, archive_ext) for the appropriate Biber release."""
294
+ system, machine, is_musl = _platform_details()
295
+
296
+ if system == "Linux":
297
+ platform_dir = "Linux-musl" if is_musl else "Linux"
298
+ libc_suffix = "-musl" if is_musl else ""
299
+ if machine in {"x86_64", "x86-64", "amd64", "x64"}:
300
+ filename = f"biber-linux_x86_64{libc_suffix}.tar.gz"
301
+ elif machine in {"aarch64", "arm64"}:
302
+ filename = f"biber-linux_aarch64{libc_suffix}.tar.gz"
303
+ elif machine in {"i386", "i486", "i686", "i786", "x86"}:
304
+ filename = f"biber-linux_i386{libc_suffix}.tar.gz"
305
+ else:
306
+ raise BiberAcquisitionError(f"Unsupported CPU architecture for Biber: {machine}")
307
+ return f"{_BIBER_BASE_URL}{platform_dir}/{filename}", ".tar.gz"
308
+
309
+ if system == "Darwin":
310
+ if machine in {"aarch64", "arm64"}:
311
+ filename = "biber-darwin_arm64.tar.gz"
312
+ elif machine in {"x86_64", "x86-64", "amd64", "x64"}:
313
+ filename = "biber-darwin_x86_64.tar.gz"
314
+ else:
315
+ raise BiberAcquisitionError(f"Unsupported CPU architecture for Biber: {machine}")
316
+ return f"{_BIBER_BASE_URL}Darwin/{filename}", ".tar.gz"
317
+
318
+ if system == "Windows":
319
+ filename = (
320
+ "biber-MSWIN64.zip" if machine in {"x86_64", "amd64", "x64"} else "biber-MSWIN32.zip"
321
+ )
322
+ return f"{_BIBER_BASE_URL}Windows/{filename}", ".zip"
323
+
324
+ raise BiberAcquisitionError(f"Unsupported platform for bundled Biber: {system}")
325
+
326
+
327
+ def _platform_details() -> tuple[str, str, bool]:
328
+ system = platform.system()
329
+ machine = platform.machine().lower()
330
+
331
+ is_musl = False
332
+ if system == "Linux":
333
+ try:
334
+ output = subprocess.check_output(
335
+ ["ldd", "--version"], stderr=subprocess.STDOUT, text=True
336
+ )
337
+ is_musl = "musl" in output.lower()
338
+ except (OSError, subprocess.CalledProcessError):
339
+ is_musl = False
340
+
341
+ return system, machine, is_musl
342
+
343
+
344
+ def _is_windows() -> bool:
345
+ return sys.platform.startswith("win")
346
+
347
+
348
+ def _log(console: Console | None, message: str, *, tool: str = "tectonic") -> None:
349
+ if console is None:
350
+ return
351
+ console.log(f"[cyan]{tool}[/]: {message}")
352
+
353
+
354
+ __all__ = [
355
+ "BIBER_VERSION",
356
+ "TECTONIC_VERSION",
357
+ "BiberAcquisitionError",
358
+ "BundledToolError",
359
+ "HelperSelection",
360
+ "MakeglossariesAcquisitionError",
361
+ "TectonicAcquisitionError",
362
+ "TectonicSelection",
363
+ "select_biber_binary",
364
+ "select_makeglossaries",
365
+ "select_tectonic_binary",
366
+ ]
@@ -0,0 +1,86 @@
1
+ """Utility helpers specific to LaTeX rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import unicodedata
7
+
8
+ from pylatexenc.latexencode import unicode_to_latex
9
+
10
+
11
+ _BASIC_LATEX_ESCAPE_MAP = {
12
+ "&": r"\&",
13
+ "%": r"\%",
14
+ "#": r"\#",
15
+ "$": r"\$",
16
+ "_": r"\_",
17
+ "^": r"\^",
18
+ "{": r"\{",
19
+ "}": r"\}",
20
+ "~": r"\textasciitilde{}",
21
+ "\\": r"\textbackslash{}",
22
+ }
23
+
24
+ _ACCENT_NEEDS_BRACES_PATTERN = re.compile(
25
+ r"\\([" + re.escape("`'^\"~=\\.Hrvuck") + r"])\s*([A-Za-z])(?!\{)"
26
+ )
27
+ _ACCENT_CONTROL_TARGET_PATTERN = re.compile(
28
+ r"\\([" + re.escape("`'^\"~=\\.Hrvuck") + r"])\s*(\\[ij])"
29
+ )
30
+
31
+
32
+ def _wrap_latex_output(payload: str) -> str:
33
+ """Ensure accent macros wrap their payload in braces."""
34
+
35
+ def _repl(match: re.Match[str]) -> str:
36
+ command, char = match.groups()
37
+ return f"\\{command}{{{char}}}"
38
+
39
+ payload = _ACCENT_NEEDS_BRACES_PATTERN.sub(_repl, payload)
40
+
41
+ def _repl_control(match: re.Match[str]) -> str:
42
+ command, control = match.groups()
43
+ return f"\\{command}{{{control}}}"
44
+
45
+ return _ACCENT_CONTROL_TARGET_PATTERN.sub(_repl_control, payload)
46
+
47
+
48
+ def escape_latex_chars(text: str, *, legacy_accents: bool = False) -> str:
49
+ """Escape LaTeX special characters leveraging pylatexenc."""
50
+ if not text:
51
+ return text
52
+ parts: list[str] = []
53
+ buffer: list[str] = []
54
+
55
+ def _encode_chunk(chunk: str) -> str:
56
+ escaped = "".join(_BASIC_LATEX_ESCAPE_MAP.get(char, char) for char in chunk)
57
+ if legacy_accents:
58
+ encoded = unicode_to_latex(escaped, non_ascii_only=True, unknown_char_warning=False)
59
+ return _wrap_latex_output(encoded)
60
+ return escaped
61
+
62
+ def _should_skip_encoding(char: str) -> bool:
63
+ try:
64
+ name = unicodedata.name(char)
65
+ except ValueError:
66
+ return False
67
+ if "SUPERSCRIPT" in name or "SUBSCRIPT" in name:
68
+ return True
69
+ return "MODIFIER LETTER" in name and ("SMALL" in name or "CAPITAL" in name)
70
+
71
+ for char in text:
72
+ if _should_skip_encoding(char):
73
+ if buffer:
74
+ parts.append(_encode_chunk("".join(buffer)))
75
+ buffer.clear()
76
+ parts.append(char)
77
+ else:
78
+ buffer.append(char)
79
+
80
+ if buffer:
81
+ parts.append(_encode_chunk("".join(buffer)))
82
+
83
+ return "".join(parts)
84
+
85
+
86
+ __all__ = ["escape_latex_chars"]