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,263 @@
1
+ """Shared CLI state management utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass, field
8
+ import sys
9
+ from typing import TYPE_CHECKING, Any, cast
10
+
11
+ import click
12
+ import typer
13
+
14
+
15
+ if TYPE_CHECKING:
16
+ from rich.console import Console
17
+
18
+ __all__ = [
19
+ "CLIState",
20
+ "debug_enabled",
21
+ "emit_error",
22
+ "emit_warning",
23
+ "ensure_rich_compat",
24
+ "get_cli_state",
25
+ "render_message",
26
+ "set_cli_state",
27
+ ]
28
+
29
+
30
+ def ensure_rich_compat() -> None:
31
+ """Patch Rich stub modules provided by tests to expose required attributes."""
32
+ import importlib.machinery
33
+ import sys as _sys
34
+ import types
35
+
36
+ rich_mod = _sys.modules.get("rich")
37
+ if rich_mod is None:
38
+ return
39
+ if getattr(rich_mod, "__spec__", None) is None:
40
+ rich_mod.__spec__ = importlib.machinery.ModuleSpec("rich", loader=None)
41
+
42
+ is_stub = getattr(rich_mod, "__file__", None) is None
43
+
44
+ if is_stub:
45
+ try:
46
+ import typer.core as typer_core
47
+
48
+ cast(Any, typer_core).HAS_RICH = False
49
+ except ImportError: # pragma: no cover - typer not available
50
+ pass
51
+ try:
52
+ import typer.main as typer_main
53
+
54
+ cast(Any, typer_main).HAS_RICH = False
55
+ except ImportError: # pragma: no cover - typer not available
56
+ pass
57
+
58
+ if not hasattr(rich_mod, "box"):
59
+ box_module = types.ModuleType("rich.box")
60
+ cast(Any, box_module).SQUARE = object()
61
+ cast(Any, box_module).MINIMAL_DOUBLE_HEAD = object()
62
+ cast(Any, box_module).SIMPLE = object()
63
+ cast(Any, rich_mod).box = box_module
64
+ _sys.modules.setdefault("rich.box", box_module)
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class CLIState:
69
+ """Shared state controlling CLI diagnostics."""
70
+
71
+ verbosity: int = 0
72
+ show_tracebacks: bool = False
73
+ events: dict[str, list[dict[str, Any]]] = field(default_factory=dict, init=False)
74
+ _console: Console | None = field(default=None, init=False, repr=False)
75
+ _err_console: Console | None = field(default=None, init=False, repr=False)
76
+
77
+ @property
78
+ def console(self) -> Console:
79
+ """Return a lazily instantiated stdout console."""
80
+ from rich.console import Console
81
+
82
+ current = getattr(self._console, "file", None)
83
+ if self._console is None or current is not sys.stdout:
84
+ try:
85
+ self._console = Console(file=sys.stdout)
86
+ except TypeError: # pragma: no cover - stub Console fallback
87
+ self._console = Console()
88
+ return self._console
89
+
90
+ @property
91
+ def err_console(self) -> Console:
92
+ """Return a lazily instantiated stderr console."""
93
+ from rich.console import Console
94
+
95
+ current = getattr(self._err_console, "file", None)
96
+ if self._err_console is None or current is not sys.stderr:
97
+ try:
98
+ self._err_console = Console(file=sys.stderr, highlight=False)
99
+ except TypeError: # pragma: no cover - stub Console fallback
100
+ self._err_console = Console()
101
+ return self._err_console
102
+
103
+ def record_event(self, name: str, payload: Mapping[str, Any] | None = None) -> None:
104
+ """Store a structured diagnostic event for later presentation."""
105
+ entry = dict(payload or {})
106
+ self.events.setdefault(name, []).append(entry)
107
+
108
+ def consume_events(self, name: str) -> list[dict[str, Any]]:
109
+ """Retrieve and clear events for the given name."""
110
+ return self.events.pop(name, [])
111
+
112
+
113
+ _STATE_VAR: ContextVar[CLIState | None] = ContextVar("texsmith_cli_state", default=None)
114
+
115
+
116
+ def get_cli_state(
117
+ ctx: typer.Context | click.Context | None = None,
118
+ *,
119
+ create: bool = True,
120
+ ) -> CLIState:
121
+ """Return the CLI state associated with the active Typer context."""
122
+ if ctx is None:
123
+ try:
124
+ candidate = click.get_current_context(silent=True)
125
+ except RuntimeError:
126
+ candidate = None
127
+ if isinstance(candidate, typer.Context):
128
+ ctx = candidate
129
+
130
+ state: CLIState | None = None
131
+
132
+ if isinstance(ctx, typer.Context):
133
+ current_ctx: typer.Context | None = ctx
134
+ while current_ctx is not None:
135
+ obj = getattr(current_ctx, "obj", None)
136
+ if isinstance(obj, CLIState):
137
+ state = obj
138
+ break
139
+ current_ctx = getattr(current_ctx, "parent", None)
140
+ if state is None and create:
141
+ state = CLIState()
142
+ current_ctx = ctx
143
+ current_ctx.obj = state
144
+ if state is not None:
145
+ _STATE_VAR.set(state)
146
+
147
+ if state is None:
148
+ fallback = _STATE_VAR.get(None)
149
+ if fallback is None:
150
+ if not create:
151
+ raise RuntimeError("CLI state is not initialised for this context.")
152
+ fallback = CLIState()
153
+ _STATE_VAR.set(fallback)
154
+ state = fallback
155
+
156
+ return state
157
+
158
+
159
+ def set_cli_state(
160
+ *,
161
+ ctx: typer.Context | None = None,
162
+ verbosity: int | None = None,
163
+ debug: bool | None = None,
164
+ ) -> CLIState:
165
+ """Update the CLI state, returning the current instance."""
166
+ state = get_cli_state(ctx)
167
+ if verbosity is not None:
168
+ state.verbosity = max(0, verbosity)
169
+ if debug is not None:
170
+ state.show_tracebacks = debug
171
+ return state
172
+
173
+
174
+ def _exception_chain(exc: BaseException) -> list[str]:
175
+ chain: list[str] = []
176
+ visited: set[int] = set()
177
+ current = exc.__cause__ or exc.__context__
178
+ while current is not None and id(current) not in visited:
179
+ visited.add(id(current))
180
+ chain.append(f"{type(current).__name__}: {current}")
181
+ current = current.__cause__ or current.__context__
182
+ return chain
183
+
184
+
185
+ def render_message(
186
+ level: str,
187
+ message: str,
188
+ *,
189
+ exception: BaseException | None = None,
190
+ ) -> None:
191
+ """Render a formatted message to the console, including optional diagnostics."""
192
+ state = get_cli_state()
193
+
194
+ if level == "info":
195
+ # Use a neutral console log for info to align with pipeline logging.
196
+ console = state.console
197
+ if getattr(console, "log", None):
198
+ console.log(message)
199
+ return
200
+
201
+ from rich.text import Text
202
+
203
+ style = "red" if level == "error" else "yellow"
204
+ label_style = f"bold {style}"
205
+ if hasattr(Text, "assemble"):
206
+ text = Text.assemble((f"{level}: ", label_style), (message, style))
207
+ else: # pragma: no cover - stub Text fallback
208
+ text = Text()
209
+ text.append(f"{level}: ")
210
+ text.append(message)
211
+ extra_lines: list[str] = []
212
+ if exception is not None and state.verbosity >= 1:
213
+ detail = str(exception).strip()
214
+ if detail and detail not in message:
215
+ extra_lines.append(detail)
216
+ extra_lines.append(f"type: {type(exception).__name__}")
217
+ notes = getattr(exception, "__notes__", None)
218
+ if notes:
219
+ extra_lines.extend(str(note) for note in notes)
220
+ if state.verbosity >= 2:
221
+ chain = _exception_chain(exception)
222
+ if chain:
223
+ extra_lines.append("caused by:")
224
+ extra_lines.extend(f" {entry}" for entry in chain)
225
+ if state.verbosity >= 3:
226
+ extra_lines.append(f"repr: {exception!r}")
227
+
228
+ if extra_lines:
229
+ if hasattr(text, "append"):
230
+ text.append("\n")
231
+ if hasattr(Text, "assemble"):
232
+ text.append("\n".join(extra_lines), style=style)
233
+ else: # pragma: no cover - stub Text fallback
234
+ text.append("\n".join(extra_lines))
235
+ else: # pragma: no cover - fallback if text is a plain string
236
+ text = f"{text}\n" + "\n".join(extra_lines)
237
+
238
+ console = state.err_console if level != "info" else state.console
239
+ if type(console).__name__.startswith("_Stub"): # pragma: no cover - stub Console fallback
240
+ target = sys.stderr if level != "info" else sys.stdout
241
+ print(text if isinstance(text, str) else str(text), file=target)
242
+ else:
243
+ console.print(text)
244
+
245
+
246
+ def emit_warning(message: str, *, exception: BaseException | None = None) -> None:
247
+ """Log a warning-level message to stderr respecting verbosity settings."""
248
+ render_message("warning", message, exception=exception)
249
+
250
+
251
+ def emit_error(message: str, *, exception: BaseException | None = None) -> None:
252
+ """Log an error-level message to stderr respecting verbosity settings."""
253
+ if exception is not None and getattr(exception, "_texsmith_logged", False):
254
+ return
255
+ render_message("error", message, exception=exception)
256
+
257
+
258
+ def debug_enabled() -> bool:
259
+ """Return whether full tracebacks should be displayed."""
260
+ try:
261
+ return get_cli_state(create=False).show_tracebacks
262
+ except RuntimeError:
263
+ return False
@@ -0,0 +1,235 @@
1
+ """Auxiliary helpers used by CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ from texsmith.api.service import SlotAssignment
11
+ from texsmith.core.conversion.inputs import DOCUMENT_SELECTOR_SENTINEL
12
+
13
+
14
+ def parse_slot_option(values: Iterable[str] | None) -> dict[str, str]:
15
+ """Parse CLI slot overrides declared as 'slot:Section' pairs."""
16
+ overrides: dict[str, str] = {}
17
+ if not values:
18
+ return overrides
19
+
20
+ for raw in values:
21
+ if not isinstance(raw, str):
22
+ continue
23
+ entry = raw.strip()
24
+ if not entry:
25
+ continue
26
+ if ":" not in entry:
27
+ raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
28
+ slot_name, selector = entry.split(":", 1)
29
+ slot_name = slot_name.strip()
30
+ selector = selector.strip()
31
+ if not slot_name or not selector:
32
+ raise ValueError(f"Invalid slot override '{raw}', expected format 'slot:Section'.")
33
+ overrides[slot_name] = selector
34
+
35
+ return overrides
36
+
37
+
38
+ def determine_output_target(
39
+ template_selected: bool,
40
+ documents: list[Path],
41
+ output_option: Path | None,
42
+ ) -> tuple[str, Path | None]:
43
+ """Infer where conversion output should be written based on CLI arguments."""
44
+ if template_selected:
45
+ if output_option is None:
46
+ base_dir = documents[0].parent if documents else Path()
47
+ return "template", (base_dir / "build")
48
+ suffix = output_option.suffix.lower()
49
+ if suffix == ".pdf":
50
+ return "template-pdf", output_option
51
+ if output_option.exists() and output_option.is_file():
52
+ raise typer.BadParameter("Template output must be a directory.")
53
+ if suffix:
54
+ raise typer.BadParameter("Template output must be a directory path.")
55
+ return "template", output_option
56
+
57
+ if output_option is None:
58
+ return "stdout", None
59
+
60
+ if output_option.exists() and output_option.is_dir():
61
+ return "directory", output_option
62
+
63
+ if output_option.suffix:
64
+ return "file", output_option
65
+
66
+ return "directory", output_option
67
+
68
+
69
+ def write_output_file(target: Path, content: str) -> None:
70
+ """Persist LaTeX content to disk, creating parent directories as needed."""
71
+ try:
72
+ target.parent.mkdir(parents=True, exist_ok=True)
73
+ target.write_text(content, encoding="utf-8")
74
+ except OSError as exc: # pragma: no cover - filesystem errors
75
+ raise OSError(f"Failed to write LaTeX output to '{target}': {exc}") from exc
76
+
77
+
78
+ def looks_like_document_path(candidate: str) -> bool:
79
+ """Return True when the string has an extension resembling a document."""
80
+ suffix = Path(candidate).suffix.lower()
81
+ return bool(suffix) and suffix in {
82
+ ".md",
83
+ ".markdown",
84
+ ".mdown",
85
+ ".mkd",
86
+ ".html",
87
+ ".htm",
88
+ }
89
+
90
+
91
+ def normalise_selector(selector: str | None) -> str | None:
92
+ """Strip surrounding quotes and whitespace from user-provided selectors."""
93
+ if selector is None:
94
+ return None
95
+ candidate = selector.strip()
96
+ if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in {'"', "'"}:
97
+ candidate = candidate[1:-1].strip()
98
+ return candidate or None
99
+
100
+
101
+ def parse_cli_slot_tokens(
102
+ values: Iterable[str] | None,
103
+ ) -> list[tuple[str, str | None, str | None, str]]:
104
+ """Tokenise slot overrides into (slot, path, selector, raw) tuples."""
105
+ tokens: list[tuple[str, str | None, str | None, str]] = []
106
+ if not values:
107
+ return tokens
108
+
109
+ for raw in values:
110
+ if not isinstance(raw, str):
111
+ continue
112
+ entry = raw.strip()
113
+ if not entry:
114
+ continue
115
+ if ":" not in entry:
116
+ raise typer.BadParameter(
117
+ f"Invalid slot override '{raw}', expected format "
118
+ f"'slot:selector' or 'slot:file[:selector]'."
119
+ )
120
+ slot_name, remainder = entry.split(":", 1)
121
+ slot_name = slot_name.strip()
122
+ remainder = remainder.strip()
123
+ if not slot_name or not remainder:
124
+ raise typer.BadParameter(
125
+ f"Invalid slot override '{raw}', expected format "
126
+ f"'slot:selector' or 'slot:file[:selector]'."
127
+ )
128
+
129
+ path_hint: str | None
130
+ selector_value: str | None
131
+ if ":" in remainder:
132
+ path_part, selector_part = remainder.split(":", 1)
133
+ path_part = path_part.strip()
134
+ selector_value = normalise_selector(selector_part)
135
+ path_hint = path_part or None
136
+ else:
137
+ if looks_like_document_path(remainder):
138
+ path_hint = remainder
139
+ selector_value = None
140
+ else:
141
+ path_hint = None
142
+ selector_value = normalise_selector(remainder)
143
+
144
+ tokens.append((slot_name, path_hint, selector_value, raw))
145
+
146
+ return tokens
147
+
148
+
149
+ def resolve_slot_assignments(
150
+ tokens: list[tuple[str, str | None, str | None, str]],
151
+ documents: list[Path],
152
+ ) -> dict[Path, list[SlotAssignment]]:
153
+ """Resolve parsed slot tokens against provided documents."""
154
+ assignments: dict[Path, list[SlotAssignment]] = {doc: [] for doc in documents}
155
+ if not tokens:
156
+ return assignments
157
+
158
+ resolved_index = {doc.resolve(): doc for doc in documents}
159
+ name_index: dict[str, list[Path]] = {}
160
+ for doc in documents:
161
+ name_index.setdefault(doc.name, []).append(doc)
162
+
163
+ for slot_name, path_hint, selector_value, raw in tokens:
164
+ target_doc: Path | None = None
165
+ if path_hint is None:
166
+ if len(documents) == 1:
167
+ target_doc = documents[0]
168
+ else:
169
+ raise typer.BadParameter(
170
+ f"slot override '{raw}' requires a document "
171
+ "path when multiple inputs are provided."
172
+ )
173
+ else:
174
+ candidate_path = Path(path_hint)
175
+ resolved_candidate: Path | None = None
176
+ try:
177
+ base = (
178
+ candidate_path if candidate_path.is_absolute() else Path.cwd() / candidate_path
179
+ )
180
+ resolved_candidate = base.resolve()
181
+ except OSError:
182
+ resolved_candidate = None
183
+
184
+ if resolved_candidate is not None and resolved_candidate in resolved_index:
185
+ target_doc = resolved_index[resolved_candidate]
186
+ else:
187
+ matches = name_index.get(candidate_path.name, [])
188
+ if len(matches) == 1:
189
+ target_doc = matches[0]
190
+ elif len(matches) > 1:
191
+ raise typer.BadParameter(
192
+ f"slot override '{raw}' is ambiguous; multiple "
193
+ f"documents match '{candidate_path.name}'."
194
+ )
195
+
196
+ if target_doc is None:
197
+ raise typer.BadParameter(f"slot override '{raw}' does not match any provided document.")
198
+
199
+ selector_clean = selector_value
200
+ include_document = False
201
+ if selector_clean is None:
202
+ include_document = True
203
+ else:
204
+ token_lower = selector_clean.strip().lower()
205
+ if token_lower in {"*", DOCUMENT_SELECTOR_SENTINEL.lower()}:
206
+ include_document = True
207
+ selector_clean = None
208
+
209
+ assignments[target_doc].append(
210
+ SlotAssignment(
211
+ slot=slot_name, selector=selector_clean, include_document=include_document
212
+ )
213
+ )
214
+
215
+ return assignments
216
+
217
+
218
+ def organise_slot_overrides(
219
+ values: Iterable[str] | None,
220
+ documents: list[Path],
221
+ ) -> tuple[dict[Path, dict[str, str]], dict[Path, list[SlotAssignment]]]:
222
+ """Produce slot selector overrides and assignments for downstream processing."""
223
+ tokens = parse_cli_slot_tokens(values)
224
+ assignments = resolve_slot_assignments(tokens, documents)
225
+
226
+ slot_overrides: dict[Path, dict[str, str]] = {}
227
+ for doc, entries in assignments.items():
228
+ if not entries:
229
+ continue
230
+ mapping = slot_overrides.setdefault(doc, {})
231
+ for entry in entries:
232
+ if entry.selector is not None:
233
+ mapping[entry.slot] = entry.selector
234
+
235
+ return slot_overrides, assignments
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: texsmith
3
+ Version: 0.0.2.dev0
4
+ Summary: A Markdown to LaTeX converter.
5
+ Project-URL: Homepage, https://github.com/yves-chevallier/texsmith
6
+ Author-email: Yves Chevallier <yves.chevalier@heig-vd.ch>
7
+ License-Expression: MIT
8
+ License-File: LICENSE.md
9
+ Keywords: convert,extensions,latex,markdown,mkdocs
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: beautifulsoup4>=4.14.2
19
+ Requires-Dist: emoji>=2.12.1
20
+ Requires-Dist: jinja2>=3.1.6
21
+ Requires-Dist: markdown>=3.7
22
+ Requires-Dist: pint>=0.24
23
+ Requires-Dist: playwright>=1.56.0
24
+ Requires-Dist: pybtex>=0.24.0
25
+ Requires-Dist: pydantic>=2.7.0
26
+ Requires-Dist: pygments>=2.18.0
27
+ Requires-Dist: pylatexenc>=2.10
28
+ Requires-Dist: pymdown-extensions>=10.9
29
+ Requires-Dist: pymupdf>=1.24.3
30
+ Requires-Dist: python-markdown-math>=0.9
31
+ Requires-Dist: python-slugify>=8.0.4
32
+ Requires-Dist: pyxindy>=0.0.3
33
+ Requires-Dist: pyyaml>=6.0.3
34
+ Requires-Dist: rich>=13.9.0
35
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
36
+ Requires-Dist: typer>=0.12.5
37
+ Requires-Dist: unicodeblocks>=0.3
38
+ Description-Content-Type: text/markdown
39
+
40
+ # TeXSmith
41
+
42
+ [![CI](https://github.com/yves-chevallier/texsmith/actions/workflows/ci.yml/badge.svg)](https://github.com/yves-chevallier/texsmith/actions/workflows/ci.yml)
43
+ [![Coverage](https://codecov.io/gh/yves-chevallier/texsmith/branch/main/graph/badge.svg)](https://codecov.io/gh/yves-chevallier/texsmith)
44
+ [![PyPI](https://img.shields.io/pypi/v/texsmith.svg)](https://pypi.org/project/texsmith/)
45
+ [![Repo Size](https://img.shields.io/github/repo-size/yves-chevallier/texsmith.svg)](https://github.com/yves-chevallier/texsmith)
46
+ [![Python Versions](https://img.shields.io/pypi/pyversions/texsmith.svg?logo=python)](https://pypi.org/project/texsmith/)
47
+ [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md)
48
+
49
+ ![MkDocs](https://img.shields.io/badge/MkDocs-1.6+-blue.svg?logo=mkdocs)
50
+ ![MkDocs Material](https://img.shields.io/badge/MkDocs%20Material-supported-success.svg?logo=materialdesign)
51
+ ![Python](https://img.shields.io/badge/Python-typed-blue.svg?logo=python)
52
+
53
+ TeXSmith is a [Python](https://www.python.org/) package and CLI tool to convert **Markdown** or **HTML** documents into LaTeX format. It is designed to be extensible via templates and integrates with [MkDocs](https://www.mkdocs.org/) for generating printable documents from documentation sites.
54
+
55
+ <p align="center">
56
+ <img src="docs/assets/ts-logo.svg" width="70%" />
57
+ </p>
58
+
59
+ ## TL;DR
60
+
61
+ ```bash
62
+ pip install texsmith
63
+ texsmith input.md input.bib -o build/ --build
64
+ ```
65
+
66
+ ## Key features
67
+
68
+ - **MkDocs-native Markdown** – Ships with the same Material + pymdown extension stack you use in MkDocs, so tabs, callouts, annotations, tooltips, and data tables survive the conversion.
69
+ - **Template-first runtime** – Bundle multiple fragments into slots, merge front matter metadata, and emit LaTeX projects ready for Tectonic or latexmk with Docker-friendly manifests.
70
+ - **CLI and Python parity** – The Typer-powered CLI wraps the same ConversionService you can consume as a library, making CI/CD and notebooks behave like local runs.
71
+ - **Actionable diagnostics** – Structured emitters, verbosity switches, and `--debug` traces keep LaTeX issues debuggable even in automated pipelines.
72
+ - **Extensible converters** – Override Markdown parsers, hook into RenderPhase handlers, or ship diagram transformers (Mermaid, Draw.io, Svgbob) that plug directly into the engine.
73
+
74
+ ## Installation
75
+
76
+ ```bash
77
+ # uv (recommended for isolated CLI installs)
78
+ uv tool install texsmith
79
+
80
+ # pip / pipx
81
+ pip install texsmith
82
+ pipx install texsmith
83
+ ```
84
+
85
+ TeXSmith targets Python 3.10+ and expects a LaTeX distribution (TeX Live, MiKTeX, or MacTeX) when you pass `--build`. Optional converters such as Mermaid rely on Docker (`minlag/mermaid-cli`) unless you register custom handlers.
86
+
87
+ ### Platform notes
88
+
89
+ - **Linux** – Install TeX Live (full) via your package manager or `install-tl`. When running inside CI containers, cache `~/.texliveYY` so repeated latexmk runs stay fast—or use the default Tectonic engine to minimise setup.
90
+ - **macOS** – Use [MacTeX](https://www.tug.org/mactex/) or `BasicTeX` plus the tlmgr packages reported by `texsmith --template <name> --template-info`. Homebrew’s `mactex` cask works well when paired with `uv`.
91
+ - **Windows** – TeXSmith runs via native Python or WSL. For PDF builds we recommend [MiKTeX](https://miktex.org/) + PowerShell, or WSL2 with TeX Live and Docker Desktop (needed for Mermaid).
92
+ - **Docker workflows** – Run `texsmith --build` inside a TeX Live container, mounting your project plus the template directory. Copy tlmgr prerequisites from `--template-info` so images compile without network access.
93
+
94
+ See the [Getting Started guide](docs/guide/getting-started.md) for a step-by-step walkthrough, verification commands, and Python API examples.
95
+
96
+ ## Documentation
97
+
98
+ Browse the full documentation at [yves-chevallier.github.io/texsmith](https://yves-chevallier.github.io/texsmith) for:
99
+
100
+ - [Getting Started](docs/guide/getting-started.md): installation, prerequisites, and API snippets.
101
+ - [CLI Reference](docs/cli/index.md): every flag, including the template inspector.
102
+ - [Markdown Directory](docs/markdown/supported.md): exhaustive syntax coverage.
103
+ - [API Reference](docs/api/index.md): ConversionService, TemplateSession, handlers, and plugins.
104
+ - [Template Cookbook](docs/guide/template-cookbook.md): practical recipes for slots, overrides, packaging, and testing.
105
+ - [Release Notes & Compatibility](docs/guide/release-notes.md): TeXSmith feature history plus template/TeX Live requirements.
106
+
107
+ ## Template catalog
108
+
109
+ Inspect templates by name or path to understand their slots, metadata attributes, TeX Live requirements, and declared assets:
110
+
111
+ ```bash
112
+ texsmith --template article --template-info
113
+ # or inspect a local path
114
+ texsmith --template ./templates/nature --template-info
115
+ texsmith templates # view discovery order across built-ins/packages/local/home
116
+ ```
117
+
118
+ Use this command before wiring slots or when you need to confirm which tlmgr packages to preinstall in CI.
119
+
120
+ ## Examples
121
+
122
+ The `examples/` directory includes reproducible demos:
123
+
124
+ - `examples/paper` – end-to-end render with bibliographies and latexmk (or Tectonic with `--engine tectonic`).
125
+ - `examples/diagrams` – Mermaid and Draw.io conversions.
126
+ - `examples/markdown` – exhaustive Markdown showcase with diagram/front-matter overrides.
127
+
128
+ Each example ships build instructions inside [`docs/examples/index.md`](docs/examples/index.md).
129
+
130
+ ## Project layout
131
+
132
+ The source tree is organised around three top-level namespaces:
133
+
134
+ - `texsmith.core` contains the conversion pipeline, document models, diagnostics, and template helpers.
135
+ - `texsmith.adapters` hosts infrastructure integrations such as Markdown parsing, LaTeX rendering, Docker helpers, and transformer utilities.
136
+ - `texsmith.ui` provides end-user interfaces, including the Typer-powered CLI.
137
+
138
+ ## Core architecture highlights
139
+
140
+ - `ConversionService` encapsulates the orchestration that previously lived in `texsmith.api.service` helpers. Provide a `ConversionRequest` and receive a `ConversionResponse` with rendered bundles and diagnostics.
141
+ - `TemplateRenderer` now owns slot aggregation and LaTeX assembly. `TemplateSession` focuses on session state, template options, and bibliography tracking.
142
+ - `DocumentSlots` unify slot directives from front matter, CLI flags, and programmatic overrides. Every entry point now speaks the same data model.
143
+ - `DiagnosticEmitter` replaces ad-hoc callback bags so warnings, errors, and structured events flow through a predictable interface (CLI uses `CliEmitter`; libraries can plug in their own).
144
+ - Fragments use a `BaseFragment` + config dataclass model (`fragment = YourFragment()` export referenced by `fragment.toml` entrypoints). No legacy factories remain.
145
+
146
+ ### Programmatic conversions with `ConversionService`
147
+
148
+ ```python
149
+ from pathlib import Path
150
+
151
+ from texsmith.api.service import ConversionRequest, ConversionService
152
+
153
+ service = ConversionService()
154
+ request = ConversionRequest(
155
+ documents=[Path("docs/index.html")],
156
+ bibliography_files=[Path("references.bib")],
157
+ template="article",
158
+ render_dir=Path("build"),
159
+ )
160
+ prepared = service.prepare_documents(request)
161
+ response = service.execute(request, prepared=prepared)
162
+
163
+ print("Main TeX:", response.render_result.main_tex_path)
164
+ print("Diagnostics:", [event.name for event in response.diagnostics])
165
+ ```
166
+
167
+ If you only need a quick conversion, the high-level helpers (`texsmith.Document`, `texsmith.convert_documents`, `texsmith.TemplateSession`) continue to work, but they now reuse the same ConversionService plumbing as the CLI.
168
+
169
+ > Refer to `UPGRADE.md` for release notes and migration guidance from earlier builds.
170
+
171
+ ## Render engine phases
172
+
173
+ The rendering pipeline walks the BeautifulSoup tree four times. Each pass maps
174
+ to a value of `RenderPhase` so handlers can opt into the point where their
175
+ transform should fire:
176
+
177
+ - `RenderPhase.PRE`: Early normalisation. Use it to clean the DOM and
178
+ replace nodes before structural changes happen (e.g. unwrap unwanted tags,
179
+ turn inline `<code>` into LaTeX).
180
+ - `RenderPhase.BLOCK`: Block-level transformations once the tree structure
181
+ is stable. Typical consumers convert paragraphs, lists, or blockquotes into
182
+ LaTeX environments.
183
+ - `RenderPhase.INLINE`: Inline formatting where block layout is already
184
+ resolved. It is the right place for emphasis, inline math, or link handling.
185
+ - `RenderPhase.POST`: Final pass after children are processed. Use it for
186
+ tasks that depend on previous passes such as heading numbering or emitting
187
+ collected assets.