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,663 @@
1
+ """Rich-aware presenters for CLI output and diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ import contextlib
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import typer
11
+
12
+ from texsmith.adapters.latex.engines import (
13
+ LatexMessage,
14
+ LatexMessageSeverity,
15
+ parse_latex_log,
16
+ )
17
+ from texsmith.api.pipeline import ConversionBundle
18
+ from texsmith.api.templates import TemplateRenderResult
19
+
20
+ from .state import CLIState
21
+
22
+
23
+ if TYPE_CHECKING: # pragma: no cover - typing only
24
+ from rich.console import Console
25
+
26
+
27
+ def _get_console(state: CLIState, *, stderr: bool = False) -> Console | None:
28
+ """Retrieve the active Rich console from the CLI state if available.
29
+
30
+ This helper ensures that we respect the user's stream preference (stdout vs stderr)
31
+ and gracefully handle cases where the console hasn't been initialized or is
32
+ running in a non-interactive environment.
33
+ """
34
+ try:
35
+ console = state.err_console if stderr else state.console
36
+ except Exception: # pragma: no cover - fallback when Rich unavailable
37
+ return None
38
+ if getattr(console, "is_terminal", False):
39
+ return console
40
+ return None
41
+
42
+
43
+ def _rich_components() -> tuple[Any, Any, Any, Any] | None:
44
+ """Import and return Rich components if the library is installed.
45
+
46
+ This allows the CLI to degrade gracefully on systems where `rich` is missing,
47
+ falling back to plain text output instead of crashing.
48
+ """
49
+ try:
50
+ from rich import box
51
+ from rich.panel import Panel
52
+ from rich.table import Table
53
+ from rich.text import Text
54
+ except ImportError: # pragma: no cover - Rich absent
55
+ return None
56
+ return box, Panel, Table, Text
57
+
58
+
59
+ def _build_table(
60
+ *,
61
+ title: str | None,
62
+ columns: Sequence[str],
63
+ header_style: str = "bold cyan",
64
+ box_style: Any | None = None,
65
+ ) -> tuple[Any, Any, Any] | tuple[None, None, None]:
66
+ """Create a Rich table with the house style."""
67
+ components = _rich_components()
68
+ if components is None:
69
+ return None, None, None
70
+ box_module, _panel_cls, table_cls, text_cls = components
71
+ table = table_cls(
72
+ title=title or None,
73
+ box=box_style or box_module.SQUARE,
74
+ show_edge=True,
75
+ header_style=header_style,
76
+ )
77
+ for col in columns:
78
+ table.add_column(col)
79
+ return table, text_cls, box_module
80
+
81
+
82
+ def _format_path(path: Path) -> str:
83
+ """Format a path relative to the current working directory for display.
84
+
85
+ Using relative paths reduces visual noise in console output, making it easier
86
+ for users to identify files within their project structure.
87
+ """
88
+ resolved = path.resolve()
89
+ try:
90
+ return str(resolved.relative_to(Path.cwd()))
91
+ except ValueError:
92
+ return str(resolved)
93
+
94
+
95
+ def _colorize_location(text_cls: Any, *, artifact: str, location: str) -> Any:
96
+ """Apply per-artifact coloring to locations for Rich tables."""
97
+ lower_loc = location.lower()
98
+ suffixes = {
99
+ "tex": "bright_cyan",
100
+ "pdf": "bright_green",
101
+ "sty": "yellow",
102
+ "cls": "yellow",
103
+ }
104
+ image_suffixes = {"png", "jpg", "jpeg", "gif", "svg", "bmp", "webp"}
105
+ style: str | None = None
106
+ for suffix, mapped in suffixes.items():
107
+ if lower_loc.endswith(f".{suffix}"):
108
+ style = mapped
109
+ break
110
+ if style is None and (
111
+ any(lower_loc.endswith(f".{ext}") for ext in image_suffixes)
112
+ or artifact.lower() == "asset"
113
+ or "/assets/" in lower_loc
114
+ or lower_loc.startswith("assets")
115
+ ):
116
+ style = "magenta"
117
+ return text_cls(location, style=style) if style else text_cls(location)
118
+
119
+
120
+ def _size_details(path: Path) -> str:
121
+ """Return a human-readable size for a file if it exists."""
122
+ try:
123
+ stat = path.stat()
124
+ except OSError:
125
+ return ""
126
+ if not path.is_file():
127
+ return ""
128
+ size = stat.st_size
129
+ if size >= 1024 * 1024:
130
+ return f"{size / (1024 * 1024):.2f} MiB"
131
+ if size >= 1024:
132
+ return f"{size / 1024:.1f} KiB"
133
+ return f"{size} B"
134
+
135
+
136
+ def _align_size_rows(rows: Sequence[tuple[str, str, str]]) -> list[tuple[str, str, str]]:
137
+ """Pad size strings so decimal points line up."""
138
+ enriched: list[tuple[str, str, str, str | None, str | None, str | None]] = []
139
+ max_left = 0
140
+ max_right = 0
141
+ for artifact, location, detail in rows:
142
+ if detail:
143
+ tokens = detail.split()
144
+ num = tokens[0]
145
+ tail = " ".join(tokens[1:]) if len(tokens) > 1 else ""
146
+ if "." in num:
147
+ left, right = num.split(".", 1)
148
+ else:
149
+ left, right = num, ""
150
+ max_left = max(max_left, len(left))
151
+ max_right = max(max_right, len(right))
152
+ enriched.append((artifact, location, detail, left, right, tail))
153
+ else:
154
+ enriched.append((artifact, location, detail, None, None, None))
155
+
156
+ if max_left == 0 and max_right == 0:
157
+ return list(rows)
158
+
159
+ aligned: list[tuple[str, str, str]] = []
160
+ for artifact, location, detail, left, right, tail in enriched:
161
+ if detail and left is not None and right is not None:
162
+ padded_left = left.rjust(max_left)
163
+ padded_right = right.ljust(max_right)
164
+ if max_right:
165
+ if right:
166
+ number = f"{padded_left}.{padded_right}".rstrip()
167
+ else:
168
+ number = f"{padded_left} {' ' * (max_right + 1)}".rstrip()
169
+ else:
170
+ number = padded_left
171
+ padded = f"{number} {tail}".rstrip()
172
+ aligned.append((artifact, location, padded))
173
+ else:
174
+ aligned.append((artifact, location, detail))
175
+ return aligned
176
+
177
+
178
+ def _render_summary(state: CLIState, title: str, rows: Sequence[tuple[str, str, str]]) -> None:
179
+ """Display a summary table of generated artifacts.
180
+
181
+ This provides the user with a high-level overview of what was created and where,
182
+ saving them from having to manually check the output directory.
183
+ """
184
+ rows = _align_size_rows(rows)
185
+ console = _get_console(state)
186
+ components = _rich_components()
187
+ has_details = any(bool(details) for _, _, details in rows)
188
+ if console is not None and components is not None:
189
+ box_module, _panel_cls, table_cls, text_cls = components
190
+ table = table_cls(box=box_module.SQUARE, header_style="bold cyan")
191
+ if title:
192
+ table.title = title
193
+ table.add_column("Artifact", style="cyan")
194
+ table.add_column("Location")
195
+ if has_details:
196
+ table.add_column("Filesize", style="magenta", justify="right", no_wrap=True)
197
+ for artifact, location, details in rows:
198
+ location_cell = _colorize_location(text_cls, artifact=artifact, location=location)
199
+ if has_details:
200
+ table.add_row(artifact, location_cell, details)
201
+ else:
202
+ table.add_row(artifact, location_cell)
203
+ console.print(table)
204
+ return
205
+
206
+ # Plain-text fallback
207
+ if title:
208
+ typer.echo(title)
209
+ for artifact, location, details in rows:
210
+ suffix = f" — {details}" if details else ""
211
+ typer.echo(f" * {artifact}: {location}{suffix}")
212
+
213
+
214
+ def present_rule_descriptions(state: CLIState, rules: Sequence[Mapping[str, Any]]) -> None:
215
+ """Render a diagnostic view of registered render rules."""
216
+ if not rules:
217
+ return
218
+
219
+ console = _get_console(state)
220
+ if console is not None:
221
+ table, _text_cls, box_module = _build_table(
222
+ title="Registered Rules",
223
+ columns=["Phase", "Tag", "Name", "Priority", "Before", "After"],
224
+ )
225
+ if table is not None and box_module is not None:
226
+ for entry in rules:
227
+ table.add_row(
228
+ str(entry.get("phase", "")),
229
+ str(entry.get("tag", "")),
230
+ str(entry.get("name", "")),
231
+ str(entry.get("priority", "")),
232
+ ", ".join(entry.get("before", []) or []),
233
+ ", ".join(entry.get("after", []) or []),
234
+ )
235
+ console.print(table)
236
+ return
237
+
238
+ typer.echo("Registered Rules:")
239
+ for entry in rules:
240
+ before = ", ".join(entry.get("before", []) or [])
241
+ after = ", ".join(entry.get("after", []) or [])
242
+ typer.echo(
243
+ f" - {entry.get('phase', '')}/{entry.get('tag', '')}: {entry.get('name', '')} "
244
+ f"(priority={entry.get('priority', '')}, before=[{before}], after=[{after}])"
245
+ )
246
+
247
+
248
+ def _normalise_script_usage(
249
+ usage: Sequence[Mapping[str, Any]] | None,
250
+ ) -> list[Mapping[str, Any]]:
251
+ normalised: list[Mapping[str, Any]] = []
252
+ for entry in usage or []:
253
+ slug = entry.get("slug") if isinstance(entry, Mapping) else None
254
+ if not slug:
255
+ continue
256
+ normalised.append(entry)
257
+ return normalised
258
+
259
+
260
+ def present_fonts_info(state: CLIState, render_result: TemplateRenderResult) -> None:
261
+ """Display a table of detected fallback fonts."""
262
+ template_context = getattr(render_result, "context", {}) or {}
263
+ fonts_section = template_context.get("fonts") if isinstance(template_context, Mapping) else {}
264
+ usage = _normalise_script_usage(
265
+ fonts_section.get("script_usage") if isinstance(fonts_section, Mapping) else []
266
+ )
267
+ if not usage:
268
+ usage = _normalise_script_usage(getattr(render_result.document_state, "script_usage", []))
269
+ if not usage:
270
+ console = _get_console(state)
271
+ components = _rich_components()
272
+ if console is not None and components is not None:
273
+ box_module, panel_cls, _table_cls, text_cls = components
274
+ console.print(
275
+ panel_cls(
276
+ text_cls("No fallback fonts detected; base fonts only.", style="dim"),
277
+ box=box_module.SIMPLE,
278
+ border_style="cyan",
279
+ )
280
+ )
281
+ return
282
+ typer.echo("No fallback fonts detected; base fonts only.")
283
+ return
284
+
285
+ console = _get_console(state)
286
+ if console is not None:
287
+ table, _text_cls, _box_module = _build_table(
288
+ title="Fallback Fonts",
289
+ columns=["Script", "Text Cmd", "Font Cmd", "Codepoints", "Font Name"],
290
+ )
291
+ if table is not None:
292
+ for entry in usage:
293
+ count = entry.get("count")
294
+ count_text = str(int(count)) if isinstance(count, (int, float)) else ""
295
+ table.add_row(
296
+ str(entry.get("group") or entry.get("slug") or ""),
297
+ f"\\{entry.get('text_command')}" if entry.get("text_command") else "",
298
+ f"\\{entry.get('font_command')}" if entry.get("font_command") else "",
299
+ count_text,
300
+ str(entry.get("font_name") or ""),
301
+ )
302
+ console.print(table)
303
+ return
304
+
305
+ typer.echo("Fallback Fonts:")
306
+ for entry in usage:
307
+ script = entry.get("group") or entry.get("slug") or ""
308
+ text_cmd = entry.get("text_command") or ""
309
+ font_cmd = entry.get("font_command") or ""
310
+ font_name = entry.get("font_name") or ""
311
+ count = entry.get("count")
312
+ count_text = f" [{int(count)}]" if isinstance(count, (int, float)) else ""
313
+ typer.echo(
314
+ f" - {script}: \\{text_cmd or '?'} -> \\{font_cmd or '?'} ({font_name}){count_text}"
315
+ )
316
+
317
+
318
+ def present_context_attributes(state: CLIState, render_result: TemplateRenderResult) -> None:
319
+ """Display resolved context attributes with emitters and consumers."""
320
+
321
+ entries = getattr(render_result, "context_attributes", []) or []
322
+ if not entries:
323
+ typer.echo("No context attributes recorded.")
324
+ return
325
+
326
+ console = _get_console(state)
327
+ components = _rich_components()
328
+ if console is not None and components is not None:
329
+ table, _text_cls, _box_module = _build_table(
330
+ title="Template Context",
331
+ columns=["Key", "Emitters", "Consumers", "Value"],
332
+ )
333
+ if table is not None:
334
+ for entry in entries:
335
+ emitters = "\n".join(entry.get("emitters", []) or [])
336
+ consumers = "\n".join(entry.get("consumers", []) or [])
337
+ table.add_row(
338
+ str(entry.get("name", "")),
339
+ emitters or "-",
340
+ consumers or "-",
341
+ str(entry.get("value", "")),
342
+ )
343
+ console.print(table)
344
+ return
345
+
346
+ typer.echo("Context attributes:")
347
+ for entry in entries:
348
+ emitters_list = entry.get("emitters", []) or ["-"]
349
+ consumers_list = entry.get("consumers", []) or ["-"]
350
+ emitters = "\n ".join(emitters_list)
351
+ consumers = "\n ".join(consumers_list)
352
+ typer.echo(
353
+ f" - {entry.get('name', '')}:\n"
354
+ f" emitters: {emitters}\n"
355
+ f" consumers: {consumers}\n"
356
+ f" value: {entry.get('value', '')}"
357
+ )
358
+
359
+
360
+ def _detect_assets(directory: Path) -> list[Path]:
361
+ """Find asset files in the given directory.
362
+
363
+ We report these to the user so they know which static resources (images, fonts)
364
+ were successfully copied or generated alongside their document.
365
+ """
366
+ assets_dir = directory / "assets"
367
+ if not assets_dir.is_dir():
368
+ return []
369
+ return sorted(
370
+ (path for path in assets_dir.rglob("*") if path.is_file()),
371
+ key=lambda p: p.relative_to(assets_dir).as_posix().lower(),
372
+ )
373
+
374
+
375
+ def _detect_ts_packages(directory: Path) -> list[Path]:
376
+ """Detect generated ts-* packages to highlight them in the summary."""
377
+ candidates = [
378
+ directory / "ts-fonts.sty",
379
+ directory / "ts-glossary.sty",
380
+ ]
381
+ return [path for path in candidates if path.is_file()]
382
+
383
+
384
+ def _detect_manifests(directory: Path) -> list[Path]:
385
+ """Find manifest files in the given directory.
386
+
387
+ Manifests contain machine-readable metadata about the build. Detecting and
388
+ reporting them confirms to the user that the build metadata was correctly persisted.
389
+ """
390
+ return sorted(path for path in directory.glob("*.json") if "manifest" in path.name.lower())
391
+
392
+
393
+ def _detect_debug_html(directory: Path) -> list[Path]:
394
+ """Find debug HTML snapshots in the given directory.
395
+
396
+ These snapshots are crucial for troubleshooting rendering issues. Pointing them
397
+ out explicitly helps users find the diagnostic information they need.
398
+ """
399
+ return sorted(directory.glob("*.debug.html"))
400
+
401
+
402
+ def present_conversion_summary(
403
+ *,
404
+ state: CLIState,
405
+ output_mode: str,
406
+ bundle: ConversionBundle | None,
407
+ output_path: Path | None,
408
+ render_result: TemplateRenderResult | None,
409
+ ) -> None:
410
+ rows: list[tuple[str, str, str]] = []
411
+
412
+ if render_result is not None:
413
+ main_dir = render_result.main_tex_path.parent
414
+ rows.append(
415
+ (
416
+ "Main document",
417
+ _format_path(render_result.main_tex_path),
418
+ _size_details(render_result.main_tex_path),
419
+ )
420
+ )
421
+ for fragment in render_result.fragment_paths:
422
+ rows.append(("Fragment", _format_path(fragment), _size_details(fragment)))
423
+ if render_result.bibliography_path is not None:
424
+ rows.append(
425
+ (
426
+ "Bibliography",
427
+ _format_path(render_result.bibliography_path),
428
+ _size_details(render_result.bibliography_path),
429
+ )
430
+ )
431
+ for package in _detect_ts_packages(main_dir):
432
+ rows.append(
433
+ (
434
+ package.stem,
435
+ _format_path(package),
436
+ _size_details(package),
437
+ )
438
+ )
439
+ for manifest in _detect_manifests(main_dir):
440
+ rows.append(("Manifest", _format_path(manifest), _size_details(manifest)))
441
+ for asset in _detect_assets(main_dir):
442
+ rows.append(("Asset", _format_path(asset), _size_details(asset)))
443
+ for debug_html in _detect_debug_html(main_dir):
444
+ rows.append(("Debug HTML", _format_path(debug_html), _size_details(debug_html)))
445
+ _render_summary(state, "", rows)
446
+ return
447
+
448
+ if output_mode == "file" and output_path is not None:
449
+ rows.append(("LaTeX", _format_path(output_path), _size_details(output_path)))
450
+ elif output_mode == "directory" and bundle is not None:
451
+ for fragment in bundle.fragments:
452
+ path = fragment.output_path or (
453
+ output_path / f"{fragment.stem}.tex" if output_path else None
454
+ )
455
+ if path is None:
456
+ continue
457
+ rows.append(("Fragment", _format_path(Path(path)), _size_details(Path(path))))
458
+ if output_path is not None:
459
+ for manifest in _detect_manifests(output_path):
460
+ rows.append(("Manifest", _format_path(manifest), _size_details(manifest)))
461
+ for asset in _detect_assets(output_path):
462
+ rows.append(("Asset", _format_path(asset), _size_details(asset)))
463
+ for debug_html in _detect_debug_html(output_path):
464
+ rows.append(("Debug HTML", _format_path(debug_html), _size_details(debug_html)))
465
+
466
+ if rows:
467
+ _render_summary(state, "Conversion Summary", rows)
468
+
469
+
470
+ def present_html_summary(
471
+ *,
472
+ state: CLIState,
473
+ output_mode: str,
474
+ output_paths: list[Path],
475
+ ) -> None:
476
+ rows: list[tuple[str, str, str]] = []
477
+ if output_mode == "file" and output_paths:
478
+ rows.append(("HTML", _format_path(output_paths[0]), _size_details(output_paths[0])))
479
+ elif output_mode in {"directory", "template"}:
480
+ for path in output_paths:
481
+ rows.append(("HTML", _format_path(path), _size_details(path)))
482
+ if rows:
483
+ _render_summary(state, "HTML Output", rows)
484
+
485
+
486
+ def present_build_summary(
487
+ *,
488
+ state: CLIState,
489
+ render_result: TemplateRenderResult,
490
+ pdf_path: Path,
491
+ ) -> None:
492
+ rows = [
493
+ (
494
+ "Main document",
495
+ _format_path(render_result.main_tex_path),
496
+ _size_details(render_result.main_tex_path),
497
+ ),
498
+ ("PDF", _format_path(pdf_path), _size_details(pdf_path)),
499
+ ]
500
+ for fragment in render_result.fragment_paths:
501
+ rows.append(("Fragment", _format_path(fragment), _size_details(fragment)))
502
+ if render_result.bibliography_path is not None:
503
+ rows.append(
504
+ (
505
+ "Bibliography",
506
+ _format_path(render_result.bibliography_path),
507
+ _size_details(render_result.bibliography_path),
508
+ )
509
+ )
510
+ build_dir = render_result.main_tex_path.parent
511
+ for package in _detect_ts_packages(build_dir):
512
+ rows.append((package.stem, _format_path(package), _size_details(package)))
513
+ for manifest in _detect_manifests(build_dir):
514
+ rows.append(("Manifest", _format_path(manifest), _size_details(manifest)))
515
+ for asset in _detect_assets(build_dir):
516
+ rows.append(("Asset", _format_path(asset), _size_details(asset)))
517
+ for debug_html in _detect_debug_html(build_dir):
518
+ rows.append(("Debug HTML", _format_path(debug_html), _size_details(debug_html)))
519
+ _render_summary(state, "", rows)
520
+
521
+
522
+ def _format_message_entry(message: LatexMessage) -> str:
523
+ """Format a LaTeX log message for display in the failure panel.
524
+
525
+ This condenses complex LaTeX log entries into a single, readable line,
526
+ stripping unnecessary details to help the user focus on the primary error.
527
+ """
528
+ details = "; ".join(message.details[:2])
529
+ if details:
530
+ return f"{message.summary} ({details})"
531
+ return message.summary
532
+
533
+
534
+ def _render_failure_panel(
535
+ state: CLIState,
536
+ title: str,
537
+ rows: Sequence[tuple[str, str]],
538
+ ) -> None:
539
+ """Display a failure diagnostic panel.
540
+
541
+ This panel highlights the critical error and suggests next steps, helping
542
+ users diagnose build failures quickly without wading through raw logs.
543
+ """
544
+ console = _get_console(state, stderr=True)
545
+ components = _rich_components()
546
+ if console is not None and components is not None:
547
+ box_module, panel_cls, table_cls, text_cls = components
548
+ table = table_cls(box=box_module.SQUARE, show_header=False)
549
+ for label, value in rows:
550
+ table.add_row(text_cls(label, style="bold red"), text_cls(value, style="yellow"))
551
+ console.print(panel_cls(table, box=box_module.SQUARE, title=title, border_style="red"))
552
+ return
553
+
554
+ typer.echo(title, err=True)
555
+ for label, value in rows:
556
+ typer.echo(f" {label}: {value}", err=True)
557
+
558
+
559
+ def present_latex_failure(
560
+ *,
561
+ state: CLIState,
562
+ log_path: Path,
563
+ messages: Sequence[LatexMessage],
564
+ open_log: bool,
565
+ ) -> None:
566
+ errors = [msg for msg in messages if msg.severity is LatexMessageSeverity.ERROR]
567
+ warnings = [msg for msg in messages if msg.severity is LatexMessageSeverity.WARNING]
568
+ rows: list[tuple[str, str]] = []
569
+
570
+ primary_entry: str | None = None
571
+ if errors:
572
+ primary_entry = _format_message_entry(errors[0])
573
+ elif warnings:
574
+ primary_entry = _format_message_entry(warnings[-1])
575
+ else:
576
+ parsed = parse_latex_log(log_path)
577
+ if parsed:
578
+ primary_entry = _format_message_entry(parsed[0])
579
+
580
+ if primary_entry:
581
+ label = "Primary error" if errors or not warnings else "Last warning"
582
+ rows.append((label, primary_entry))
583
+
584
+ rows.append(("Log file", _format_path(log_path)))
585
+ rows.append(("Next steps", "Inspect the log or re-run with --classic-output"))
586
+
587
+ _render_failure_panel(state, "LaTeX failure", rows)
588
+
589
+ if open_log and log_path.exists(): # pragma: no cover - depends on platform
590
+ with contextlib.suppress(Exception):
591
+ typer.launch(str(log_path))
592
+
593
+
594
+ def consume_event_diagnostics(state: CLIState) -> list[str]:
595
+ verbosity = state.verbosity
596
+ if verbosity <= 0 or not state.events:
597
+ state.events.clear()
598
+ return []
599
+
600
+ output_lines: list[str] = []
601
+
602
+ if verbosity >= 1:
603
+ slot_events = state.events.get("slot_assignments", [])
604
+ for event in slot_events:
605
+ assignments = event.get("entries", [])
606
+ if not assignments:
607
+ continue
608
+ output_lines.append("Slot assignments:")
609
+ for assignment in assignments:
610
+ document = assignment.get("document", "")
611
+ slot = assignment.get("slot", "")
612
+ selector = assignment.get("selector") or "*"
613
+ include = "include" if assignment.get("include_document") else "extract"
614
+ output_lines.append(f" - {slot} ← {document} ({selector}, {include})")
615
+
616
+ for event in state.events.get("doi_fetch", []):
617
+ value = event.get("value")
618
+ key = event.get("key")
619
+ output_lines.append(f"Fetched DOI {value} for entry '{key}'")
620
+
621
+ if verbosity >= 2:
622
+ for event in state.events.get("parser_fallback", []):
623
+ preferred = event.get("preferred", "unknown")
624
+ fallback = event.get("fallback", "unknown")
625
+ output_lines.append(f"Parser fallback: {preferred} → {fallback}")
626
+
627
+ for event in state.events.get("template_overrides", []):
628
+ overrides = event.get("values", {})
629
+ if overrides:
630
+ output_lines.append("Template overrides:")
631
+ for key, value in sorted(overrides.items()):
632
+ output_lines.append(f" - {key}: {value}")
633
+
634
+ for event in state.events.get("conversion_settings", []):
635
+ parser = event.get("parser", "auto")
636
+ copy_assets = event.get("copy_assets")
637
+ manifest = event.get("manifest")
638
+ fallback_enabled = event.get("fallback_converters_enabled")
639
+ output_lines.append(
640
+ f"Settings: parser={parser}, copy_assets={copy_assets}, manifest={manifest}, fallback_converters={fallback_enabled}"
641
+ )
642
+ for event in state.events.get("font_requirements", []):
643
+ required = event.get("required", [])
644
+ missing = event.get("missing", [])
645
+ present = event.get("present", [])
646
+ if missing:
647
+ output_lines.append(f"Font gaps: {', '.join(missing)}")
648
+ output_lines.append(f"Font fallbacks: {', '.join(required) or '<none>'}")
649
+ if present:
650
+ output_lines.append(f"Detected locally: {', '.join(present)}")
651
+
652
+ state.events.clear()
653
+ return output_lines
654
+
655
+
656
+ __all__ = [
657
+ "consume_event_diagnostics",
658
+ "present_build_summary",
659
+ "present_conversion_summary",
660
+ "present_html_summary",
661
+ "present_latex_failure",
662
+ "present_rule_descriptions",
663
+ ]