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.
- texsmith/__init__.py +107 -0
- texsmith/_alias.py +59 -0
- texsmith/adapters/__init__.py +6 -0
- texsmith/adapters/docker.py +258 -0
- texsmith/adapters/handlers/__init__.py +3 -0
- texsmith/adapters/handlers/_assets.py +363 -0
- texsmith/adapters/handlers/_helpers.py +62 -0
- texsmith/adapters/handlers/_mermaid.py +122 -0
- texsmith/adapters/handlers/admonitions.py +267 -0
- texsmith/adapters/handlers/basic.py +228 -0
- texsmith/adapters/handlers/blocks.py +851 -0
- texsmith/adapters/handlers/code.py +309 -0
- texsmith/adapters/handlers/inline.py +937 -0
- texsmith/adapters/handlers/links.py +239 -0
- texsmith/adapters/handlers/media.py +380 -0
- texsmith/adapters/latex/__init__.py +10 -0
- texsmith/adapters/latex/engines/__init__.py +726 -0
- texsmith/adapters/latex/engines/latex/__init__.py +26 -0
- texsmith/adapters/latex/engines/latex/log.py +720 -0
- texsmith/adapters/latex/engines/latex/runner.py +28 -0
- texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
- texsmith/adapters/latex/formatter.py +297 -0
- texsmith/adapters/latex/latexmk.py +148 -0
- texsmith/adapters/latex/partials/acronym.tex +1 -0
- texsmith/adapters/latex/partials/add.tex +1 -0
- texsmith/adapters/latex/partials/addition.tex +1 -0
- texsmith/adapters/latex/partials/blockquote.tex +3 -0
- texsmith/adapters/latex/partials/callout.tex +3 -0
- texsmith/adapters/latex/partials/choices.tex +5 -0
- texsmith/adapters/latex/partials/citation.tex +1 -0
- texsmith/adapters/latex/partials/codeblock.tex +7 -0
- texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
- texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
- texsmith/adapters/latex/partials/codeinline.tex +1 -0
- texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
- texsmith/adapters/latex/partials/comment.tex +1 -0
- texsmith/adapters/latex/partials/del.tex +1 -0
- texsmith/adapters/latex/partials/deletion.tex +1 -0
- texsmith/adapters/latex/partials/description_list.tex +5 -0
- texsmith/adapters/latex/partials/enquote.tex +1 -0
- texsmith/adapters/latex/partials/epigraph.tex +1 -0
- texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
- texsmith/adapters/latex/partials/figure.tex +33 -0
- texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
- texsmith/adapters/latex/partials/footnote.tex +3 -0
- texsmith/adapters/latex/partials/glossary.tex +1 -0
- texsmith/adapters/latex/partials/heading.tex +14 -0
- texsmith/adapters/latex/partials/highlight.tex +25 -0
- texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
- texsmith/adapters/latex/partials/href.tex +1 -0
- texsmith/adapters/latex/partials/icon.tex +1 -0
- texsmith/adapters/latex/partials/include.tex +1 -0
- texsmith/adapters/latex/partials/index.tex +1 -0
- texsmith/adapters/latex/partials/italic.tex +1 -0
- texsmith/adapters/latex/partials/keystroke.tex +46 -0
- texsmith/adapters/latex/partials/label.tex +1 -0
- texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
- texsmith/adapters/latex/partials/list_glossary.tex +8 -0
- texsmith/adapters/latex/partials/multicolumn.tex +3 -0
- texsmith/adapters/latex/partials/ordered_list.tex +5 -0
- texsmith/adapters/latex/partials/pagestyle.tex +1 -0
- texsmith/adapters/latex/partials/ref.tex +1 -0
- texsmith/adapters/latex/partials/regex.tex +1 -0
- texsmith/adapters/latex/partials/smallcaps.tex +1 -0
- texsmith/adapters/latex/partials/strikethrough.tex +1 -0
- texsmith/adapters/latex/partials/strong.tex +1 -0
- texsmith/adapters/latex/partials/subscript.tex +1 -0
- texsmith/adapters/latex/partials/substitution.tex +1 -0
- texsmith/adapters/latex/partials/superscript.tex +1 -0
- texsmith/adapters/latex/partials/tabbed.tex +3 -0
- texsmith/adapters/latex/partials/table.tex +48 -0
- texsmith/adapters/latex/partials/underline.tex +1 -0
- texsmith/adapters/latex/partials/unordered_list.tex +5 -0
- texsmith/adapters/latex/partials/url.tex +1 -0
- texsmith/adapters/latex/pygments.py +98 -0
- texsmith/adapters/latex/pyxindy.py +64 -0
- texsmith/adapters/latex/renderer.py +220 -0
- texsmith/adapters/latex/tectonic.py +366 -0
- texsmith/adapters/latex/utils.py +86 -0
- texsmith/adapters/markdown/__init__.py +342 -0
- texsmith/adapters/plugins/__init__.py +8 -0
- texsmith/adapters/plugins/material.py +174 -0
- texsmith/adapters/plugins/snippet.py +1734 -0
- texsmith/adapters/transformers/__init__.py +122 -0
- texsmith/adapters/transformers/base.py +140 -0
- texsmith/adapters/transformers/strategies.py +1456 -0
- texsmith/adapters/transformers/utils.py +59 -0
- texsmith/api/__init__.py +80 -0
- texsmith/api/_utils.py +56 -0
- texsmith/api/document.py +645 -0
- texsmith/api/pipeline.py +229 -0
- texsmith/api/service.py +648 -0
- texsmith/api/templates.py +287 -0
- texsmith/core/__init__.py +6 -0
- texsmith/core/bibliography/__init__.py +57 -0
- texsmith/core/bibliography/collection.py +354 -0
- texsmith/core/bibliography/doi.py +194 -0
- texsmith/core/bibliography/issues.py +15 -0
- texsmith/core/bibliography/parsing.py +45 -0
- texsmith/core/callouts.py +99 -0
- texsmith/core/config.py +191 -0
- texsmith/core/context.py +242 -0
- texsmith/core/conversion/__init__.py +103 -0
- texsmith/core/conversion/core.py +780 -0
- texsmith/core/conversion/debug.py +87 -0
- texsmith/core/conversion/inputs.py +474 -0
- texsmith/core/conversion/renderer.py +578 -0
- texsmith/core/conversion/templates.py +646 -0
- texsmith/core/conversion_contexts.py +95 -0
- texsmith/core/diagnostics.py +118 -0
- texsmith/core/exceptions.py +41 -0
- texsmith/core/fonts/__init__.py +3 -0
- texsmith/core/fragments/__init__.py +716 -0
- texsmith/core/fragments/base.py +117 -0
- texsmith/core/metadata.py +280 -0
- texsmith/core/mustache.py +86 -0
- texsmith/core/partials.py +20 -0
- texsmith/core/rules.py +394 -0
- texsmith/core/templates/__init__.py +58 -0
- texsmith/core/templates/base.py +343 -0
- texsmith/core/templates/builtins.py +68 -0
- texsmith/core/templates/context_usage.py +137 -0
- texsmith/core/templates/loader.py +303 -0
- texsmith/core/templates/manifest.py +861 -0
- texsmith/core/templates/runtime.py +347 -0
- texsmith/core/templates/text.py +14 -0
- texsmith/core/templates/wrapper.py +340 -0
- texsmith/core/user_dir.py +179 -0
- texsmith/devtools.py +28 -0
- texsmith/extensions/__init__.py +162 -0
- texsmith/extensions/index/__init__.py +21 -0
- texsmith/extensions/index/markdown.py +94 -0
- texsmith/extensions/index/mkdocs_plugin.py +136 -0
- texsmith/extensions/index/registry.py +57 -0
- texsmith/extensions/index/renderer.py +183 -0
- texsmith/extensions/index/templates/index.tex +1 -0
- texsmith/extensions/latex_raw.py +115 -0
- texsmith/extensions/latex_text.py +117 -0
- texsmith/extensions/mermaid.py +267 -0
- texsmith/extensions/missing_footnotes.py +125 -0
- texsmith/extensions/multi_citations.py +54 -0
- texsmith/extensions/progressbar/__init__.py +9 -0
- texsmith/extensions/progressbar/markdown.py +212 -0
- texsmith/extensions/progressbar/renderer.py +117 -0
- texsmith/extensions/smallcaps.py +48 -0
- texsmith/extensions/texlogos/__init__.py +10 -0
- texsmith/extensions/texlogos/markdown.py +236 -0
- texsmith/extensions/texlogos/renderer.py +81 -0
- texsmith/extensions/texlogos/specs.py +66 -0
- texsmith/fonts/__init__.py +57 -0
- texsmith/fonts/cache.py +46 -0
- texsmith/fonts/constants.py +60 -0
- texsmith/fonts/coverage.py +275 -0
- texsmith/fonts/downloader.py +103 -0
- texsmith/fonts/fallback.py +438 -0
- texsmith/fonts/html_scripts.py +168 -0
- texsmith/fonts/logging.py +127 -0
- texsmith/fonts/pipeline.py +338 -0
- texsmith/fonts/scripts.py +493 -0
- texsmith/fonts/ucharclasses.py +164 -0
- texsmith/fragments/__init__.py +11 -0
- texsmith/fragments/bibliography/__init__.py +65 -0
- texsmith/fragments/bibliography/fragment.toml +3 -0
- texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
- texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
- texsmith/fragments/callouts/__init__.py +88 -0
- texsmith/fragments/callouts/fragment.toml +3 -0
- texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
- texsmith/fragments/code/__init__.py +82 -0
- texsmith/fragments/code/fragment.toml +3 -0
- texsmith/fragments/code/ts-code.jinja.sty +140 -0
- texsmith/fragments/extra/__init__.py +180 -0
- texsmith/fragments/extra/fragment.toml +3 -0
- texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
- texsmith/fragments/fonts/__init__.py +880 -0
- texsmith/fragments/fonts/fragment.toml +3 -0
- texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
- texsmith/fragments/frame/__init__.py +170 -0
- texsmith/fragments/frame/fragment.toml +3 -0
- texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
- texsmith/fragments/geometry/__init__.py +169 -0
- texsmith/fragments/geometry/fragment.toml +3 -0
- texsmith/fragments/geometry/paper.py +526 -0
- texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
- texsmith/fragments/glossary/__init__.py +67 -0
- texsmith/fragments/glossary/fragment.toml +3 -0
- texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
- texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
- texsmith/fragments/index/__init__.py +66 -0
- texsmith/fragments/index/fragment.toml +3 -0
- texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
- texsmith/fragments/index/ts-index.jinja.sty +12 -0
- texsmith/fragments/keystrokes/__init__.py +69 -0
- texsmith/fragments/keystrokes/fragment.toml +3 -0
- texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
- texsmith/fragments/todolist/__init__.py +71 -0
- texsmith/fragments/todolist/fragment.toml +3 -0
- texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
- texsmith/fragments/typesetting/__init__.py +214 -0
- texsmith/fragments/typesetting/fragment.toml +3 -0
- texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
- texsmith/index.py +26 -0
- texsmith/plugins/__init__.py +14 -0
- texsmith/progressbar.py +8 -0
- texsmith/quotes.py +40 -0
- texsmith/smart_dashes.py +66 -0
- texsmith/templates/__init__.py +3 -0
- texsmith/templates/article/README.md +33 -0
- texsmith/templates/article/__init__.py +281 -0
- texsmith/templates/article/template/manifest.toml +125 -0
- texsmith/templates/article/template/mermaid-config.json +18 -0
- texsmith/templates/article/template/template.tex +74 -0
- texsmith/templates/book/README.md +26 -0
- texsmith/templates/book/__init__.py +75 -0
- texsmith/templates/book/overrides/codeblock.tex +7 -0
- texsmith/templates/book/overrides/codeinline.tex +1 -0
- texsmith/templates/book/template/fixtoc.sty +81 -0
- texsmith/templates/book/template/manifest.toml +198 -0
- texsmith/templates/book/template/template.tex +314 -0
- texsmith/templates/common/__init__.py +1 -0
- texsmith/templates/common/latexmkrc +46 -0
- texsmith/templates/letter/README.md +59 -0
- texsmith/templates/letter/__init__.py +495 -0
- texsmith/templates/letter/demo.md +31 -0
- texsmith/templates/letter/fonts/modernline bold.otf +0 -0
- texsmith/templates/letter/fonts/modernline.otf +0 -0
- texsmith/templates/letter/manifest.toml +197 -0
- texsmith/templates/letter/template/callouts.jinja.sty +290 -0
- texsmith/templates/letter/template/template.tex +123 -0
- texsmith/templates/snippet/README.md +21 -0
- texsmith/templates/snippet/__init__.py +80 -0
- texsmith/templates/snippet/template/manifest.toml +66 -0
- texsmith/templates/snippet/template/template.tex +47 -0
- texsmith/texlogos.py +15 -0
- texsmith/ui/__init__.py +6 -0
- texsmith/ui/cli/__init__.py +22 -0
- texsmith/ui/cli/_options.py +338 -0
- texsmith/ui/cli/app.py +65 -0
- texsmith/ui/cli/bibliography.py +300 -0
- texsmith/ui/cli/commands/__init__.py +14 -0
- texsmith/ui/cli/commands/render.py +1128 -0
- texsmith/ui/cli/commands/templates.py +397 -0
- texsmith/ui/cli/diagnostics.py +36 -0
- texsmith/ui/cli/presenter.py +663 -0
- texsmith/ui/cli/state.py +263 -0
- texsmith/ui/cli/utils.py +235 -0
- texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
- texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
- texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
- texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
- 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)
|