texmini 0.2.0__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.
- texmini/__init__.py +3 -0
- texmini/cli.py +1078 -0
- texmini-0.2.0.dist-info/METADATA +258 -0
- texmini-0.2.0.dist-info/RECORD +8 -0
- texmini-0.2.0.dist-info/WHEEL +5 -0
- texmini-0.2.0.dist-info/entry_points.txt +2 -0
- texmini-0.2.0.dist-info/licenses/LICENSE +21 -0
- texmini-0.2.0.dist-info/top_level.txt +1 -0
texmini/__init__.py
ADDED
texmini/cli.py
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from time import monotonic
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from texmini import __version__
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import subprocess
|
|
12
|
+
import tarfile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ENGINE_ARGS = {
|
|
16
|
+
"pdflatex": ["-pdf"],
|
|
17
|
+
"lualatex": ["-lualatex"],
|
|
18
|
+
"xelatex": ["-xelatex"],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
AUX_EXTENSIONS = [
|
|
22
|
+
"aux",
|
|
23
|
+
"bbl",
|
|
24
|
+
"bcf",
|
|
25
|
+
"bcf-SAVE-ERROR",
|
|
26
|
+
"blg",
|
|
27
|
+
"fls",
|
|
28
|
+
"fdb_latexmk",
|
|
29
|
+
"log",
|
|
30
|
+
"nav",
|
|
31
|
+
"out",
|
|
32
|
+
"snm",
|
|
33
|
+
"toc",
|
|
34
|
+
"vrb",
|
|
35
|
+
"run.xml",
|
|
36
|
+
]
|
|
37
|
+
FIXED_AUXILIARY_FILES = ["missfont.log"]
|
|
38
|
+
|
|
39
|
+
TINYTEX_RELEASE_API = "https://api.github.com/repos/rstudio/tinytex-releases/releases/latest"
|
|
40
|
+
DEFAULT_TINYTEX_BUNDLE = "TinyTeX-0"
|
|
41
|
+
TINYTEX_BOOTSTRAP_PACKAGES = ["latex-bin", "latexmk", "metafont", "mfware"]
|
|
42
|
+
TINYTEX_ENGINE_PACKAGES = {"xelatex": "xetex"}
|
|
43
|
+
MAX_INSTALL_ROUNDS = 20
|
|
44
|
+
MISSING_FILE_EXTENSIONS = "sty|cls|bst|bbx|cbx|def|fd|map|tfm|pfb|otf|ttf|enc|cfg"
|
|
45
|
+
COMMON_TEXLIVE_FILE_PACKAGES = {
|
|
46
|
+
"amsmath.sty": "amsmath",
|
|
47
|
+
"authoryear.bbx": "biblatex",
|
|
48
|
+
"authoryear-comp.bbx": "biblatex",
|
|
49
|
+
"authoryear-comp.cbx": "biblatex",
|
|
50
|
+
"biblatex.sty": "biblatex",
|
|
51
|
+
"csquotes.sty": "csquotes",
|
|
52
|
+
"framed.sty": "framed",
|
|
53
|
+
"geometry.sty": "geometry",
|
|
54
|
+
"graphicx.sty": "graphics",
|
|
55
|
+
"hyperref.sty": "hyperref",
|
|
56
|
+
"memoir.cls": "memoir",
|
|
57
|
+
"numeric.cbx": "biblatex",
|
|
58
|
+
"pgf.sty": "pgf",
|
|
59
|
+
"plainnat.bst": "natbib",
|
|
60
|
+
"tikz.sty": "pgf",
|
|
61
|
+
"unsrtnat.bst": "natbib",
|
|
62
|
+
"xcolor.sty": "xcolor",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TexMiniError(Exception):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class PrimaryError:
|
|
72
|
+
message: str
|
|
73
|
+
file: str | None = None
|
|
74
|
+
line: int | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class BuildOutcome:
|
|
79
|
+
returncode: int
|
|
80
|
+
elapsed_seconds: float
|
|
81
|
+
pdf_changed: bool
|
|
82
|
+
failure_kind: str | None = None
|
|
83
|
+
missing_files: tuple[str, ...] = ()
|
|
84
|
+
unmapped_files: tuple[str, ...] = ()
|
|
85
|
+
primary_error: PrimaryError | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Reporter:
|
|
89
|
+
def __init__(self, verbose: bool = False) -> None:
|
|
90
|
+
self.verbose = verbose
|
|
91
|
+
self._gpg_warning_printed = False
|
|
92
|
+
|
|
93
|
+
def status(self, message: str) -> None:
|
|
94
|
+
print(message, flush=True)
|
|
95
|
+
|
|
96
|
+
def warning(self, message: str) -> None:
|
|
97
|
+
print(message, file=sys.stderr, flush=True)
|
|
98
|
+
|
|
99
|
+
def error(self, message: str) -> None:
|
|
100
|
+
print(message, file=sys.stderr, flush=True)
|
|
101
|
+
|
|
102
|
+
def observe_output(self, output: str) -> None:
|
|
103
|
+
if self.verbose or self._gpg_warning_printed:
|
|
104
|
+
return
|
|
105
|
+
if "not verified: gpg unavailable" in output.lower():
|
|
106
|
+
self.warning("Warning: TeX Live could not verify repository signatures because GPG is unavailable.")
|
|
107
|
+
self.warning("Use --verbose for details.")
|
|
108
|
+
self._gpg_warning_printed = True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_missing_file_patterns = None
|
|
112
|
+
_biblatex_style_patterns = None
|
|
113
|
+
_source_patterns = None
|
|
114
|
+
_source_cache: dict[str, tuple[int, int, str]] = {}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def run_command(args: list[str], reporter: Reporter | None = None, **kwargs: object):
|
|
118
|
+
import subprocess
|
|
119
|
+
|
|
120
|
+
if reporter is None:
|
|
121
|
+
return subprocess.run(args, **kwargs)
|
|
122
|
+
|
|
123
|
+
options = dict(kwargs)
|
|
124
|
+
options.pop("stdout", None)
|
|
125
|
+
options.pop("stderr", None)
|
|
126
|
+
options.pop("check", None)
|
|
127
|
+
options["stdout"] = subprocess.PIPE
|
|
128
|
+
options["stderr"] = subprocess.STDOUT
|
|
129
|
+
options["text"] = True
|
|
130
|
+
if reporter.verbose:
|
|
131
|
+
process = subprocess.Popen(args, **options)
|
|
132
|
+
output_parts: list[str] = []
|
|
133
|
+
assert process.stdout is not None
|
|
134
|
+
for line in process.stdout:
|
|
135
|
+
output_parts.append(line)
|
|
136
|
+
print(line, end="", flush=True)
|
|
137
|
+
return subprocess.CompletedProcess(args, process.wait(), "".join(output_parts), None)
|
|
138
|
+
|
|
139
|
+
result = subprocess.run(args, check=False, **options)
|
|
140
|
+
reporter.observe_output(result.stdout or "")
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def read_source_file(path: str) -> str:
|
|
145
|
+
cache_key = os.path.abspath(os.fspath(path))
|
|
146
|
+
stat_result = os.stat(path)
|
|
147
|
+
cached = _source_cache.get(cache_key)
|
|
148
|
+
if cached is None or cached[0] != stat_result.st_mtime_ns or cached[1] != stat_result.st_size:
|
|
149
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
150
|
+
source = handle.read()
|
|
151
|
+
_source_cache[cache_key] = (stat_result.st_mtime_ns, stat_result.st_size, source)
|
|
152
|
+
return source
|
|
153
|
+
return cached[2]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def missing_file_patterns():
|
|
157
|
+
import re
|
|
158
|
+
|
|
159
|
+
global _missing_file_patterns
|
|
160
|
+
if _missing_file_patterns is None:
|
|
161
|
+
_missing_file_patterns = [
|
|
162
|
+
re.compile(rf"File\s+[`'\"]([^`'\"]+\.({MISSING_FILE_EXTENSIONS}))['\"]\s+not found", re.IGNORECASE),
|
|
163
|
+
re.compile(
|
|
164
|
+
rf"I\s+(?:can't|cannot|couldn't|could not)\s+find\s+file\s+[`'\"]?([^`'\"\s]+\.({MISSING_FILE_EXTENSIONS}))",
|
|
165
|
+
re.IGNORECASE,
|
|
166
|
+
),
|
|
167
|
+
re.compile(r"I couldn't open style file\s+([^`'\"\s]+\.bst)\b", re.IGNORECASE),
|
|
168
|
+
re.compile(r"mktextfm\s+([A-Za-z0-9_.-]+)"),
|
|
169
|
+
re.compile(r"Font .*=([A-Za-z0-9_.-]+).*Metric \(TFM\) file not found", re.IGNORECASE),
|
|
170
|
+
re.compile(r"pdfTeX error:.*?\(file\s+([A-Za-z0-9_.-]+)\):\s+Font\b[^\n]*\bnot found", re.IGNORECASE),
|
|
171
|
+
]
|
|
172
|
+
return _missing_file_patterns
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def biblatex_style_patterns():
|
|
176
|
+
import re
|
|
177
|
+
|
|
178
|
+
global _biblatex_style_patterns
|
|
179
|
+
if _biblatex_style_patterns is None:
|
|
180
|
+
_biblatex_style_patterns = (
|
|
181
|
+
re.compile(
|
|
182
|
+
r"Package biblatex Info:\s+Trying to load (bibliography|citation) style [`'\"]([^`'\"]+)['\"]",
|
|
183
|
+
re.IGNORECASE,
|
|
184
|
+
),
|
|
185
|
+
re.compile(
|
|
186
|
+
r"Package biblatex Error:\s+Style [`'\"]([^`'\"]+)['\"]\s+not found",
|
|
187
|
+
re.IGNORECASE,
|
|
188
|
+
),
|
|
189
|
+
)
|
|
190
|
+
return _biblatex_style_patterns
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def source_patterns():
|
|
194
|
+
import re
|
|
195
|
+
|
|
196
|
+
global _source_patterns
|
|
197
|
+
if _source_patterns is None:
|
|
198
|
+
_source_patterns = (
|
|
199
|
+
re.compile(r"\\usepackage(?:\[[^\]]*\])?\{[^}]*\bbiblatex\b[^}]*\}"),
|
|
200
|
+
re.compile(r"\\documentclass(?:\[[^\]]*\])?\{([^}]+)\}"),
|
|
201
|
+
re.compile(r"\\(?:usepackage|RequirePackage)(?:\[[^\]]*\])?\{([^}]+)\}"),
|
|
202
|
+
re.compile(r"^[A-Za-z0-9_.+-]+\.(sty|cls)$"),
|
|
203
|
+
)
|
|
204
|
+
return _source_patterns
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def source_uses_bibliography(source: str) -> bool:
|
|
208
|
+
if "\\bibliography{" in source or "\\addbibresource{" in source:
|
|
209
|
+
return True
|
|
210
|
+
if "biblatex" not in source or "\\usepackage" not in source:
|
|
211
|
+
return False
|
|
212
|
+
biblatex_package_pattern, _, _, _ = source_patterns()
|
|
213
|
+
return bool(biblatex_package_pattern.search(source))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def print_help() -> None:
|
|
217
|
+
print(
|
|
218
|
+
"""Usage: texmini [install-tinytex] [--engine pdflatex|lualatex|xelatex] [OPTIONS] [document.tex] [refs.bib ...]
|
|
219
|
+
|
|
220
|
+
Compile a LaTeX document with a private TinyTeX runtime.
|
|
221
|
+
|
|
222
|
+
Options:
|
|
223
|
+
--engine ENGINE Select pdflatex, lualatex, or xelatex.
|
|
224
|
+
--clean Remove auxiliary files after a successful build.
|
|
225
|
+
--verbose Show complete TeX, latexmk, and package-manager output.
|
|
226
|
+
--no-install Do not install missing TeX Live packages.
|
|
227
|
+
--version Print the texMini version.
|
|
228
|
+
|
|
229
|
+
All other arguments are passed through to latexmk."""
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_args(argv: list[str]) -> tuple[str, bool, bool, bool, list[str], list[str], str | None]:
|
|
234
|
+
engine = os.environ.get("TEXMINI_ENGINE", "pdflatex")
|
|
235
|
+
clean = os.environ.get("TEXMINI_CLEAN", "false").lower() == "true"
|
|
236
|
+
verbose = False
|
|
237
|
+
auto_install = os.environ.get("TEXMINI_AUTO_INSTALL", "true").lower() != "false"
|
|
238
|
+
latexmk_args: list[str] = []
|
|
239
|
+
bib_files: list[str] = []
|
|
240
|
+
tex_file: str | None = None
|
|
241
|
+
|
|
242
|
+
i = 0
|
|
243
|
+
while i < len(argv):
|
|
244
|
+
arg = argv[i]
|
|
245
|
+
if arg == "--backend" or arg.startswith("--backend="):
|
|
246
|
+
raise TexMiniError("Error: --backend is no longer supported; texMini always uses managed TinyTeX.")
|
|
247
|
+
if arg == "--engine":
|
|
248
|
+
if i + 1 >= len(argv):
|
|
249
|
+
raise TexMiniError("Error: --engine requires pdflatex, lualatex, or xelatex.")
|
|
250
|
+
engine = argv[i + 1]
|
|
251
|
+
i += 2
|
|
252
|
+
continue
|
|
253
|
+
if arg.startswith("--engine="):
|
|
254
|
+
engine = arg.split("=", 1)[1]
|
|
255
|
+
i += 1
|
|
256
|
+
continue
|
|
257
|
+
if arg == "--clean":
|
|
258
|
+
clean = True
|
|
259
|
+
i += 1
|
|
260
|
+
continue
|
|
261
|
+
if arg == "--verbose":
|
|
262
|
+
verbose = True
|
|
263
|
+
i += 1
|
|
264
|
+
continue
|
|
265
|
+
if arg == "--no-install":
|
|
266
|
+
auto_install = False
|
|
267
|
+
i += 1
|
|
268
|
+
continue
|
|
269
|
+
if arg == "-pvc":
|
|
270
|
+
raise TexMiniError("Error: -pvc is not supported by managed TinyTeX.")
|
|
271
|
+
if arg.endswith(".tex"):
|
|
272
|
+
if tex_file is not None:
|
|
273
|
+
raise TexMiniError(f"Error: Multiple .tex files specified: {tex_file} and {arg}")
|
|
274
|
+
tex_file = arg
|
|
275
|
+
latexmk_args.append(arg)
|
|
276
|
+
i += 1
|
|
277
|
+
continue
|
|
278
|
+
if arg.endswith(".bib"):
|
|
279
|
+
bib_files.append(arg)
|
|
280
|
+
i += 1
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
latexmk_args.append(arg)
|
|
284
|
+
i += 1
|
|
285
|
+
|
|
286
|
+
if engine not in ENGINE_ARGS:
|
|
287
|
+
raise TexMiniError("Error: --engine must be pdflatex, lualatex, or xelatex.")
|
|
288
|
+
|
|
289
|
+
return engine, clean, verbose, auto_install, latexmk_args, bib_files, tex_file
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def executable_on_path(command: str) -> str | None:
|
|
293
|
+
for directory in os.environ.get("PATH", os.defpath).split(os.pathsep):
|
|
294
|
+
candidate = os.path.join(directory or ".", command)
|
|
295
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
296
|
+
return candidate
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def detect_tex_file(latexmk_args: list[str], tex_file: str | None, reporter: Reporter | None = None) -> str:
|
|
301
|
+
reporter = reporter or Reporter()
|
|
302
|
+
if tex_file is not None:
|
|
303
|
+
return tex_file
|
|
304
|
+
|
|
305
|
+
tex_files = sorted(entry.name for entry in os.scandir(os.getcwd()) if entry.is_file() and entry.name.endswith(".tex"))
|
|
306
|
+
if len(tex_files) == 1:
|
|
307
|
+
reporter.status(f"Auto-detected LaTeX file: {tex_files[0]}")
|
|
308
|
+
latexmk_args.append(tex_files[0])
|
|
309
|
+
return tex_files[0]
|
|
310
|
+
|
|
311
|
+
print("Error: No .tex file specified and unable to auto-detect.")
|
|
312
|
+
if not tex_files:
|
|
313
|
+
print("No .tex files found in current directory.")
|
|
314
|
+
else:
|
|
315
|
+
print(f"Multiple .tex files found: {' '.join(tex_files)}")
|
|
316
|
+
print("Please specify which file to compile.")
|
|
317
|
+
raise SystemExit(1)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def check_bibliography(tex_file: str, bib_files: list[str], reporter: Reporter | None = None) -> None:
|
|
321
|
+
reporter = reporter or Reporter()
|
|
322
|
+
tex_path = os.fspath(tex_file)
|
|
323
|
+
if not os.path.isfile(tex_path):
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
source = read_source_file(tex_path)
|
|
327
|
+
if not source_uses_bibliography(source):
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
if bib_files:
|
|
331
|
+
for bib_file in bib_files:
|
|
332
|
+
if not os.path.isfile(bib_file):
|
|
333
|
+
raise TexMiniError(f"Error: Specified bibliography file '{bib_file}' not found")
|
|
334
|
+
if bib_file not in source:
|
|
335
|
+
reporter.warning(f"Warning: Bibliography file {bib_file} is not referenced in {tex_file}.")
|
|
336
|
+
reporter.warning(f"You may need to add \\addbibresource{{{bib_file}}} to your document.")
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
detected_bib_files = sorted(entry.name for entry in os.scandir(os.getcwd()) if entry.is_file() and entry.name.endswith(".bib"))
|
|
340
|
+
if len(detected_bib_files) == 1:
|
|
341
|
+
bib_file = detected_bib_files[0]
|
|
342
|
+
if bib_file not in source:
|
|
343
|
+
reporter.warning(f"Warning: Bibliography file {bib_file} is not referenced in {tex_file}.")
|
|
344
|
+
reporter.warning(f"You may need to add \\addbibresource{{{bib_file}}} to your document.")
|
|
345
|
+
elif not detected_bib_files:
|
|
346
|
+
reporter.warning(f"Warning: Bibliography commands were found in {tex_file}, but no .bib files were found.")
|
|
347
|
+
else:
|
|
348
|
+
reporter.warning(f"Warning: Multiple bibliography files found: {' '.join(detected_bib_files)}")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def cleanup_auxiliary_files(tex_file: str) -> None:
|
|
352
|
+
base, _ = os.path.splitext(os.fspath(tex_file))
|
|
353
|
+
for extension in AUX_EXTENSIONS:
|
|
354
|
+
try:
|
|
355
|
+
os.unlink(f"{base}.{extension}")
|
|
356
|
+
except FileNotFoundError:
|
|
357
|
+
pass
|
|
358
|
+
for path in FIXED_AUXILIARY_FILES:
|
|
359
|
+
try:
|
|
360
|
+
os.unlink(path)
|
|
361
|
+
except FileNotFoundError:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def tinytex_root() -> "Path":
|
|
366
|
+
return Path(os.environ.get("TEXMINI_TINYTEX_ROOT", Path.home() / ".texmini" / "TinyTeX"))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def package_map_path() -> "Path":
|
|
370
|
+
return Path(os.environ.get("TEXMINI_PACKAGE_MAP", Path.home() / ".texmini" / "package-map.json"))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def display_path(path: Path) -> str:
|
|
374
|
+
try:
|
|
375
|
+
return os.fspath(Path("~") / path.relative_to(Path.home()))
|
|
376
|
+
except ValueError:
|
|
377
|
+
return os.fspath(path)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def tinytex_bin_dir(root: "Path", executable: str = "latexmk") -> "Path":
|
|
381
|
+
bin_root = root / "bin"
|
|
382
|
+
for path in sorted(bin_root.iterdir() if bin_root.exists() else []):
|
|
383
|
+
if (path / executable).exists():
|
|
384
|
+
return path
|
|
385
|
+
raise TexMiniError(f"Error: TinyTeX does not provide {executable} at {root}. Run: texmini install-tinytex")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def tinytex_env(root: "Path", executable: str = "latexmk") -> dict[str, str]:
|
|
389
|
+
env = os.environ.copy()
|
|
390
|
+
bin_dir = tinytex_bin_dir(root, executable)
|
|
391
|
+
env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}"
|
|
392
|
+
return env
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def tinytex_bundle() -> str:
|
|
396
|
+
return os.environ.get("TEXMINI_TINYTEX_BUNDLE", DEFAULT_TINYTEX_BUNDLE)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def tinytex_platform_key() -> str:
|
|
400
|
+
import platform
|
|
401
|
+
|
|
402
|
+
if sys.platform == "darwin":
|
|
403
|
+
return "darwin"
|
|
404
|
+
if sys.platform.startswith("linux"):
|
|
405
|
+
machine = platform.machine().lower()
|
|
406
|
+
if machine in {"aarch64", "arm64"}:
|
|
407
|
+
return "linux-arm64"
|
|
408
|
+
libc = platform.libc_ver()[0].lower()
|
|
409
|
+
return "linuxmusl-x86_64" if libc == "musl" else "linux-x86_64"
|
|
410
|
+
raise TexMiniError("Error: The Python TinyTeX installer currently supports macOS and Linux.")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def latest_tinytex_asset() -> tuple[str, str, str | None]:
|
|
414
|
+
import json
|
|
415
|
+
import urllib.request
|
|
416
|
+
|
|
417
|
+
bundle = tinytex_bundle()
|
|
418
|
+
prefix = f"{bundle}-{tinytex_platform_key()}-"
|
|
419
|
+
request = urllib.request.Request(
|
|
420
|
+
TINYTEX_RELEASE_API,
|
|
421
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": f"texmini/{__version__}"},
|
|
422
|
+
)
|
|
423
|
+
if github_token := os.environ.get("GITHUB_TOKEN"):
|
|
424
|
+
request.add_header("Authorization", f"Bearer {github_token}")
|
|
425
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
426
|
+
release = json.load(response)
|
|
427
|
+
for asset in release["assets"]:
|
|
428
|
+
name = asset["name"]
|
|
429
|
+
if name.startswith(prefix) and name.endswith(".tar.xz"):
|
|
430
|
+
return name, asset["browser_download_url"], asset.get("digest")
|
|
431
|
+
raise TexMiniError(f"Error: No {bundle} TinyTeX archive found for this platform.")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def update_tinytex_manager(root: "Path", reporter: Reporter) -> None:
|
|
435
|
+
env = tinytex_env(root, "tlmgr")
|
|
436
|
+
if reporter.verbose:
|
|
437
|
+
reporter.status("Updating the managed TinyTeX package manager...")
|
|
438
|
+
update_result = run_command(["tlmgr", "update", "--self"], reporter=reporter, env=env, check=False)
|
|
439
|
+
if update_result.returncode != 0:
|
|
440
|
+
raise TexMiniError("Error: TinyTeX package manager bootstrap failed.")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def bootstrap_tinytex(root: "Path", reporter: Reporter) -> None:
|
|
444
|
+
update_tinytex_manager(root, reporter)
|
|
445
|
+
env = tinytex_env(root, "tlmgr")
|
|
446
|
+
reporter.status("Installing the LaTeX compiler...")
|
|
447
|
+
install_result = run_command(
|
|
448
|
+
["tlmgr", "install", *TINYTEX_BOOTSTRAP_PACKAGES], reporter=reporter, env=env, check=False
|
|
449
|
+
)
|
|
450
|
+
if install_result.returncode != 0:
|
|
451
|
+
raise TexMiniError("Error: TinyTeX bootstrap package installation failed.")
|
|
452
|
+
tinytex_bin_dir(root)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def validate_tinytex_archive_member(member: "tarfile.TarInfo") -> None:
|
|
456
|
+
import posixpath
|
|
457
|
+
|
|
458
|
+
path = member.name.replace("\\", "/")
|
|
459
|
+
normalized_path = posixpath.normpath(path)
|
|
460
|
+
|
|
461
|
+
def is_managed_path(candidate: str) -> bool:
|
|
462
|
+
return any(
|
|
463
|
+
candidate == root_name or candidate.startswith(f"{root_name}/")
|
|
464
|
+
for root_name in ("TinyTeX", ".TinyTeX")
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
if "\0" in path or posixpath.isabs(path) or not is_managed_path(normalized_path):
|
|
468
|
+
raise TexMiniError(f"Error: Unsafe path in TinyTeX archive: {member.name}")
|
|
469
|
+
if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
|
|
470
|
+
raise TexMiniError(f"Error: Unsupported entry in TinyTeX archive: {member.name}")
|
|
471
|
+
if member.issym() or member.islnk():
|
|
472
|
+
link_path = member.linkname.replace("\\", "/")
|
|
473
|
+
target = (
|
|
474
|
+
posixpath.normpath(posixpath.join(posixpath.dirname(normalized_path), link_path))
|
|
475
|
+
if member.issym()
|
|
476
|
+
else posixpath.normpath(link_path)
|
|
477
|
+
)
|
|
478
|
+
if posixpath.isabs(link_path) or not is_managed_path(target):
|
|
479
|
+
raise TexMiniError(f"Error: Unsafe link in TinyTeX archive: {member.name} -> {member.linkname}")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def install_tinytex_archive(root: "Path", reporter: Reporter | None = None) -> None:
|
|
483
|
+
import hashlib
|
|
484
|
+
import shutil
|
|
485
|
+
import tarfile
|
|
486
|
+
import tempfile
|
|
487
|
+
import urllib.request
|
|
488
|
+
|
|
489
|
+
reporter = reporter or Reporter()
|
|
490
|
+
|
|
491
|
+
if executable_on_path("perl") is None:
|
|
492
|
+
raise TexMiniError("Error: Perl is required to install and run TinyTeX. Install Perl and retry.")
|
|
493
|
+
|
|
494
|
+
if (root / "bin").exists():
|
|
495
|
+
if tinytex_bundle() == "TinyTeX-0":
|
|
496
|
+
try:
|
|
497
|
+
tinytex_bin_dir(root)
|
|
498
|
+
except TexMiniError:
|
|
499
|
+
bootstrap_tinytex(root, reporter)
|
|
500
|
+
else:
|
|
501
|
+
tinytex_bin_dir(root)
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
reporter.status(f"Preparing a private TinyTeX runtime in {display_path(root)}.")
|
|
505
|
+
reporter.status("This one-time setup requires a network connection and may take a minute.")
|
|
506
|
+
name, url, digest = latest_tinytex_asset()
|
|
507
|
+
root.parent.mkdir(parents=True, exist_ok=True)
|
|
508
|
+
with tempfile.TemporaryDirectory(prefix=".texmini-extract-", dir=root.parent) as temporary_directory:
|
|
509
|
+
extraction_root = Path(temporary_directory)
|
|
510
|
+
archive_path = extraction_root / name
|
|
511
|
+
if reporter.verbose:
|
|
512
|
+
reporter.status(f"Downloading {url}")
|
|
513
|
+
with urllib.request.urlopen(url, timeout=60) as response:
|
|
514
|
+
with archive_path.open("wb") as archive:
|
|
515
|
+
shutil.copyfileobj(response, archive)
|
|
516
|
+
if digest and digest.startswith("sha256:"):
|
|
517
|
+
expected = digest.removeprefix("sha256:")
|
|
518
|
+
checksum = hashlib.sha256()
|
|
519
|
+
with archive_path.open("rb") as archive:
|
|
520
|
+
for chunk in iter(lambda: archive.read(1024 * 1024), b""):
|
|
521
|
+
checksum.update(chunk)
|
|
522
|
+
actual = checksum.hexdigest()
|
|
523
|
+
if actual != expected:
|
|
524
|
+
raise TexMiniError(f"Error: Checksum verification failed for {name}.")
|
|
525
|
+
if reporter.verbose:
|
|
526
|
+
reporter.status(f"Verified SHA-256: {actual}")
|
|
527
|
+
reporter.status(f"Downloaded {tinytex_bundle()}.")
|
|
528
|
+
if reporter.verbose:
|
|
529
|
+
reporter.status(f"Extracting {name}...")
|
|
530
|
+
with tarfile.open(archive_path, mode="r:xz") as tar:
|
|
531
|
+
for member in tar:
|
|
532
|
+
validate_tinytex_archive_member(member)
|
|
533
|
+
tar.extract(member, extraction_root)
|
|
534
|
+
extracted_root = next(
|
|
535
|
+
(candidate for root_name in ("TinyTeX", ".TinyTeX") if (candidate := extraction_root / root_name).exists()),
|
|
536
|
+
None,
|
|
537
|
+
)
|
|
538
|
+
if extracted_root is None:
|
|
539
|
+
raise TexMiniError("Error: TinyTeX archive did not contain a TinyTeX runtime.")
|
|
540
|
+
extracted_root.rename(root)
|
|
541
|
+
if tinytex_bundle() == "TinyTeX-0":
|
|
542
|
+
bootstrap_tinytex(root, reporter)
|
|
543
|
+
else:
|
|
544
|
+
update_tinytex_manager(root, reporter)
|
|
545
|
+
tinytex_bin_dir(root)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def install_tinytex(verbose: bool = False) -> int:
|
|
549
|
+
reporter = Reporter(verbose)
|
|
550
|
+
try:
|
|
551
|
+
install_tinytex_archive(tinytex_root(), reporter)
|
|
552
|
+
return 0
|
|
553
|
+
except TexMiniError as error:
|
|
554
|
+
reporter.error(str(error))
|
|
555
|
+
return 1
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def tex_log_requirements(log_path: "Path") -> tuple[list[str], list[str]]:
|
|
559
|
+
log_file = os.fspath(log_path)
|
|
560
|
+
if not os.path.isfile(log_file):
|
|
561
|
+
return [], []
|
|
562
|
+
|
|
563
|
+
found: list[str] = []
|
|
564
|
+
seen: set[str] = set()
|
|
565
|
+
with open(log_file, encoding="utf-8", errors="replace") as handle:
|
|
566
|
+
source = handle.read()
|
|
567
|
+
def add_missing_file(missing_file: str) -> None:
|
|
568
|
+
if "." not in missing_file:
|
|
569
|
+
missing_file = f"{missing_file}.tfm"
|
|
570
|
+
if missing_file not in seen:
|
|
571
|
+
seen.add(missing_file)
|
|
572
|
+
found.append(missing_file)
|
|
573
|
+
|
|
574
|
+
for pattern in missing_file_patterns():
|
|
575
|
+
for match in pattern.finditer(source):
|
|
576
|
+
add_missing_file(match.group(1))
|
|
577
|
+
|
|
578
|
+
context_pattern, error_pattern = biblatex_style_patterns()
|
|
579
|
+
biblatex_context: dict[str, str] = {}
|
|
580
|
+
for line in source.splitlines():
|
|
581
|
+
context_match = context_pattern.search(line)
|
|
582
|
+
if context_match:
|
|
583
|
+
biblatex_context[context_match.group(2)] = context_match.group(1).lower()
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
error_match = error_pattern.search(line)
|
|
587
|
+
if not error_match:
|
|
588
|
+
continue
|
|
589
|
+
|
|
590
|
+
style = error_match.group(1)
|
|
591
|
+
context = biblatex_context.get(style)
|
|
592
|
+
if context == "bibliography":
|
|
593
|
+
add_missing_file(f"{style}.bbx")
|
|
594
|
+
elif context == "citation":
|
|
595
|
+
add_missing_file(f"{style}.cbx")
|
|
596
|
+
else:
|
|
597
|
+
add_missing_file(f"{style}.bbx")
|
|
598
|
+
add_missing_file(f"{style}.cbx")
|
|
599
|
+
direct_packages = ["biber"] if "Package biblatex Warning:" in source and "Please (re)run Biber" in source else []
|
|
600
|
+
return found, direct_packages
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def tex_source_requirements(tex_file: str) -> tuple[list[str], list[str]]:
|
|
604
|
+
tex_path = os.fspath(tex_file)
|
|
605
|
+
if not os.path.isfile(tex_path):
|
|
606
|
+
return [], []
|
|
607
|
+
|
|
608
|
+
source = read_source_file(tex_path)
|
|
609
|
+
found: list[str] = []
|
|
610
|
+
seen: set[str] = set()
|
|
611
|
+
biblatex_package_pattern, documentclass_pattern, package_pattern, package_file_pattern = source_patterns()
|
|
612
|
+
|
|
613
|
+
def add_file(name: str, extension: str) -> None:
|
|
614
|
+
file_name = f"{name.strip()}.{extension}"
|
|
615
|
+
if package_file_pattern.match(file_name) and file_name not in seen:
|
|
616
|
+
seen.add(file_name)
|
|
617
|
+
found.append(file_name)
|
|
618
|
+
|
|
619
|
+
for match in documentclass_pattern.finditer(source):
|
|
620
|
+
add_file(match.group(1), "cls")
|
|
621
|
+
for match in package_pattern.finditer(source):
|
|
622
|
+
for package in match.group(1).split(","):
|
|
623
|
+
add_file(package, "sty")
|
|
624
|
+
direct_packages = ["biber"] if biblatex_package_pattern.search(source) else []
|
|
625
|
+
return found, direct_packages
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def tex_source_package_files(tex_file: str) -> list[str]:
|
|
629
|
+
source_files, _ = tex_source_requirements(tex_file)
|
|
630
|
+
return source_files
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def missing_tinytex_source_files(
|
|
634
|
+
root: "Path",
|
|
635
|
+
tex_file: str,
|
|
636
|
+
env: dict[str, str] | None = None,
|
|
637
|
+
source_files: list[str] | None = None,
|
|
638
|
+
reporter: Reporter | None = None,
|
|
639
|
+
) -> list[str]:
|
|
640
|
+
env = tinytex_env(root) if env is None else env
|
|
641
|
+
source_files = tex_source_package_files(tex_file) if source_files is None else source_files
|
|
642
|
+
if not source_files:
|
|
643
|
+
return []
|
|
644
|
+
|
|
645
|
+
import subprocess
|
|
646
|
+
|
|
647
|
+
result = run_command(
|
|
648
|
+
["kpsewhich", *source_files],
|
|
649
|
+
reporter=reporter,
|
|
650
|
+
env=env,
|
|
651
|
+
stdout=subprocess.PIPE,
|
|
652
|
+
stderr=subprocess.DEVNULL,
|
|
653
|
+
text=True,
|
|
654
|
+
check=False,
|
|
655
|
+
)
|
|
656
|
+
found_files = {os.path.basename(line) for line in result.stdout.splitlines() if line}
|
|
657
|
+
return [file_name for file_name in source_files if file_name not in found_files]
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def load_package_map(path: "Path") -> dict[str, str]:
|
|
661
|
+
import json
|
|
662
|
+
|
|
663
|
+
if not path.is_file():
|
|
664
|
+
return {}
|
|
665
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
666
|
+
data = json.load(handle)
|
|
667
|
+
return {str(key): str(value) for key, value in data.items() if value}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def save_package_map(path: "Path", package_map: dict[str, str]) -> None:
|
|
671
|
+
import json
|
|
672
|
+
|
|
673
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
674
|
+
with path.open("w", encoding="utf-8") as handle:
|
|
675
|
+
json.dump(package_map, handle, indent=2, sort_keys=True)
|
|
676
|
+
handle.write("\n")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def package_from_tlmgr_search(output: str) -> str | None:
|
|
680
|
+
pending_package: str | None = None
|
|
681
|
+
for line in output.splitlines():
|
|
682
|
+
if pending_package and "texmf-dist/" in line:
|
|
683
|
+
return pending_package
|
|
684
|
+
package_name, separator, rest = line.partition(":")
|
|
685
|
+
if not separator or not package_name or not all(char.isalnum() or char in "_.+-" for char in package_name):
|
|
686
|
+
continue
|
|
687
|
+
if "texmf-dist/" in rest:
|
|
688
|
+
return package_name
|
|
689
|
+
if not rest.strip():
|
|
690
|
+
pending_package = package_name
|
|
691
|
+
continue
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def common_texlive_package_for_file(file_name: str) -> str | None:
|
|
696
|
+
if file_name.endswith(".tfm"):
|
|
697
|
+
stem = file_name[:-4]
|
|
698
|
+
if len(stem) > 4 and stem[0] in {"e", "t"} and stem[1:4] == "crm" and stem[4:].isdigit():
|
|
699
|
+
return "ec"
|
|
700
|
+
return COMMON_TEXLIVE_FILE_PACKAGES.get(file_name)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def resolve_tinytex_packages(
|
|
704
|
+
root: "Path",
|
|
705
|
+
missing_files: list[str],
|
|
706
|
+
cache_path: "Path | None" = None,
|
|
707
|
+
env: dict[str, str] | None = None,
|
|
708
|
+
reporter: Reporter | None = None,
|
|
709
|
+
) -> dict[str, str]:
|
|
710
|
+
env = tinytex_env(root) if env is None else env
|
|
711
|
+
cache_path = package_map_path() if cache_path is None else cache_path
|
|
712
|
+
package_map = load_package_map(cache_path)
|
|
713
|
+
resolved: dict[str, str] = {}
|
|
714
|
+
updated = False
|
|
715
|
+
|
|
716
|
+
for missing_file in dict.fromkeys(missing_files):
|
|
717
|
+
cached_package = package_map.get(missing_file)
|
|
718
|
+
if cached_package:
|
|
719
|
+
resolved[missing_file] = cached_package
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
built_in_package = common_texlive_package_for_file(missing_file)
|
|
723
|
+
if built_in_package:
|
|
724
|
+
package_map[missing_file] = built_in_package
|
|
725
|
+
resolved[missing_file] = built_in_package
|
|
726
|
+
updated = True
|
|
727
|
+
continue
|
|
728
|
+
|
|
729
|
+
import subprocess
|
|
730
|
+
|
|
731
|
+
result = run_command(
|
|
732
|
+
["tlmgr", "search", "--global", "--file", f"/{missing_file}"],
|
|
733
|
+
reporter=reporter,
|
|
734
|
+
env=env,
|
|
735
|
+
stdout=subprocess.PIPE,
|
|
736
|
+
stderr=subprocess.STDOUT,
|
|
737
|
+
text=True,
|
|
738
|
+
check=False,
|
|
739
|
+
)
|
|
740
|
+
package = package_from_tlmgr_search(result.stdout)
|
|
741
|
+
if package:
|
|
742
|
+
package_map[missing_file] = package
|
|
743
|
+
resolved[missing_file] = package
|
|
744
|
+
updated = True
|
|
745
|
+
|
|
746
|
+
if updated:
|
|
747
|
+
save_package_map(cache_path, package_map)
|
|
748
|
+
return resolved
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def install_tinytex_packages(
|
|
752
|
+
root: "Path",
|
|
753
|
+
packages: list[str],
|
|
754
|
+
env: dict[str, str] | None = None,
|
|
755
|
+
reporter: Reporter | None = None,
|
|
756
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
757
|
+
env = tinytex_env(root) if env is None else env
|
|
758
|
+
return run_command(["tlmgr", "install", *packages], reporter=reporter, env=env, check=False)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def ensure_tinytex_engine(root: "Path", engine: str, env: dict[str, str], reporter: Reporter) -> None:
|
|
762
|
+
package = TINYTEX_ENGINE_PACKAGES.get(engine)
|
|
763
|
+
if package is None or executable_on_path_with_env(engine, env):
|
|
764
|
+
return
|
|
765
|
+
|
|
766
|
+
reporter.status(f"Installing the {engine} engine...")
|
|
767
|
+
result = install_tinytex_packages(root, [package], env, reporter)
|
|
768
|
+
if result.returncode != 0:
|
|
769
|
+
raise TexMiniError(f"Error: TinyTeX could not install the {engine} engine.")
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def executable_on_path_with_env(command: str, env: dict[str, str]) -> str | None:
|
|
773
|
+
for directory in env.get("PATH", os.defpath).split(os.pathsep):
|
|
774
|
+
candidate = os.path.join(directory or ".", command)
|
|
775
|
+
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
776
|
+
return candidate
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def run_tinytex_compile(
|
|
781
|
+
engine: str,
|
|
782
|
+
latexmk_args: list[str],
|
|
783
|
+
root: "Path",
|
|
784
|
+
force: bool = False,
|
|
785
|
+
env: dict[str, str] | None = None,
|
|
786
|
+
reporter: Reporter | None = None,
|
|
787
|
+
) -> "subprocess.CompletedProcess[str]":
|
|
788
|
+
env = tinytex_env(root) if env is None else env
|
|
789
|
+
force_args = ["-g"] if force else []
|
|
790
|
+
return run_command(
|
|
791
|
+
["latexmk", *ENGINE_ARGS[engine], "-interaction=nonstopmode", "-file-line-error", *force_args, *latexmk_args],
|
|
792
|
+
reporter=reporter,
|
|
793
|
+
env=env,
|
|
794
|
+
check=False,
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def pdf_snapshot(path: Path) -> tuple[int, int] | None:
|
|
799
|
+
if not path.is_file():
|
|
800
|
+
return None
|
|
801
|
+
stat_result = path.stat()
|
|
802
|
+
return stat_result.st_mtime_ns, stat_result.st_size
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def format_elapsed(seconds: float) -> str:
|
|
806
|
+
if seconds < 60:
|
|
807
|
+
return f"{seconds:.2f}s"
|
|
808
|
+
minutes, remaining = divmod(round(seconds), 60)
|
|
809
|
+
return f"{minutes}m {remaining:02d}s"
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def primary_latex_error(log_path: Path, tex_file: str, missing_files: list[str]) -> PrimaryError | None:
|
|
813
|
+
import re
|
|
814
|
+
|
|
815
|
+
if missing_files:
|
|
816
|
+
return PrimaryError(f"{missing_files[0]} is missing")
|
|
817
|
+
if not log_path.is_file():
|
|
818
|
+
return None
|
|
819
|
+
source = log_path.read_text(encoding="utf-8", errors="replace")
|
|
820
|
+
file_line = re.search(r"^(.*?\.tex):(\d+):\s*(?:!\s*)?(.+)$", source, re.MULTILINE)
|
|
821
|
+
if file_line:
|
|
822
|
+
return PrimaryError(
|
|
823
|
+
file_line.group(3).strip().rstrip("."),
|
|
824
|
+
file_line.group(1).removeprefix("./"),
|
|
825
|
+
int(file_line.group(2)),
|
|
826
|
+
)
|
|
827
|
+
lines = source.splitlines()
|
|
828
|
+
for index, line in enumerate(lines):
|
|
829
|
+
if not line.startswith("! "):
|
|
830
|
+
continue
|
|
831
|
+
message = line[2:].strip().rstrip(".")
|
|
832
|
+
for context in lines[index + 1 : index + 8]:
|
|
833
|
+
line_match = re.match(r"l\.(\d+)\s", context)
|
|
834
|
+
if line_match:
|
|
835
|
+
return PrimaryError(message, tex_file, int(line_match.group(1)))
|
|
836
|
+
return PrimaryError(message)
|
|
837
|
+
return None
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def document_warnings(log_path: Path) -> list[str]:
|
|
841
|
+
import re
|
|
842
|
+
|
|
843
|
+
if not log_path.is_file():
|
|
844
|
+
return []
|
|
845
|
+
patterns = (
|
|
846
|
+
re.compile(
|
|
847
|
+
r"(?:LaTeX|Package \S+|Class \S+) Warning:.*(?:undefined|rerun|\(re\)run)", re.IGNORECASE
|
|
848
|
+
),
|
|
849
|
+
re.compile(r"LaTeX Warning: There were undefined (?:references|citations)", re.IGNORECASE),
|
|
850
|
+
re.compile(r"Missing character:", re.IGNORECASE),
|
|
851
|
+
re.compile(r"Font Warning:.*(?:not available|substituted)", re.IGNORECASE),
|
|
852
|
+
)
|
|
853
|
+
warnings: list[str] = []
|
|
854
|
+
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
855
|
+
stripped = line.strip()
|
|
856
|
+
if stripped and any(pattern.search(stripped) for pattern in patterns) and stripped not in warnings:
|
|
857
|
+
warnings.append(stripped)
|
|
858
|
+
return warnings
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def show_resolution_mappings(resolved: dict[str, str], reporter: Reporter) -> None:
|
|
862
|
+
if not reporter.verbose:
|
|
863
|
+
return
|
|
864
|
+
for file_name, package in resolved.items():
|
|
865
|
+
reporter.status(f"{file_name} -> {package}")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def run_tinytex_backend(
|
|
869
|
+
engine: str,
|
|
870
|
+
auto_install: bool,
|
|
871
|
+
verbose: bool,
|
|
872
|
+
tex_file: str,
|
|
873
|
+
latexmk_args: list[str],
|
|
874
|
+
started_at: float | None = None,
|
|
875
|
+
reporter: Reporter | None = None,
|
|
876
|
+
) -> BuildOutcome:
|
|
877
|
+
started_at = monotonic() if started_at is None else started_at
|
|
878
|
+
reporter = reporter or Reporter(verbose)
|
|
879
|
+
root = tinytex_root()
|
|
880
|
+
base, _ = os.path.splitext(os.fspath(tex_file))
|
|
881
|
+
log_path = Path(f"{base}.log")
|
|
882
|
+
pdf_path = Path(f"{base}.pdf")
|
|
883
|
+
pdf_before = pdf_snapshot(pdf_path)
|
|
884
|
+
|
|
885
|
+
install_tinytex_archive(root, reporter)
|
|
886
|
+
env = tinytex_env(root)
|
|
887
|
+
ensure_tinytex_engine(root, engine, env, reporter)
|
|
888
|
+
source_files, source_direct_packages = tex_source_requirements(tex_file)
|
|
889
|
+
source_direct_packages = [
|
|
890
|
+
package for package in source_direct_packages if executable_on_path_with_env(package, env) is None
|
|
891
|
+
]
|
|
892
|
+
attempted_packages: set[str] = set()
|
|
893
|
+
install_rounds = 0
|
|
894
|
+
|
|
895
|
+
if auto_install:
|
|
896
|
+
source_missing = missing_tinytex_source_files(root, tex_file, env, source_files, reporter)
|
|
897
|
+
source_resolved = (
|
|
898
|
+
resolve_tinytex_packages(root, source_missing, env=env, reporter=reporter) if source_missing else {}
|
|
899
|
+
)
|
|
900
|
+
initial_packages = sorted(set(source_resolved.values()) | set(source_direct_packages))
|
|
901
|
+
show_resolution_mappings(source_resolved, reporter)
|
|
902
|
+
if initial_packages:
|
|
903
|
+
reporter.status(f"Analyzing {tex_file}...")
|
|
904
|
+
noun = "package" if len(initial_packages) == 1 else "packages"
|
|
905
|
+
reporter.status(f"Installing {len(initial_packages)} {noun}: {', '.join(initial_packages)}")
|
|
906
|
+
install_result = install_tinytex_packages(root, initial_packages, env, reporter)
|
|
907
|
+
attempted_packages.update(initial_packages)
|
|
908
|
+
install_rounds += 1
|
|
909
|
+
if install_result.returncode != 0:
|
|
910
|
+
return BuildOutcome(
|
|
911
|
+
install_result.returncode,
|
|
912
|
+
monotonic() - started_at,
|
|
913
|
+
pdf_snapshot(pdf_path) != pdf_before,
|
|
914
|
+
failure_kind="install_failed",
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
reporter.status(f"Compiling {tex_file}...")
|
|
918
|
+
result = run_tinytex_compile(engine, latexmk_args, root, env=env, reporter=reporter)
|
|
919
|
+
last_missing_files: list[str] = []
|
|
920
|
+
last_unmapped_files: list[str] = []
|
|
921
|
+
|
|
922
|
+
while result.returncode != 0:
|
|
923
|
+
missing_files, log_direct_packages = tex_log_requirements(log_path)
|
|
924
|
+
for missing_file in missing_tinytex_source_files(root, tex_file, env, source_files, reporter):
|
|
925
|
+
if missing_file not in missing_files:
|
|
926
|
+
missing_files.append(missing_file)
|
|
927
|
+
direct_packages = [*log_direct_packages, *source_direct_packages]
|
|
928
|
+
last_missing_files = missing_files
|
|
929
|
+
|
|
930
|
+
if not auto_install:
|
|
931
|
+
failure_kind = "disabled" if missing_files or direct_packages else "ordinary"
|
|
932
|
+
break
|
|
933
|
+
|
|
934
|
+
resolved = resolve_tinytex_packages(root, missing_files, env=env, reporter=reporter) if missing_files else {}
|
|
935
|
+
show_resolution_mappings(resolved, reporter)
|
|
936
|
+
last_unmapped_files = [file_name for file_name in missing_files if file_name not in resolved]
|
|
937
|
+
packages = sorted(
|
|
938
|
+
package
|
|
939
|
+
for package in set(resolved.values()) | set(direct_packages)
|
|
940
|
+
if package not in attempted_packages
|
|
941
|
+
)
|
|
942
|
+
if not packages:
|
|
943
|
+
if last_unmapped_files:
|
|
944
|
+
failure_kind = "unmapped"
|
|
945
|
+
elif primary_latex_error(log_path, tex_file, missing_files):
|
|
946
|
+
failure_kind = "ordinary"
|
|
947
|
+
else:
|
|
948
|
+
failure_kind = "unidentified"
|
|
949
|
+
break
|
|
950
|
+
if install_rounds >= MAX_INSTALL_ROUNDS:
|
|
951
|
+
failure_kind = "ceiling"
|
|
952
|
+
break
|
|
953
|
+
|
|
954
|
+
noun = "package" if len(packages) == 1 else "packages"
|
|
955
|
+
dependency = "dependency" if len(packages) == 1 else "dependencies"
|
|
956
|
+
qualifier = f"required {noun}" if install_rounds == 0 else f"additional {dependency}"
|
|
957
|
+
reporter.status(f"Installing {len(packages)} {qualifier}...")
|
|
958
|
+
install_result = install_tinytex_packages(root, packages, env, reporter)
|
|
959
|
+
attempted_packages.update(packages)
|
|
960
|
+
install_rounds += 1
|
|
961
|
+
if install_result.returncode != 0:
|
|
962
|
+
return BuildOutcome(
|
|
963
|
+
install_result.returncode,
|
|
964
|
+
monotonic() - started_at,
|
|
965
|
+
pdf_snapshot(pdf_path) != pdf_before,
|
|
966
|
+
failure_kind="install_failed",
|
|
967
|
+
missing_files=tuple(missing_files),
|
|
968
|
+
unmapped_files=tuple(last_unmapped_files),
|
|
969
|
+
primary_error=primary_latex_error(log_path, tex_file, missing_files),
|
|
970
|
+
)
|
|
971
|
+
result = run_tinytex_compile(engine, latexmk_args, root, force=True, env=env, reporter=reporter)
|
|
972
|
+
else:
|
|
973
|
+
failure_kind = None
|
|
974
|
+
|
|
975
|
+
elapsed = monotonic() - started_at
|
|
976
|
+
pdf_changed = pdf_snapshot(pdf_path) != pdf_before
|
|
977
|
+
if result.returncode == 0:
|
|
978
|
+
return BuildOutcome(0, elapsed, pdf_changed)
|
|
979
|
+
return BuildOutcome(
|
|
980
|
+
result.returncode,
|
|
981
|
+
elapsed,
|
|
982
|
+
pdf_changed,
|
|
983
|
+
failure_kind=failure_kind,
|
|
984
|
+
missing_files=tuple(last_missing_files),
|
|
985
|
+
unmapped_files=tuple(last_unmapped_files),
|
|
986
|
+
primary_error=primary_latex_error(log_path, tex_file, last_missing_files),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def report_failure(outcome: BuildOutcome, tex_file: str, auto_install: bool, reporter: Reporter) -> None:
|
|
991
|
+
base, _ = os.path.splitext(os.fspath(tex_file))
|
|
992
|
+
log_path = f"{base}.log"
|
|
993
|
+
error = outcome.primary_error
|
|
994
|
+
if error is not None:
|
|
995
|
+
location = ""
|
|
996
|
+
if error.file and error.line:
|
|
997
|
+
location = f" at {error.file}:{error.line}"
|
|
998
|
+
reporter.error(f"Build failed: {error.message}{location}")
|
|
999
|
+
elif outcome.failure_kind == "install_failed":
|
|
1000
|
+
reporter.error("Build failed: TeX Live package installation failed.")
|
|
1001
|
+
else:
|
|
1002
|
+
reporter.error("Build failed: no primary LaTeX error could be identified.")
|
|
1003
|
+
|
|
1004
|
+
if outcome.failure_kind == "disabled" and not auto_install:
|
|
1005
|
+
reporter.error("Automatic package installation is disabled by --no-install.")
|
|
1006
|
+
elif outcome.failure_kind == "install_failed" and error is not None:
|
|
1007
|
+
reporter.error("TeX Live package installation failed.")
|
|
1008
|
+
elif outcome.failure_kind == "unmapped":
|
|
1009
|
+
reporter.error(f"Could not map missing TeX files to packages: {', '.join(outcome.unmapped_files)}")
|
|
1010
|
+
elif outcome.failure_kind == "ceiling":
|
|
1011
|
+
reporter.error(f"Automatic package installation stopped after {MAX_INSTALL_ROUNDS} rounds.")
|
|
1012
|
+
elif outcome.failure_kind == "unidentified":
|
|
1013
|
+
reporter.error("No missing TeX package could be identified.")
|
|
1014
|
+
if Path(log_path).is_file():
|
|
1015
|
+
reporter.error(f"See {log_path} for complete diagnostics.")
|
|
1016
|
+
if outcome.pdf_changed:
|
|
1017
|
+
reporter.error(f"{base}.pdf may be incomplete.")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1021
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
1022
|
+
_source_cache.clear()
|
|
1023
|
+
if "--help" in argv or "-h" in argv:
|
|
1024
|
+
print_help()
|
|
1025
|
+
return 0
|
|
1026
|
+
if "--version" in argv:
|
|
1027
|
+
print(__version__)
|
|
1028
|
+
return 0
|
|
1029
|
+
if "install-tinytex" in argv:
|
|
1030
|
+
remaining = [arg for arg in argv if arg != "--verbose"]
|
|
1031
|
+
if remaining != ["install-tinytex"]:
|
|
1032
|
+
print("Error: install-tinytex only accepts --verbose.", file=sys.stderr)
|
|
1033
|
+
return 1
|
|
1034
|
+
return install_tinytex("--verbose" in argv)
|
|
1035
|
+
|
|
1036
|
+
started_at = monotonic()
|
|
1037
|
+
reporter = Reporter("--verbose" in argv)
|
|
1038
|
+
try:
|
|
1039
|
+
engine, clean, verbose, auto_install, latexmk_args, bib_files, tex_file = parse_args(argv)
|
|
1040
|
+
reporter = Reporter(verbose)
|
|
1041
|
+
detected_tex_file = detect_tex_file(latexmk_args, tex_file, reporter)
|
|
1042
|
+
check_bibliography(detected_tex_file, bib_files, reporter)
|
|
1043
|
+
outcome = run_tinytex_backend(
|
|
1044
|
+
engine,
|
|
1045
|
+
auto_install,
|
|
1046
|
+
verbose,
|
|
1047
|
+
detected_tex_file,
|
|
1048
|
+
latexmk_args,
|
|
1049
|
+
started_at,
|
|
1050
|
+
reporter,
|
|
1051
|
+
)
|
|
1052
|
+
except TexMiniError as error:
|
|
1053
|
+
reporter.error(str(error))
|
|
1054
|
+
return 1
|
|
1055
|
+
|
|
1056
|
+
base, _ = os.path.splitext(os.fspath(detected_tex_file))
|
|
1057
|
+
if outcome.returncode != 0:
|
|
1058
|
+
report_failure(outcome, detected_tex_file, auto_install, reporter)
|
|
1059
|
+
return outcome.returncode
|
|
1060
|
+
|
|
1061
|
+
if not verbose:
|
|
1062
|
+
for warning in document_warnings(Path(f"{base}.log")):
|
|
1063
|
+
reporter.warning(warning)
|
|
1064
|
+
elapsed = format_elapsed(outcome.elapsed_seconds)
|
|
1065
|
+
if outcome.pdf_changed:
|
|
1066
|
+
reporter.status(f"Built {base}.pdf in {elapsed}")
|
|
1067
|
+
if not clean:
|
|
1068
|
+
reporter.status("Build files retained for faster rebuilds; use --clean to remove them.")
|
|
1069
|
+
else:
|
|
1070
|
+
reporter.status(f"{base}.pdf is up to date ({elapsed})")
|
|
1071
|
+
if clean:
|
|
1072
|
+
cleanup_auxiliary_files(detected_tex_file)
|
|
1073
|
+
reporter.status("Removed auxiliary build files.")
|
|
1074
|
+
return 0
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
if __name__ == "__main__":
|
|
1078
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: texmini
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Ultra-lean LaTeX command wrapper with bibliography detection and cleanup.
|
|
5
|
+
Author: Alex Mill
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/alexmill/texMini
|
|
8
|
+
Project-URL: Issues, https://github.com/alexmill/texMini/issues
|
|
9
|
+
Project-URL: Repository, https://github.com/alexmill/texMini
|
|
10
|
+
Keywords: latex,tex,latexmk,bibliography
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Text Processing :: Markup :: LaTeX
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# texMini
|
|
24
|
+
|
|
25
|
+
**LaTeX that just works, without managing a full TeX installation.**
|
|
26
|
+
|
|
27
|
+
## Try it now
|
|
28
|
+
|
|
29
|
+
Choose either path. Both run texMini without a separate texMini installation.
|
|
30
|
+
|
|
31
|
+
On macOS or Linux with [uv](https://docs.astral.sh/uv/):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uvx texmini paper.tex
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
With Docker Desktop or Docker Engine:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
docker run --rm -v "${PWD}:/work" ghcr.io/alexmill/texmini:latest paper.tex
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The Docker command works in Bash, zsh, and PowerShell. The image downloads on its first use. Pin `ghcr.io/alexmill/texmini:0.2.0` instead of `:latest` when reproducibility matters.
|
|
44
|
+
|
|
45
|
+
texMini builds existing LaTeX projects with real TeX Live and `latexmk`. On the first run, it downloads a minimal private TinyTeX runtime. When a document needs a package that is not installed, texMini finds the corresponding TeX Live package, installs it, and retries the build.
|
|
46
|
+
|
|
47
|
+
The result is a TeX installation that grows with your documents instead of arriving as a multi-gigabyte desktop distribution. It lives in `~/.texmini`, does not modify the system TeX installation, and can be removed by deleting that directory.
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
paper.tex ──▶ texmini ──▶ install what is missing ──▶ paper.pdf
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
pdfLaTeX, LuaLaTeX, XeLaTeX, Biber, and the wider TeX Live package ecosystem remain available. Existing projects do not need to adopt a new document language or a different TeX engine.
|
|
54
|
+
|
|
55
|
+
## Why texMini
|
|
56
|
+
|
|
57
|
+
A conventional TeX installation offers broad compatibility, but asks you to install and maintain an entire distribution. Tectonic offers an excellent self-contained build experience, but uses its own XeTeX-derived engine and cannot replace every traditional TeX engine and utility. TinyTeX provides the small, portable TeX Live foundation used here, while its most automatic missing-package workflow is normally accessed through R.
|
|
58
|
+
|
|
59
|
+
texMini combines conventional TeX compatibility with a disposable command-line experience:
|
|
60
|
+
|
|
61
|
+
- **Use the project you already have.** Build ordinary `.tex` files with TeX Live and `latexmk`.
|
|
62
|
+
- **Install only what the document needs.** Missing classes, packages, fonts, bibliography styles, and Biber are resolved and installed automatically.
|
|
63
|
+
- **Keep TeX contained.** The managed runtime and its packages stay under `~/.texmini`.
|
|
64
|
+
- **Remove it like ordinary files.** There is no system-wide uninstaller or package database to unwind.
|
|
65
|
+
- **Choose native or containerized execution.** The published Docker image is a ready-to-run, cross-platform option for common documents.
|
|
66
|
+
|
|
67
|
+
## Comparison
|
|
68
|
+
|
|
69
|
+
| System | Existing LaTeX projects | Package handling | Installation and removal | Main compromise |
|
|
70
|
+
| --- | --- | --- | --- | --- |
|
|
71
|
+
| **texMini** | Builds conventional projects with pdfLaTeX, LuaLaTeX, or XeLaTeX | Automatically detects and installs needed TeX Live packages into a private runtime | Run with `uvx` or Docker; delete `~/.texmini` to remove the native runtime | Specialized external tools can require additional setup |
|
|
72
|
+
| [Tectonic](https://tectonic-typesetting.github.io/) | Builds many projects, subject to its XeTeX-derived engine and build model | Downloads support files from a configured bundle | A single executable and a removable cache | It does not provide every engine and utility in conventional TeX Live |
|
|
73
|
+
| [TinyTeX with R](https://yihui.org/tinytex/) | Broad TeX Live compatibility | The R package can detect and install missing packages during compilation | A small, portable TeX Live directory | The automated workflow is coupled to R |
|
|
74
|
+
| **TinyTeX from the shell** | Broad TeX Live compatibility | Packages are managed directly with `tlmgr` | A small, portable TeX Live directory | Compilation and missing-package repair are manual |
|
|
75
|
+
| **TeX Live, MacTeX, or MiKTeX** | Broadest conventional compatibility | Large package sets or distribution-specific package management | A conventional desktop or system installation | More disk usage and distribution administration |
|
|
76
|
+
| [Overleaf](https://www.overleaf.com/) | Builds projects supported by its hosted TeX environment | A large package set is supplied by the service | No local TeX installation | The build environment is remote and controlled by the service |
|
|
77
|
+
| [Typst](https://typst.app/) | LaTeX projects must be rewritten | Uses Typst packages rather than TeX Live packages | A simple executable and package cache | It is a different document language, not a LaTeX compiler |
|
|
78
|
+
|
|
79
|
+
## Native workflow
|
|
80
|
+
|
|
81
|
+
The native managed runtime supports macOS and Linux and requires uv and Perl. TinyTeX uses Perl for `tlmgr` and `latexmk`. Windows users should use the Docker pathway above.
|
|
82
|
+
|
|
83
|
+
Run texMini directly from PyPI:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
uvx texmini paper.tex
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The first compile downloads TinyTeX-0 into `~/.texmini/TinyTeX`, bootstraps the compiler, and installs the packages required by `paper.tex`. Later builds reuse that runtime.
|
|
90
|
+
|
|
91
|
+
For repeated authoring, use the default incremental workflow:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
texmini paper.tex
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
texMini retains LaTeX's auxiliary build state so unchanged builds and partial rebuilds are substantially faster. For a one-shot or CI build that should remove supported auxiliary files after success, use:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
texmini --clean paper.tex
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Install the command for repeated use:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
uv tool install texmini
|
|
107
|
+
texmini paper.tex
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
If a directory contains exactly one `.tex` file, the filename is optional:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
texmini
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## What happens during a build
|
|
117
|
+
|
|
118
|
+
Running:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
texmini paper.tex
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
causes texMini to:
|
|
125
|
+
|
|
126
|
+
1. Select `paper.tex`, or find the only `.tex` file in the current directory.
|
|
127
|
+
2. Check the source for required classes, packages, and bibliography tooling.
|
|
128
|
+
3. Install the private TinyTeX runtime if it does not exist.
|
|
129
|
+
4. Compile with managed `latexmk` and pdfLaTeX.
|
|
130
|
+
5. Read a failed build for missing TeX files, resolve their TeX Live packages, and install them together.
|
|
131
|
+
6. Continue installing and retrying while each round discovers a new package, with a 20-round safety ceiling.
|
|
132
|
+
7. Write `paper.pdf` beside the source and retain incremental build state by default.
|
|
133
|
+
|
|
134
|
+
Package mappings are cached in `~/.texmini/package-map.json`. Package installation modifies only texMini's private TinyTeX tree.
|
|
135
|
+
|
|
136
|
+
## Engines and options
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
texmini [install-tinytex] [--engine pdflatex|lualatex|xelatex] [OPTIONS] [document.tex] [refs.bib ...]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
texmini paper.tex
|
|
146
|
+
texmini --engine lualatex paper.tex
|
|
147
|
+
texmini --engine xelatex paper.tex
|
|
148
|
+
texmini --clean paper.tex
|
|
149
|
+
texmini --verbose paper.tex
|
|
150
|
+
texmini paper.tex references.bib
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
|
|
155
|
+
- `--engine ENGINE`: select `pdflatex`, `lualatex`, or `xelatex`.
|
|
156
|
+
- `--clean`: remove supported auxiliary files after a successful build.
|
|
157
|
+
- `--verbose`: show complete TeX, `latexmk`, Biber, and package-manager output.
|
|
158
|
+
- `--no-install`: do not install missing TeX Live packages.
|
|
159
|
+
- `--version`: print the texMini version.
|
|
160
|
+
|
|
161
|
+
Arguments not handled by texMini are passed to managed `latexmk`. Continuous preview mode (`-pvc`) is not supported.
|
|
162
|
+
|
|
163
|
+
Prepare the managed runtime without compiling a document:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
texmini install-tinytex
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Bibliographies
|
|
170
|
+
|
|
171
|
+
texMini detects `\bibliography{...}`, `\addbibresource{...}`, and `biblatex`. Biber is installed automatically when a managed document uses `biblatex`.
|
|
172
|
+
|
|
173
|
+
Explicit bibliography files are checked before compilation:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
texmini paper.tex references.bib
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Build cleanup
|
|
180
|
+
|
|
181
|
+
By default, successful builds retain `.aux`, `.bbl`, `.bcf`, `.fdb_latexmk`, and related state so `latexmk` can avoid unnecessary work on the next invocation.
|
|
182
|
+
|
|
183
|
+
With `--clean`, texMini removes supported auxiliary files after a successful build while preserving `.tex`, `.bib`, `.pdf`, and unrelated files. Failed builds always retain their logs and auxiliary files for diagnosis.
|
|
184
|
+
|
|
185
|
+
## Output and diagnostics
|
|
186
|
+
|
|
187
|
+
Normal builds show short, stable progress messages and suppress successful `tlmgr`, TeX, Metafont, Biber, and `latexmk` transcripts. Warnings that affect the finished document, including unresolved references and missing characters, remain visible.
|
|
188
|
+
|
|
189
|
+
Use `--verbose` to stream complete subprocess output. On failure, the default output shows the primary LaTeX error and source line when available, points to the retained log, and warns when the failed invocation created or changed the PDF.
|
|
190
|
+
|
|
191
|
+
## Docker
|
|
192
|
+
|
|
193
|
+
Docker is the cross-platform, isolated pathway for Docker Desktop and Docker Engine users, including Windows. Compile a document in the current directory with:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
docker run --rm -v "${PWD}:/work" ghcr.io/alexmill/texmini:latest paper.tex
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Use the versioned image for a reproducible invocation:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
docker run --rm -v "${PWD}:/work" ghcr.io/alexmill/texmini:0.2.0 paper.tex
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The image bundles TinyTeX plus packages used by many math, layout, bibliography, hyperlink, color, and TikZ documents. Common documents can therefore build from the downloaded image alone. When networking is available, texMini downloads uncommon TeX Live packages as needed. Those additions are discarded with `--rm`; this is an isolated ready-to-run workflow, not a promise that every possible project compiles offline.
|
|
206
|
+
|
|
207
|
+
On native Linux, the entrypoint writes outputs as the owner of the mounted directory. Explicit Docker `--user` settings remain supported. Docker Desktop handles bind-mount ownership through its virtual machine.
|
|
208
|
+
|
|
209
|
+
## Automation and AI agents
|
|
210
|
+
|
|
211
|
+
texMini is noninteractive and uses stable status lines without spinners or terminal-only formatting. A successful build exits with zero; a failed build returns the underlying nonzero status, retains its log and diagnostic files, and prints the primary error near the end. Use `--verbose` for complete tool transcripts and `--clean` when an automation should remove supported auxiliary files after success.
|
|
212
|
+
|
|
213
|
+
This makes texMini friendly to scripts, CI, and AI coding agents without adding an agent-specific protocol: the same small CLI is used by people and automation.
|
|
214
|
+
|
|
215
|
+
## Compatibility and limitations
|
|
216
|
+
|
|
217
|
+
texMini targets ordinary projects that build with real TeX Live, `latexmk`, and pdfLaTeX, LuaLaTeX, or XeLaTeX. It can plausibly replace the compilation part of an Overleaf workflow, but it is not a collaborative editor or document-hosting service.
|
|
218
|
+
|
|
219
|
+
- Native runtime installation supports macOS and Linux; Windows uses Docker Desktop.
|
|
220
|
+
- Specialized external tools such as glossary generators, Pygments-based syntax highlighting, or project-specific scripts may require additional setup.
|
|
221
|
+
- The managed native runtime grows as packages are installed. It is shared across builds and is not locked independently per project.
|
|
222
|
+
- TeX projects can depend on system fonts, executables, or shell-escape behavior that texMini does not automatically provision.
|
|
223
|
+
|
|
224
|
+
## Environment
|
|
225
|
+
|
|
226
|
+
- `TEXMINI_ENGINE`: default engine; defaults to `pdflatex`.
|
|
227
|
+
- `TEXMINI_CLEAN=true`: remove supported auxiliary files after successful builds.
|
|
228
|
+
- `TEXMINI_AUTO_INSTALL=false`: disable document-driven package installation.
|
|
229
|
+
- `TEXMINI_TINYTEX_ROOT`: managed TinyTeX directory; defaults to `~/.texmini/TinyTeX`.
|
|
230
|
+
- `TEXMINI_TINYTEX_BUNDLE`: release bundle; defaults to `TinyTeX-0`.
|
|
231
|
+
- `TEXMINI_PACKAGE_MAP`: package mapping cache; defaults to `~/.texmini/package-map.json`.
|
|
232
|
+
|
|
233
|
+
## Development
|
|
234
|
+
|
|
235
|
+
Run texMini from the source tree:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
uv run texmini paper.tex
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Run the test suite and validate the distributions:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
uv run python -m unittest discover -s tests -v
|
|
245
|
+
uv build --sdist --wheel
|
|
246
|
+
uvx --from twine==6.2.0 twine check dist/*
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Build and smoke-test Docker:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
docker build -t texmini .
|
|
253
|
+
docker run --rm --network none \
|
|
254
|
+
-v "${PWD}:/work" \
|
|
255
|
+
texmini test.tex
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
TinyTeX bundle benchmark methodology and raw results are in [`benchmarks`](benchmarks).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
texmini/__init__.py,sha256=RMMLsgujYrMA402ceCiteQE1qVNGBrATBTKN1cLZvR0,54
|
|
2
|
+
texmini/cli.py,sha256=5PJLdYsaHMJ_x3jKwCpXFT0GH1WKSO2C-waGWPuW8TY,39653
|
|
3
|
+
texmini-0.2.0.dist-info/licenses/LICENSE,sha256=bVbQOo_48V2c4zwdmwVrEEUw7FNc7GPCZXneTl2KYNw,1066
|
|
4
|
+
texmini-0.2.0.dist-info/METADATA,sha256=3KLiCuY6xQ649EAVzaTueZFIqaGAXoI52qnJXxDWn2Y,12464
|
|
5
|
+
texmini-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
texmini-0.2.0.dist-info/entry_points.txt,sha256=EyIodnbp-lUfQprJUa1JEQvB7LGX3MiJSP6YL-KUOqk,45
|
|
7
|
+
texmini-0.2.0.dist-info/top_level.txt,sha256=FFlf0O0WjrsSiJ1Qcyq5JIE07QnTL-NFHid6QlXZpe4,8
|
|
8
|
+
texmini-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Mill
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
texmini
|