Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/tools/impl/tinytex.ts
12921 views
1
/*
2
* tinytex.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
import { debug, warning } from "../../deno_ral/log.ts";
7
8
import { existsSync, safeRemoveSync } from "../../deno_ral/fs.ts";
9
import { basename, join, relative } from "../../deno_ral/path.ts";
10
11
import { expandPath, which } from "../../core/path.ts";
12
import { unzip } from "../../core/zip.ts";
13
import {
14
hasTexLive,
15
removePath,
16
texLiveContext,
17
texLiveInPath,
18
} from "../../command/render/latexmk/texlive.ts";
19
import { execProcess } from "../../core/process.ts";
20
21
import {
22
InstallableTool,
23
InstallContext,
24
kUpdatePath,
25
PackageInfo,
26
RemotePackageInfo,
27
ToolConfigurationState,
28
} from "../types.ts";
29
import { getLatestRelease } from "../github.ts";
30
import { hasTinyTex, tinyTexInstallDir } from "./tinytex-info.ts";
31
import { copyTo } from "../../core/copy.ts";
32
import { suggestUserBinPaths } from "../../core/path.ts";
33
34
import { ensureDirSync, walkSync } from "../../deno_ral/fs.ts";
35
import {
36
arch,
37
isLinux,
38
isMac,
39
isWindows,
40
os as platformOs,
41
} from "../../deno_ral/platform.ts";
42
43
// This the https texlive repo that we use by default
44
const kDefaultRepos = [
45
"https://mirrors.rit.edu/CTAN/systems/texlive/tlnet/",
46
"https://ctan.math.illinois.edu/systems/texlive/tlnet/",
47
"https://mirror.las.iastate.edu/tex-archive/systems/texlive/tlnet/",
48
];
49
50
// Different packages
51
const kTinyTexRepo = "rstudio/tinytex-releases";
52
// const kPackageMinimal = "TinyTeX-0"; // smallest
53
// const kPackageDefault = "TinyTeX-1"; // Compiles most RMarkdown
54
const kPackageMaximal = "TinyTeX"; // Compiles 80% of documents
55
// const kPackageComplete = "TinyTex-2"; // Includes complete texlive library. Huge, 4GB download.
56
57
// The name of the file that we use to store the installed version
58
const kVersionFileName = "version";
59
60
export const tinyTexInstallable: InstallableTool = {
61
name: "TinyTeX",
62
prereqs: [{
63
check: () => {
64
// bin must be writable on MacOS
65
return isWritable("/usr/local/bin");
66
},
67
os: ["darwin"],
68
message: "The directory /usr/local/bin is not writable.",
69
}],
70
installed,
71
installDir,
72
binDir,
73
installedVersion,
74
verifyConfiguration,
75
latestRelease: remotePackageInfo,
76
preparePackage,
77
install,
78
afterInstall,
79
uninstall,
80
};
81
82
async function installed() {
83
const hasTiny = hasTinyTex();
84
if (hasTiny) {
85
return true;
86
} else {
87
if (await hasTexLive()) {
88
return await isTinyTex();
89
} else {
90
return false;
91
}
92
}
93
}
94
95
async function installDir() {
96
if (await installed()) {
97
return Promise.resolve(tinyTexInstallDir());
98
} else {
99
return Promise.resolve(undefined);
100
}
101
}
102
103
function verifyConfiguration(): Promise<ToolConfigurationState> {
104
return Promise.resolve({ status: "ok" });
105
}
106
107
async function binDir() {
108
if (await installed()) {
109
const installDir = tinyTexInstallDir();
110
if (installDir) {
111
return Promise.resolve(binFolder(installDir));
112
} else {
113
warning(
114
"Failed to resolve tinytex install directory even though it is installed.",
115
);
116
return Promise.resolve(undefined);
117
}
118
} else {
119
return Promise.resolve(undefined);
120
}
121
}
122
123
async function installedVersion() {
124
const installDir = tinyTexInstallDir();
125
if (installDir) {
126
const versionFile = join(installDir, kVersionFileName);
127
if (existsSync(versionFile)) {
128
return await Deno.readTextFile(versionFile);
129
} else {
130
return undefined;
131
}
132
} else {
133
return undefined;
134
}
135
}
136
137
function noteInstalledVersion(version: string) {
138
const installDir = tinyTexInstallDir();
139
if (installDir) {
140
const versionFile = join(installDir, kVersionFileName);
141
Deno.writeTextFileSync(
142
versionFile,
143
version,
144
);
145
}
146
}
147
148
async function preparePackage(
149
context: InstallContext,
150
): Promise<PackageInfo> {
151
// Find the latest version
152
const pkgInfo = await remotePackageInfo();
153
const version = pkgInfo.version;
154
155
// target package information
156
const candidates = tinyTexPkgName(kPackageMaximal, version);
157
const result = tinyTexUrl(candidates, pkgInfo);
158
if (result) {
159
const filePath = join(context.workingDir, result.name);
160
await context.download(`TinyTex ${version}`, result.url, filePath);
161
return { filePath, version };
162
} else {
163
context.error(
164
`Couldn't determine what URL to use to download TinyTeX. ` +
165
`Tried: ${candidates.join(", ")}`,
166
);
167
return Promise.reject();
168
}
169
}
170
171
async function install(
172
pkgInfo: PackageInfo,
173
context: InstallContext,
174
) {
175
// the target installation
176
const installDir = tinyTexInstallDir();
177
if (installDir) {
178
const parentDir = join(installDir, "..");
179
const realParentDir = expandPath(parentDir);
180
const tinyTexDirName = isLinux ? ".TinyTeX" : "TinyTeX";
181
debug(`TinyTex Directory Information:`);
182
debug(`> installDir: ${installDir}`);
183
debug(`> parentDir: ${parentDir}`);
184
debug(`> realParentDir: ${realParentDir}`);
185
186
if (existsSync(realParentDir)) {
187
// Extract the package
188
189
debug(`Unzipping file ${pkgInfo.filePath}`);
190
await context.withSpinner(
191
{ message: `Unzipping ${basename(pkgInfo.filePath)}` },
192
async () => {
193
await unzip(pkgInfo.filePath);
194
},
195
);
196
197
await context.withSpinner(
198
{ message: `Moving files` },
199
() => {
200
const from = join(context.workingDir, tinyTexDirName);
201
debug(`Moving files\n> from ${from}\n> to ${installDir}`);
202
203
copyTo(from, installDir);
204
205
// Work around: https://github.com/denoland/deno/issues/16921
206
// This will verify that the permissions of the file
207
// are preserved after the file has been copied.
208
//
209
// Once the Deno bug is resolve, the commit containing
210
// this change should be reverted, quarto tinytex should
211
// be remove and reinstalled and the smoke tests
212
// should be run and pass.
213
if (isMac) {
214
for (const file of walkSync(from)) {
215
if (file.isFile) {
216
const relativePath = relative(from, file.path);
217
const destPath = join(installDir, relativePath);
218
const srcStat = Deno.statSync(file.path);
219
const destStat = Deno.statSync(destPath);
220
if (srcStat.mode !== null && srcStat.mode !== destStat.mode) {
221
Deno.chmodSync(destPath, srcStat.mode);
222
}
223
}
224
}
225
}
226
227
safeRemoveSync(from, { recursive: true });
228
229
// Note the version that we have installed
230
noteInstalledVersion(pkgInfo.version);
231
return Promise.resolve();
232
},
233
);
234
235
context.props[kTlMgrKey] = isWindows
236
? join(binFolder(installDir), "tlmgr.bat")
237
: join(binFolder(installDir), "tlmgr");
238
239
return Promise.resolve();
240
} else {
241
context.error("Installation target directory doesn't exist");
242
return Promise.reject();
243
}
244
} else {
245
context.error("Unable to determine installation directory");
246
return Promise.reject();
247
}
248
}
249
250
function binFolder(installDir: string) {
251
const nixBinFolder = () => {
252
const oldBinFolder = join(
253
installDir,
254
"bin",
255
`${Deno.build.arch}-${platformOs}`,
256
);
257
if (existsSync(oldBinFolder)) {
258
return oldBinFolder;
259
} else {
260
return join(
261
installDir,
262
"bin",
263
`universal-${platformOs}`,
264
);
265
}
266
};
267
268
const winBinFolder = () => {
269
// TeX Live 2023 use windows now. Previous version were using win32
270
const oldBinFolder = join(
271
installDir,
272
"bin",
273
"win32",
274
);
275
if (existsSync(oldBinFolder)) {
276
return oldBinFolder;
277
} else {
278
return join(
279
installDir,
280
"bin",
281
"windows",
282
);
283
}
284
};
285
286
// Find the tlmgr and note its location
287
return isWindows ? winBinFolder() : nixBinFolder();
288
}
289
290
async function afterInstall(context: InstallContext) {
291
const tlmgrPath = context.props[kTlMgrKey] as string;
292
if (tlmgrPath) {
293
// Install tlgpg to permit safe utilization of https
294
await context.withSpinner(
295
{ message: "Verifying tlgpg support" },
296
async () => {
297
if (["darwin", "windows"].includes(platformOs)) {
298
await exec(
299
tlmgrPath,
300
[
301
"-q",
302
"--repository",
303
"http://www.preining.info/tlgpg/",
304
"install",
305
"tlgpg",
306
],
307
);
308
}
309
},
310
);
311
312
// Reconfigure font paths for xetex (rstudio/tinytex#313)
313
await context.withSpinner(
314
{ message: "Configuring font paths" },
315
async () => {
316
await exec(
317
tlmgrPath,
318
[
319
"postaction",
320
"install",
321
"script",
322
"xetex",
323
],
324
);
325
},
326
);
327
328
// Set the default repo to an https repo
329
let restartRequired = false;
330
const defaultRepo = await textLiveRepo();
331
await context.withSpinner(
332
{
333
message: `Setting default repository`,
334
doneMessage: `Default Repository: ${defaultRepo}`,
335
},
336
async () => {
337
await exec(
338
tlmgrPath,
339
["-q", "option", "repository", defaultRepo],
340
);
341
},
342
);
343
344
// If the environment has requested, add this tex installation to
345
// the system path
346
if (context.flags[kUpdatePath]) {
347
const message =
348
`Unable to determine a path to use when installing TeX Live.
349
To complete the installation, please run the following:
350
351
${tlmgrPath} option sys_bin <bin_dir_on_path>
352
${tlmgrPath} path add
353
354
This will instruct TeX Live to create symlinks that it needs in <bin_dir_on_path>.`;
355
356
const configureBinPath = async (path: string) => {
357
if (!isWindows) {
358
// Find bin paths on this machine
359
// Ensure the directory exists
360
const expandedPath = expandPath(path);
361
ensureDirSync(expandedPath);
362
363
// Set the sys_bin for texlive
364
await exec(
365
tlmgrPath,
366
["option", "sys_bin", expandedPath],
367
);
368
return true;
369
} else {
370
return true;
371
}
372
};
373
374
const paths: string[] = [];
375
const envPath = Deno.env.get("QUARTO_TEXLIVE_BINPATH");
376
if (envPath) {
377
paths.push(envPath);
378
} else if (!isWindows) {
379
paths.push(...suggestUserBinPaths());
380
} else {
381
paths.push(tlmgrPath);
382
}
383
384
const binPathMessage = envPath
385
? `Setting TeXLive Binpath: ${envPath}`
386
: !isWindows
387
? `Updating Path (inspecting ${paths.length} possible paths)`
388
: "Updating Path";
389
390
// Ensure symlinks are all set
391
await context.withSpinner(
392
{ message: binPathMessage },
393
async () => {
394
let result;
395
for (const path of paths) {
396
const pathConfigured = await configureBinPath(path);
397
if (pathConfigured) {
398
result = await exec(
399
tlmgrPath,
400
["path", "add"],
401
);
402
if (result.success) {
403
break;
404
}
405
}
406
}
407
if (result && !result.success) {
408
warning(message);
409
}
410
},
411
);
412
413
// After installing on windows, the path may not be updated which means a restart is required
414
if (isWindows) {
415
const texLiveInstalled = await hasTexLive();
416
const texLivePath = await texLiveInPath();
417
restartRequired = restartRequired || !texLiveInstalled || !texLivePath;
418
}
419
}
420
421
return Promise.resolve(restartRequired);
422
} else {
423
context.error("Couldn't locate tlmgr after installation");
424
return Promise.reject();
425
}
426
}
427
428
async function uninstall(context: InstallContext) {
429
if (!isTinyTex()) {
430
context.error("Current LateX installation does not appear to be TinyTex");
431
return Promise.reject();
432
}
433
434
if (context.flags[kUpdatePath]) {
435
// remove symlinks
436
if (await texLiveInPath()) {
437
await context.withSpinner(
438
{ message: "Removing commands" },
439
async () => {
440
const texLive = await texLiveContext(true);
441
const result = await removePath(texLive);
442
if (!result.success) {
443
context.error("Failed to uninstall");
444
return Promise.reject();
445
}
446
},
447
);
448
}
449
}
450
451
await context.withSpinner(
452
{ message: "Removing directory" },
453
async () => {
454
// Remove the directory
455
const installDir = tinyTexInstallDir();
456
if (installDir) {
457
await Deno.remove(installDir, { recursive: true });
458
} else {
459
context.error("Couldn't find install directory");
460
return Promise.reject();
461
}
462
},
463
);
464
}
465
466
function exec(path: string, cmd: string[]) {
467
return execProcess({
468
cmd: path,
469
args: cmd,
470
stdout: "piped",
471
stderr: "piped",
472
});
473
}
474
475
const kTlMgrKey = "tlmgr";
476
477
async function textLiveRepo() {
478
// We don't set the default to `ctan` because one caveat of mirror.ctan.org
479
// is that it resolves to many different hosts, and they are not perfectly synchronized;
480
// Recommendation is to update only daily (at most), and not more often, which we don't want.
481
// So:
482
// 1. Try to get the automatic CTAN mirror returned from mirror.ctan.org
483
// 2. If that fails, use one of the selected mirrors
484
let autoUrl;
485
try {
486
const url = "https://mirror.ctan.org/systems/texlive/tlnet";
487
const response = await fetch(url, { redirect: "follow" });
488
autoUrl = response.url;
489
} catch (_e) {}
490
if (!autoUrl) {
491
const randomInt = Math.floor(Math.random() * kDefaultRepos.length);
492
autoUrl = kDefaultRepos[randomInt];
493
}
494
return autoUrl;
495
}
496
497
export function tinyTexPkgName(
498
base?: string,
499
ver?: string,
500
options?: { os?: string; arch?: string },
501
): string[] {
502
const effectiveOs = options?.os ??
503
(isWindows ? "windows" : isLinux ? "linux" : "darwin");
504
const effectiveArch = options?.arch ?? arch;
505
506
base = base || "TinyTeX";
507
508
if (!ver) {
509
const ext = effectiveOs === "windows"
510
? "zip"
511
: effectiveOs === "linux"
512
? "tar.gz"
513
: "tgz";
514
return [`${base}.${ext}`];
515
}
516
517
const candidates: string[] = [];
518
519
if (effectiveOs === "windows") {
520
candidates.push(`${base}-windows-${ver}.exe`);
521
candidates.push(`${base}-${ver}.zip`);
522
} else if (effectiveOs === "linux") {
523
if (effectiveArch === "aarch64") {
524
candidates.push(`${base}-linux-arm64-${ver}.tar.xz`);
525
candidates.push(`${base}-arm64-${ver}.tar.gz`);
526
} else {
527
candidates.push(`${base}-linux-x86_64-${ver}.tar.xz`);
528
candidates.push(`${base}-${ver}.tar.gz`);
529
}
530
} else {
531
candidates.push(`${base}-darwin-${ver}.tar.xz`);
532
candidates.push(`${base}-${ver}.tgz`);
533
}
534
535
return candidates;
536
}
537
538
function tinyTexUrl(candidates: string[], remotePkgInfo: RemotePackageInfo) {
539
for (const pkg of candidates) {
540
const asset = remotePkgInfo.assets.find((asset) => asset.name === pkg);
541
if (asset) {
542
return { url: asset.url, name: pkg };
543
}
544
}
545
return undefined;
546
}
547
548
async function remotePackageInfo(): Promise<RemotePackageInfo> {
549
const githubRelease = await getLatestRelease(kTinyTexRepo);
550
return {
551
url: githubRelease.html_url,
552
version: githubRelease.tag_name,
553
assets: githubRelease.assets.map((asset) => {
554
return { name: asset.name, url: asset.browser_download_url };
555
}),
556
};
557
}
558
559
async function isWritable(path: string) {
560
const desc = { name: "write", path } as const;
561
const status = await Deno.permissions.query(desc);
562
return status.state === "granted";
563
}
564
565
async function isTinyTex() {
566
const root = await texLiveRoot();
567
if (root) {
568
// directory name (lower) is tinytex
569
if (root.match(/[/\\][Tt]iny[Tt]e[Xx][/\\]?/)) {
570
return true;
571
}
572
573
// Format config contains references to tinytex
574
const cnfFile = join(root, "texmf-dist/web2c/fmtutil.cnf");
575
if (existsSync(cnfFile)) {
576
const cnfText = Deno.readTextFileSync(cnfFile);
577
const match = cnfText.match(/\W[.]?TinyTeX\W/);
578
if (match) {
579
return true;
580
}
581
}
582
return false;
583
}
584
return false;
585
}
586
587
async function texLiveRoot() {
588
const texLivePath = await which("tlmgr");
589
if (texLivePath) {
590
// The real (non-symlink) path
591
const realPath = await Deno.realPath(texLivePath);
592
if (isWindows) {
593
return join(realPath, "..", "..", "..");
594
} else {
595
// Check that the directory coontains a bin folder
596
const root = join(realPath, "..", "..", "..", "..");
597
const tlBin = join(root, "bin");
598
if (existsSync(tlBin)) {
599
return root;
600
} else {
601
return undefined;
602
}
603
}
604
} else {
605
const installDir = tinyTexInstallDir();
606
if (installDir && existsSync(installDir)) {
607
return installDir;
608
} else {
609
return undefined;
610
}
611
}
612
}
613
614