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,720 @@
1
+ """Utilities for parsing and presenting output from LaTeX engine builds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field, replace
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ import re
10
+ import selectors
11
+ import subprocess
12
+ from typing import ClassVar, TextIO, cast
13
+
14
+ from rich.console import Console
15
+ from rich.text import Text
16
+
17
+
18
+ class LatexMessageSeverity(Enum):
19
+ """Classification severity extracted from LaTeX output."""
20
+
21
+ INFO = "info"
22
+ WARNING = "warning"
23
+ ERROR = "error"
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class LatexMessage:
28
+ """Structured LaTeX message extracted from the build log."""
29
+
30
+ severity: LatexMessageSeverity
31
+ summary: str
32
+ details: list[str] = field(default_factory=list)
33
+ indent: int = 0
34
+
35
+
36
+ _MESSAGE_PATTERNS: list[tuple[re.Pattern[str], LatexMessageSeverity]] = [
37
+ (re.compile(r"^! (?P<summary>.+)$"), LatexMessageSeverity.ERROR),
38
+ (
39
+ re.compile(r"^Latexmk: (?P<summary>.+\b(?:error|failed|failure).*)$", re.I),
40
+ LatexMessageSeverity.ERROR,
41
+ ),
42
+ (
43
+ re.compile(r"^Latexmk: (?P<summary>Errors, .*)$", re.I),
44
+ LatexMessageSeverity.ERROR,
45
+ ),
46
+ (
47
+ re.compile(r"^LaTeX Warning: (?P<summary>.+)$"),
48
+ LatexMessageSeverity.WARNING,
49
+ ),
50
+ (
51
+ re.compile(r"^Package (?P<context>\S+) Warning: (?P<summary>.+)$"),
52
+ LatexMessageSeverity.WARNING,
53
+ ),
54
+ (
55
+ re.compile(r"^Class (?P<context>\S+) Warning: (?P<summary>.+)$"),
56
+ LatexMessageSeverity.WARNING,
57
+ ),
58
+ (
59
+ re.compile(r"^pdfTeX warning (?P<summary>.+)$", re.I),
60
+ LatexMessageSeverity.WARNING,
61
+ ),
62
+ (
63
+ re.compile(r"^Overfull \\hbox (?P<summary>.+)$"),
64
+ LatexMessageSeverity.WARNING,
65
+ ),
66
+ (
67
+ re.compile(r"^Underfull \\hbox (?P<summary>.+)$"),
68
+ LatexMessageSeverity.WARNING,
69
+ ),
70
+ (
71
+ re.compile(r"^Package (?P<context>\S+) Info: (?P<summary>.+)$"),
72
+ LatexMessageSeverity.INFO,
73
+ ),
74
+ (
75
+ re.compile(r"^Latexmk: (?P<summary>.+)$"),
76
+ LatexMessageSeverity.INFO,
77
+ ),
78
+ (
79
+ re.compile(r"^This is (?P<summary>.+)$"),
80
+ LatexMessageSeverity.INFO,
81
+ ),
82
+ (
83
+ re.compile(r"^Document Class: (?P<summary>.+)$"),
84
+ LatexMessageSeverity.INFO,
85
+ ),
86
+ (
87
+ re.compile(r"^Missing character:(?P<summary>.+)$", re.I),
88
+ LatexMessageSeverity.WARNING,
89
+ ),
90
+ ]
91
+
92
+ _ERROR_CONTINUATIONS = (
93
+ "Emergency stop.",
94
+ "==> Fatal error occurred, no output PDF file produced!",
95
+ )
96
+
97
+ _HIGHLIGHT_PATTERN = re.compile(r"^\.*[A-Za-z0-9 ]+:")
98
+ _TEX_ASSIGN_PATTERN = re.compile(r"\b\\[A-Za-z@]+")
99
+ _STRING_LITERAL_PATTERN = re.compile(r"(['\"])(.*?)(\1)")
100
+ _PATH_LINE_PATTERN = re.compile(r"^(?:\./|\../|/|[A-Za-z]:\\\\).+")
101
+ _PATH_FRAGMENT_PATTERN = re.compile(r"^[A-Za-z0-9._:/\\-]+$")
102
+
103
+
104
+ class LatexLogParser:
105
+ """Incrementally parse LaTeX output into structured messages."""
106
+
107
+ def __init__(self) -> None:
108
+ self._current: LatexMessage | None = None
109
+ self._messages: list[LatexMessage] = []
110
+ self._depth: int = 0
111
+
112
+ @property
113
+ def messages(self) -> Sequence[LatexMessage]:
114
+ """Return the messages accumulated so far."""
115
+ return tuple(self._messages)
116
+
117
+ def process_line(self, line: str) -> list[LatexMessage]:
118
+ """Process a log line and return messages that have just completed."""
119
+ completed: list[LatexMessage] = []
120
+ segments = self._consume_structure(line)
121
+ if not segments:
122
+ return completed
123
+
124
+ for indent_level, payload in segments:
125
+ if not payload or self._should_ignore(payload):
126
+ continue
127
+
128
+ severity, summary = self._match_message(payload)
129
+ if severity is not None:
130
+ message_summary = summary if summary else payload
131
+ if (
132
+ severity is LatexMessageSeverity.ERROR
133
+ and message_summary in _ERROR_CONTINUATIONS
134
+ and self._current
135
+ and self._current.severity is LatexMessageSeverity.ERROR
136
+ ):
137
+ self._current.details.append(message_summary)
138
+ continue
139
+ completed.extend(self._finalize_current())
140
+ self._current = LatexMessage(
141
+ severity=severity,
142
+ summary=message_summary,
143
+ indent=indent_level,
144
+ )
145
+ continue
146
+
147
+ if self._current and self._is_detail_line(payload):
148
+ self._current.details.append(payload)
149
+ continue
150
+
151
+ if self._current and self._merge_path_continuation(payload):
152
+ continue
153
+
154
+ if self._current and self._merge_text_continuation(payload, indent_level):
155
+ continue
156
+
157
+ completed.extend(self._finalize_current())
158
+ self._current = LatexMessage(
159
+ severity=LatexMessageSeverity.INFO,
160
+ summary=payload,
161
+ indent=indent_level,
162
+ )
163
+
164
+ return completed
165
+
166
+ def finalize(self) -> list[LatexMessage]:
167
+ """Flush any pending message."""
168
+ return self._finalize_current()
169
+
170
+ def _finalize_current(self) -> list[LatexMessage]:
171
+ if not self._current:
172
+ return []
173
+ current, self._current = self._current, None
174
+ self._messages.append(current)
175
+ return [current]
176
+
177
+ def _consume_structure(self, line: str) -> list[tuple[int, str]]:
178
+ raw = line.rstrip("\r\n")
179
+ if not raw.strip():
180
+ return []
181
+
182
+ depth = self._depth
183
+ segments: list[tuple[int, str]] = []
184
+ current_chars: list[str] = []
185
+ message_started = False
186
+ message_paren_balance = 0
187
+ indent_for_segment = depth
188
+
189
+ def flush() -> None:
190
+ nonlocal current_chars, message_started, message_paren_balance, indent_for_segment
191
+ if current_chars:
192
+ payload = "".join(current_chars).strip()
193
+ if payload:
194
+ segments.append((indent_for_segment, payload))
195
+ current_chars = []
196
+ message_started = False
197
+ message_paren_balance = 0
198
+ indent_for_segment = depth
199
+
200
+ def peek_next_nonspace(index: int) -> str | None:
201
+ while index < len(raw):
202
+ ch = raw[index]
203
+ if not ch.isspace():
204
+ return ch
205
+ index += 1
206
+ return None
207
+
208
+ idx = 0
209
+ while idx < len(raw):
210
+ ch = raw[idx]
211
+
212
+ if ch == "(":
213
+ if message_started:
214
+ next_ch = peek_next_nonspace(idx + 1)
215
+ if message_paren_balance > 0 or (next_ch and next_ch not in {"/", ".", "\\"}):
216
+ message_paren_balance += 1
217
+ current_chars.append(ch)
218
+ else:
219
+ flush()
220
+ depth += 1
221
+ indent_for_segment = depth
222
+ else:
223
+ depth += 1
224
+ indent_for_segment = depth
225
+ idx += 1
226
+ continue
227
+
228
+ if ch == ")":
229
+ if message_started:
230
+ if message_paren_balance > 0:
231
+ message_paren_balance -= 1
232
+ current_chars.append(ch)
233
+ else:
234
+ flush()
235
+ depth = max(depth - 1, 0)
236
+ indent_for_segment = depth
237
+ else:
238
+ depth = max(depth - 1, 0)
239
+ indent_for_segment = depth
240
+ idx += 1
241
+ continue
242
+
243
+ if ch.isspace():
244
+ if message_started:
245
+ current_chars.append(ch)
246
+ idx += 1
247
+ continue
248
+
249
+ if not message_started:
250
+ message_started = True
251
+ indent_for_segment = depth
252
+ current_chars.append(ch)
253
+ idx += 1
254
+
255
+ flush()
256
+ self._depth = depth
257
+ return segments
258
+
259
+ @staticmethod
260
+ def _is_path_message(text: str) -> bool:
261
+ return bool(_PATH_LINE_PATTERN.match(text))
262
+
263
+ @staticmethod
264
+ def _is_path_continuation_text(text: str) -> bool:
265
+ if not text:
266
+ return False
267
+ stripped = text.lstrip()
268
+ if not stripped:
269
+ return False
270
+ if stripped.startswith(("/", "./", "../")):
271
+ return False
272
+ if stripped.startswith(("Package ", "Class ", "LaTeX ", "Document ", "Library ", "File ")):
273
+ return False
274
+ if stripped.startswith(("! ", "*")):
275
+ return False
276
+ if ":" in stripped:
277
+ return False
278
+ if stripped.startswith(("x)) ", ")) ", "x))(", "))(")):
279
+ return True
280
+ if " " in stripped:
281
+ return False
282
+ return all(ch.isalnum() or ch in "._-" for ch in stripped)
283
+
284
+ def _merge_path_continuation(self, payload: str) -> bool:
285
+ if not self._current or self._current.severity is not LatexMessageSeverity.INFO:
286
+ return False
287
+ if not self._is_path_message(self._current.summary):
288
+ return False
289
+ if not self._is_path_continuation_text(payload):
290
+ return False
291
+ self._current.summary += payload.lstrip()
292
+ return True
293
+
294
+ def _merge_text_continuation(self, payload: str, indent: int) -> bool:
295
+ if not self._current:
296
+ return False
297
+ if indent != self._current.indent:
298
+ return False
299
+ text = payload.strip()
300
+ if not text:
301
+ return False
302
+ if self._is_path_message(text):
303
+ return False
304
+ if _HIGHLIGHT_PATTERN.match(text) and not text.startswith(
305
+ ("T:", "OT:", "LT:", "pt:", "mm:", "in:")
306
+ ):
307
+ return False
308
+ if len(text) > 48:
309
+ return False
310
+
311
+ summary = self._current.summary
312
+ if not self._looks_like_soft_wrap(summary, text):
313
+ return False
314
+
315
+ joiner = ""
316
+ if summary and not summary.endswith((" ", "/", "-", "'", "(", "[")):
317
+ if (
318
+ text.startswith(tuple(".,;:!?)]"))
319
+ or text[0] == "'"
320
+ or (summary.endswith(tuple("0123456789")) and text[0].isdigit())
321
+ ):
322
+ joiner = ""
323
+ else:
324
+ joiner = " "
325
+ self._current.summary = summary + joiner + text
326
+ return True
327
+
328
+ @staticmethod
329
+ def _looks_like_soft_wrap(summary: str, fragment: str) -> bool:
330
+ if not summary:
331
+ return False
332
+ if fragment.startswith(("Run number", "Rule ", "Package", "Class", "LaTeX ", "Document ")):
333
+ return False
334
+ if fragment.startswith(("(", "[", "---")):
335
+ return False
336
+ if fragment.startswith(("/", "./", "../")):
337
+ return False
338
+ if fragment.startswith("latexmk"):
339
+ return False
340
+ if fragment.startswith("! "):
341
+ return False
342
+ if ":" in fragment and not fragment.startswith(("T:", "OT:", "pt:", "mm:", "in:")):
343
+ return False
344
+
345
+ stripped = fragment.strip()
346
+ if not stripped:
347
+ return False
348
+ if stripped.isdigit():
349
+ trimmed_summary = summary.rstrip()
350
+ return trimmed_summary.endswith(tuple("0123456789")) or trimmed_summary.endswith("line")
351
+ if stripped.replace(".", "").isdigit():
352
+ trimmed_summary = summary.rstrip()
353
+ return trimmed_summary.endswith(tuple("0123456789")) or trimmed_summary.endswith("line")
354
+ if len(stripped) <= 4 and stripped.islower():
355
+ return True
356
+ if len(stripped) <= 4 and stripped in {".", "..", "...", ",", ";", "pt", "pt."}:
357
+ return True
358
+ if summary.endswith(("/", "-", "=")):
359
+ return True
360
+ if len(stripped) == 1 and stripped.isalpha():
361
+ return True
362
+ if stripped.startswith("T:") and len(summary) >= 2 and summary[-2] == "/":
363
+ return True
364
+ if stripped.startswith(("OT:", "LT:")) and len(summary) >= 2 and summary[-2] == "/":
365
+ return True
366
+ return len(stripped) <= 6 and stripped.isalpha()
367
+
368
+ @staticmethod
369
+ def _is_detail_line(line: str) -> bool:
370
+ detail_prefixes = (
371
+ "Type ",
372
+ "Enter file name",
373
+ "or enter new name",
374
+ "<read ",
375
+ "*** ",
376
+ "l.",
377
+ )
378
+ return line.startswith(detail_prefixes) or (line.startswith(" ") and bool(line.strip()))
379
+
380
+ @staticmethod
381
+ def _should_ignore(line: str) -> bool:
382
+ trimmed = line.strip()
383
+ return (
384
+ not trimmed
385
+ or trimmed.startswith("[")
386
+ or trimmed in {"Output written on", "Transcript written on"}
387
+ )
388
+
389
+ @staticmethod
390
+ def _match_message(
391
+ line: str,
392
+ ) -> tuple[LatexMessageSeverity | None, str | None]:
393
+ for pattern, severity in _MESSAGE_PATTERNS:
394
+ match = pattern.match(line)
395
+ if match:
396
+ summary = match.groupdict().get("summary", "").strip()
397
+ if not summary:
398
+ summary = line.strip()
399
+ return severity, summary
400
+ return None, None
401
+
402
+
403
+ class LatexLogRenderer:
404
+ """Render structured LaTeX messages to a Rich console."""
405
+
406
+ _SUMMARY_STYLE: ClassVar[dict[LatexMessageSeverity, str]] = {
407
+ LatexMessageSeverity.INFO: "cyan",
408
+ LatexMessageSeverity.WARNING: "bold yellow",
409
+ LatexMessageSeverity.ERROR: "bold red",
410
+ }
411
+ _DETAIL_STYLE: ClassVar[dict[LatexMessageSeverity, str]] = {
412
+ LatexMessageSeverity.INFO: "cyan",
413
+ LatexMessageSeverity.WARNING: "yellow",
414
+ LatexMessageSeverity.ERROR: "red",
415
+ }
416
+
417
+ def __init__(self, console: Console) -> None:
418
+ self.console = console
419
+ self.messages: list[LatexMessage] = []
420
+ self._current_messages: list[LatexMessage] = []
421
+ self._pending: LatexMessage | None = None
422
+ self._pending_heading: bool = False
423
+ self._branch_stack: list[bool] = []
424
+ self._heading_open = False
425
+ self._heading_next_bold = False
426
+
427
+ def consume(self, message: LatexMessage) -> None:
428
+ """Display a single message, queueing it for tree-aware formatting."""
429
+ heading_for_message = False
430
+ heading_state = self._split_heading_line(message.summary)
431
+ if heading_state is not None:
432
+ kind, remainder = heading_state
433
+ if kind == "rule":
434
+ self._heading_open = not self._heading_open
435
+ self._heading_next_bold = self._heading_open
436
+ return
437
+ if remainder:
438
+ heading_for_message = True
439
+ message = replace(message, summary=remainder)
440
+ if not self._heading_open:
441
+ self._heading_open = True
442
+ self._heading_next_bold = False
443
+
444
+ if self._heading_next_bold:
445
+ heading_for_message = True
446
+ self._heading_next_bold = False
447
+
448
+ if self._is_run_boundary(message):
449
+ self._current_messages.clear()
450
+ self.messages.append(message)
451
+ self._current_messages.append(message)
452
+ next_indent = message.indent
453
+ self._emit_pending(next_indent)
454
+ self._pending = message
455
+ self._pending_heading = heading_for_message
456
+
457
+ def summarize(self) -> None:
458
+ """Print a summary of processed messages grouped by severity."""
459
+ self._emit_pending(None)
460
+ warnings = sum(
461
+ 1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.WARNING
462
+ )
463
+ errors = sum(
464
+ 1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.ERROR
465
+ )
466
+ info = sum(1 for msg in self._current_messages if msg.severity is LatexMessageSeverity.INFO)
467
+ summary_parts = [
468
+ f"errors: {errors}",
469
+ f"warnings: {warnings}",
470
+ ]
471
+ if info:
472
+ summary_parts.append(f"info: {info}")
473
+ style = "green" if errors == 0 else "bold red"
474
+ self.console.print(Text("Summary — " + ", ".join(summary_parts), style=style))
475
+
476
+ def _emit_pending(self, next_indent: int | None) -> None:
477
+ if self._pending is None:
478
+ return
479
+ message = self._pending
480
+ heading = self._pending_heading
481
+ depth = message.indent
482
+ connector = self._select_connector(depth, next_indent)
483
+
484
+ branches_snapshot = list(self._branch_stack)
485
+ while len(branches_snapshot) <= depth:
486
+ branches_snapshot.append(False)
487
+
488
+ prefix = self._build_prefix(depth, connector, branches_snapshot)
489
+ self._print_message(message, prefix, branches_snapshot, heading)
490
+ self._update_branch_stack(depth, connector, next_indent)
491
+ self._pending = None
492
+ self._pending_heading = False
493
+
494
+ def _print_message(
495
+ self,
496
+ message: LatexMessage,
497
+ prefix: Text,
498
+ branches_snapshot: list[bool],
499
+ heading: bool = False,
500
+ ) -> None:
501
+ style = self._SUMMARY_STYLE.get(message.severity, "white")
502
+ detail_style = self._DETAIL_STYLE.get(message.severity, "white")
503
+ icon = {
504
+ LatexMessageSeverity.ERROR: "x",
505
+ LatexMessageSeverity.WARNING: "▲",
506
+ }.get(message.severity, "")
507
+
508
+ header = Text()
509
+ header.append_text(prefix)
510
+
511
+ summary_style = style
512
+ if heading:
513
+ summary_style = (
514
+ f"{summary_style} bold" if "bold" not in summary_style else summary_style
515
+ )
516
+ if _HIGHLIGHT_PATTERN.match(message.summary):
517
+ summary_style = f"{summary_style} bold" if summary_style else "bold"
518
+ if message.severity is LatexMessageSeverity.INFO and _TEX_ASSIGN_PATTERN.search(
519
+ message.summary
520
+ ):
521
+ summary_style = "grey58"
522
+ if _PATH_LINE_PATTERN.match(message.summary):
523
+ summary_style = "grey50"
524
+ if icon:
525
+ header.append(f"{icon} ", style=style)
526
+ summary_text = Text(message.summary, style=summary_style)
527
+ self._highlight_strings(summary_text)
528
+ header.append_text(summary_text)
529
+ self.console.print(header)
530
+
531
+ if not message.details:
532
+ return
533
+
534
+ for index, detail in enumerate(message.details):
535
+ is_last = index == len(message.details) - 1
536
+ connector = "└" if is_last else "├"
537
+ detail_branches = branches_snapshot.copy()
538
+ while len(detail_branches) <= message.indent:
539
+ detail_branches.append(False)
540
+ detail_branches[message.indent] = not is_last
541
+
542
+ detail_prefix = self._build_prefix(
543
+ message.indent + 1,
544
+ connector,
545
+ detail_branches,
546
+ )
547
+ detail_style_local = detail_style
548
+ if _TEX_ASSIGN_PATTERN.search(detail):
549
+ detail_style_local = "grey58"
550
+ if _PATH_LINE_PATTERN.match(detail):
551
+ detail_style_local = "grey50"
552
+ detail_text = Text(detail, style=detail_style_local)
553
+ self._highlight_strings(detail_text)
554
+ detail_line = Text()
555
+ detail_line.append_text(detail_prefix)
556
+ detail_line.append_text(detail_text)
557
+ self.console.print(detail_line)
558
+
559
+ @staticmethod
560
+ def _is_run_boundary(message: LatexMessage) -> bool:
561
+ if message.severity is not LatexMessageSeverity.INFO:
562
+ return False
563
+ summary = message.summary.strip()
564
+ return summary.startswith("Run number ") and " of rule " in summary
565
+
566
+ @staticmethod
567
+ def _highlight_strings(text: Text) -> None:
568
+ for match in _STRING_LITERAL_PATTERN.finditer(text.plain):
569
+ start, end = match.span()
570
+ text.stylize("magenta", start, end)
571
+
572
+ def _select_connector(self, depth: int, next_indent: int | None) -> str:
573
+ if next_indent is None or next_indent < depth:
574
+ return "└"
575
+ return "├"
576
+
577
+ @staticmethod
578
+ def _build_prefix(depth: int, connector: str, branches: list[bool]) -> Text:
579
+ prefix = Text()
580
+ for level in range(depth):
581
+ active = branches[level] if level < len(branches) else False
582
+ glyph = "│ " if active else " "
583
+ prefix.append(glyph, style="grey35")
584
+ prefix.append(f"{connector}─ ", style="grey35")
585
+ return prefix
586
+
587
+ def _update_branch_stack(
588
+ self,
589
+ depth: int,
590
+ connector: str,
591
+ next_indent: int | None,
592
+ ) -> None:
593
+ while len(self._branch_stack) <= depth:
594
+ self._branch_stack.append(False)
595
+ self._branch_stack[depth] = connector == "├"
596
+
597
+ if next_indent is not None:
598
+ for idx in range(next_indent + 1, len(self._branch_stack)):
599
+ if idx > depth:
600
+ self._branch_stack[idx] = False
601
+ if connector == "└":
602
+ self._branch_stack[depth] = False
603
+ del self._branch_stack[depth + 1 :]
604
+
605
+ @staticmethod
606
+ def _split_heading_line(summary: str) -> tuple[str, str | None] | None:
607
+ candidate = summary.strip()
608
+ if not candidate or candidate[0] != "-":
609
+ return None
610
+ dash_count = 0
611
+ for ch in candidate:
612
+ if ch != "-":
613
+ break
614
+ dash_count += 1
615
+ if dash_count < 5:
616
+ return None
617
+ remainder = candidate[dash_count:].strip()
618
+ if not remainder:
619
+ return ("rule", None)
620
+ return ("combined", remainder)
621
+
622
+
623
+ @dataclass(slots=True)
624
+ class LatexStreamResult:
625
+ """Result of streaming LaTeX engine output."""
626
+
627
+ returncode: int
628
+ messages: list[LatexMessage]
629
+
630
+
631
+ def _is_library_message(message: LatexMessage) -> bool:
632
+ if message.severity is not LatexMessageSeverity.INFO:
633
+ return False
634
+ summary = message.summary.strip()
635
+ return summary.startswith("Library (")
636
+
637
+
638
+ def _is_quiet_info_message(message: LatexMessage) -> bool:
639
+ if message.severity is not LatexMessageSeverity.INFO:
640
+ return False
641
+ summary = message.summary.strip()
642
+ if summary.startswith("Library ("):
643
+ return True
644
+ candidate = summary
645
+ if candidate:
646
+ candidate = candidate.replace("<", "").replace(">", "").strip()
647
+ return bool(
648
+ _PATH_LINE_PATTERN.match(candidate)
649
+ or ("/" in candidate and _PATH_FRAGMENT_PATTERN.match(candidate))
650
+ )
651
+
652
+
653
+ def _should_emit_message(message: LatexMessage, verbosity: int) -> bool:
654
+ return not (verbosity <= 0 and _is_quiet_info_message(message))
655
+
656
+
657
+ def stream_latexmk_output(
658
+ command: Sequence[str],
659
+ *,
660
+ cwd: str,
661
+ env: Mapping[str, str],
662
+ console: Console,
663
+ verbosity: int = 0,
664
+ ) -> LatexStreamResult:
665
+ """Execute a LaTeX engine command and render output incrementally."""
666
+ parser = LatexLogParser()
667
+ renderer = LatexLogRenderer(console)
668
+
669
+ with subprocess.Popen(
670
+ command,
671
+ cwd=cwd,
672
+ env=dict(env),
673
+ stdout=subprocess.PIPE,
674
+ stderr=subprocess.PIPE,
675
+ text=True,
676
+ bufsize=1,
677
+ encoding="utf-8",
678
+ errors="replace",
679
+ ) as process:
680
+ selector = selectors.DefaultSelector()
681
+ if process.stdout:
682
+ selector.register(process.stdout, selectors.EVENT_READ)
683
+ if process.stderr:
684
+ selector.register(process.stderr, selectors.EVENT_READ)
685
+
686
+ while selector.get_map():
687
+ for key, _ in selector.select():
688
+ stream_obj = key.fileobj
689
+ if isinstance(stream_obj, int) or not hasattr(stream_obj, "readline"):
690
+ selector.unregister(stream_obj)
691
+ continue
692
+ stream = cast(TextIO, stream_obj)
693
+ chunk = stream.readline()
694
+ if chunk:
695
+ for completed in parser.process_line(chunk):
696
+ if _should_emit_message(completed, verbosity):
697
+ renderer.consume(completed)
698
+ else:
699
+ selector.unregister(stream)
700
+
701
+ for completed in parser.finalize():
702
+ if _should_emit_message(completed, verbosity):
703
+ renderer.consume(completed)
704
+
705
+ returncode = process.wait()
706
+
707
+ renderer.summarize()
708
+ return LatexStreamResult(returncode=returncode, messages=renderer.messages)
709
+
710
+
711
+ def parse_latex_log(log_path: Path) -> list[LatexMessage]:
712
+ """Parse a LaTeX log file into structured messages."""
713
+ if not log_path.exists():
714
+ return []
715
+ parser = LatexLogParser()
716
+ with log_path.open("r", encoding="utf-8", errors="replace") as handle:
717
+ for line in handle:
718
+ parser.process_line(line)
719
+ parser.finalize()
720
+ return list(parser.messages)