1#' Install/Uninstall TinyTeX
2#'
3#' The function \code{install_tinytex()} downloads and installs TinyTeX, a
4#' custom LaTeX distribution based on TeX Live. The function
5#' \code{uninstall_tinytex()} removes TinyTeX; \code{reinstall_tinytex()}
6#' reinstalls TinyTeX as well as previously installed LaTeX packages by default;
7#' \code{tinytex_root()} returns the root directory of TinyTeX if found.
8#' @param force Whether to force to install (override) or uninstall TinyTeX.
9#' @param dir The directory to install or uninstall TinyTeX (should not exist
10#'   unless \code{force = TRUE}).
11#' @param version The version of TinyTeX, e.g., \code{"2020.09"} (see all
12#'   available versions at \url{https://github.com/yihui/tinytex-releases}, or
13#'   via \code{xfun::github_releases('yihui/tinytex-releases')}). By default, it
14#'   installs the latest daily build of TinyTeX. If \code{version = 'latest'},
15#'   it installs the latest Github release of TinyTeX.
16#' @param repository The CTAN repository to set. You can find available
17#'   repositories at \code{https://ctan.org/mirrors}), e.g.,
18#'   \code{'http://mirrors.tuna.tsinghua.edu.cn/CTAN/'}, or
19#'   \code{'https://mirror.las.iastate.edu/tex-archive/'}. In theory, this
20#'   argument should end with the path \file{/systems/texlive/tlnet}, and if it
21#'   does not, the path will be automatically appended.
22#' @param extra_packages A character vector of extra LaTeX packages to be
23#'   installed. By default, a vector of all currently installed LaTeX packages
24#'   if an existing installation of TinyTeX is found. If you want a fresh
25#'   installation, you may use \code{extra_packages = NULL}.
26#' @param add_path Whether to run the command \command{tlmgr path add} to add
27#'   the bin path of TeX Live to the system environment variable \var{PATH}.
28#' @references See the TinyTeX documentation (\url{https://yihui.org/tinytex/})
29#'   for the default installation directories on different platforms.
30#' @export
31install_tinytex = function(
32  force = FALSE, dir = 'auto', version = 'daily', repository = 'ctan',
33  extra_packages = if (is_tinytex()) tl_pkgs(), add_path = TRUE
34) {
35  if (!is.logical(force)) stop('The argument "force" must take a logical value.')
36  check_dir = function(dir) {
37    if (dir_exists(dir) && !force) stop(
38      'The directory "', dir, '" exists. Please either delete it, ',
39      'or use install_tinytex(force = TRUE).'
40    )
41  }
42  if (missing(dir)) dir = ''
43  user_dir = ''
44  if (dir != '') {
45    dir = gsub('[/\\]+$', '', dir)  # remove trailing slashes
46    check_dir(dir)
47    unlink(dir, recursive = TRUE)
48    user_dir = normalizePath(dir, mustWork = FALSE)
49  }
50
51  https = grepl('^https://', repository)
52  repository = normalize_repo(repository)
53  not_ctan = repository != 'ctan'
54
55  owd = setwd(tempdir()); on.exit(setwd(owd), add = TRUE)
56
57  if ((texinput <- Sys.getenv('TEXINPUT')) != '') message(
58    'Your environment variable TEXINPUT is "', texinput,
59    '". Normally you should not set this variable, because it may lead to issues like ',
60    'https://github.com/yihui/tinytex/issues/92.'
61  )
62
63  switch(
64    os,
65    'unix' = {
66      check_local_bin()
67      if (os_index != 3 && !dir_exists('~/bin')) on.exit(message(
68        'You may have to restart your system after installing TinyTeX to make sure ',
69        '~/bin appears in your PATH variable (https://github.com/yihui/tinytex/issues/16).'
70      ), add = TRUE)
71    },
72    'windows' = {},
73    stop('Sorry, but tinytex::install_tinytex() does not support this platform: ', os)
74  )
75
76  src_install = getOption('tinytex.source.install', need_source_install())
77  install = function(...) {
78    if (src_install) {
79      install_tinytex_source(repository, ...)
80    } else {
81      install_prebuilt('TinyTeX-1', ..., repo = repository)
82    }
83  }
84  force(extra_packages)  # evaluate it before installing another version of TinyTeX
85  if (version == 'daily') {
86    version = ''
87    # test if https://yihui.org or github.com is accessible because the daily
88    # version is downloaded from there
89    determine_version = function() {
90      if (xfun::url_accessible('https://yihui.org')) return('')
91      if (xfun::url_accessible('https://github.com')) return('daily-github')
92      warning(
93        "The daily version of TinyTeX does not appear to be accessible. ",
94        "Switching to version = 'latest' instead. If you are sure to install ",
95        "the daily version, call tinytex::install_tinytex(version = 'daily') ",
96        "(which may fail)."
97      )
98      'latest'
99    }
100    if (missing(version) && !src_install) version = determine_version()
101  }
102  user_dir = install(user_dir, version, add_path, extra_packages)
103
104  opts = options(tinytex.tlmgr.path = find_tlmgr(user_dir))
105  on.exit(options(opts), add = TRUE)
106
107  if (not_ctan) {
108    # install tlgpg for Windows and macOS users if an HTTPS repo is preferred
109    if (os_index %in% c(1, 3) && https) {
110      tlmgr(c('--repository', 'http://www.preining.info/tlgpg/', 'install', 'tlgpg'))
111    }
112    tlmgr_repo(repository)
113    if (tlmgr(c('update', '--list')) != 0) {
114      warning('The repository ', repository, ' does not seem to be accessible. Reverting to the default CTAN mirror.')
115      tlmgr(c('option', 'repository', 'ctan'))
116    }
117  }
118
119  invisible(user_dir)
120}
121
122# TinyTeX has to be installed from source for OSes that are not Linux or
123# non-x86_64 Linux machines
124need_source_install = function() {
125  os_index == 0 || (os_index == 2 && !identical(Sys.info()[['machine']], 'x86_64'))
126}
127
128# append /systems/texlive/tlnet to the repo url if necessary
129normalize_repo = function(url) {
130  # don't normalize the url if users passes I(url) or 'ctan' or NULL
131  if (is.null(url) || url == 'ctan' || inherits(url, 'AsIs')) return(url)
132  url = sub('/+$', '', url)
133  if (!grepl('/tlnet$', url)) {
134    url2 = paste0(url, '/systems/texlive/tlnet')
135    # return the amended url if it works
136    if (xfun::url_accessible(url2)) return(url2)
137  }
138  url
139}
140
141win_app_dir = function(..., error = TRUE) {
142  d = Sys.getenv('APPDATA')
143  if (d == '') {
144    if (error) stop('Environment variable "APPDATA" not set.')
145    return(d)
146  }
147  file.path(d, ...)
148}
149
150# check if /usr/local/bin on macOS is writable
151check_local_bin = function() {
152  if (os_index != 3 || is_writable('/usr/local/bin')) return()
153  chown_cmd = 'chown -R `whoami`:admin /usr/local/bin'
154  message(
155    'The directory /usr/local/bin is not writable. I recommend that you ',
156    'make it writable. See https://github.com/yihui/tinytex/issues/24 for more info.'
157  )
158  if (system(sprintf(
159    "/usr/bin/osascript -e 'do shell script \"%s\" with administrator privileges'", chown_cmd
160  )) != 0) warning(
161    "Please run this command in your Terminal (password required):\n  sudo ",
162    chown_cmd, call. = FALSE
163  )
164}
165
166install_tinytex_source = function(repo = '', dir, version, add_path, extra_packages) {
167  if (version != '') stop(
168    'tinytex::install_tinytex() does not support installing a specific version of ',
169    'TinyTeX for your platform. Please use the argument version = "".'
170  )
171  if (repo != 'ctan') {
172    Sys.setenv(CTAN_REPO = repo)
173    on.exit(Sys.unsetenv('CTAN_REPO'), add = TRUE)
174  }
175  download_file('https://yihui.org/gh/tinytex/tools/install-unx.sh')
176  res = system2('sh', c(
177    'install-unx.sh', if (repo != 'ctan') c('--no-admin', '--path', shQuote(repo))
178  ))
179  if (res != 0) stop('Failed to install TinyTeX', call. = FALSE)
180  target = normalizePath(default_inst())
181  if (!dir_exists(target)) stop('Failed to install TinyTeX.')
182  if (!dir %in% c('', target)) {
183    dir.create(dirname(dir), showWarnings = FALSE, recursive = TRUE)
184    dir_rename(target, dir)
185    target = dir
186  }
187  opts = options(tinytex.tlmgr.path = find_tlmgr(target))
188  on.exit(options(opts), add = TRUE)
189  post_install_config(add_path, extra_packages, repo)
190  unlink(c('install-unx.sh', 'install-tl.zip', 'pkgs-custom.txt', 'tinytex.profile'))
191  target
192}
193
194os_index = if (is_windows()) 1 else if (is_linux()) 2 else if (is_macos()) 3 else 0
195
196default_inst = function() switch(
197  os_index, win_app_dir('TinyTeX'), '~/.TinyTeX', '~/Library/TinyTeX'
198)
199
200find_tlmgr = function(dir = default_inst()) {
201  bin = file.path(list.files(file.path(dir, 'bin'), full.names = TRUE), 'tlmgr')
202  if (is_windows()) bin = paste0(bin, '.bat')
203  bin[file_test('-x', bin)][1]
204}
205
206#' @rdname install_tinytex
207#' @export
208uninstall_tinytex = function(force = FALSE, dir = tinytex_root()) {
209  tweak_path()
210  if (dir == '') stop('TinyTeX does not seem to be installed.')
211  if (!is_tinytex() && !force) stop(
212    'Detected TeX Live at "', dir, '", but it appears to be TeX Live instead of TinyTeX. ',
213    'To uninstall TeX Live, use the argument force = TRUE.'
214  )
215  r_texmf('remove', .quiet = TRUE)
216  tlmgr_path('remove')
217  delete_texmf_user()
218  unlink(dir, recursive = TRUE)
219}
220
221# delete user's texmf tree; don't delete ~/.TinyTeX if TinyTeX itself is
222# installed there
223delete_texmf_user = function() {
224  r = dir.exists(d <- path.expand('~/.TinyTeX'))
225  if (!r) return(FALSE)
226  d1 = xfun::normalize_path(tinytex_root(error = FALSE))
227  if (d1 == '') return()  # not TinyTeX
228  d2 = xfun::normalize_path(d)
229  if (substr(d1, 1, nchar(d2)) == d2) return(FALSE)
230  unlink(d, recursive = TRUE)
231  r
232}
233
234#' @param packages Whether to reinstall all currently installed packages.
235#' @param ... Other arguments to be passed to \code{install_tinytex()} (note
236#'   that the \code{extra_packages} argument will be set to \code{tl_pkgs()} if
237#'   \code{packages = TRUE}).
238#' @rdname install_tinytex
239#' @export
240reinstall_tinytex = function(packages = TRUE, dir = tinytex_root(), ...) {
241  pkgs = if (packages) tl_pkgs()
242  if (length(pkgs)) message(
243    'If reinstallation fails, try install_tinytex() again. Then ',
244    'install the following packages:\n\ntinytex::tlmgr_install(c(',
245    paste('"', pkgs, '"', sep = '', collapse = ', '), '))\n'
246  )
247  # in theory, users should not touch the texmf-local dir; if they did, I'll try
248  # to preserve it during reinstall: https://github.com/yihui/tinytex/issues/117
249  if (length(list.files(texmf <- file.path(dir, 'texmf-local'), recursive = TRUE)) > 0) {
250    dir.create(texmf_tmp <- tempfile(), recursive = TRUE)
251    message(
252      'The directory ', texmf, ' is not empty. It will be backed up to ',
253      texmf_tmp, ' and restored later.\n'
254    )
255    file.copy(texmf, texmf_tmp, recursive = TRUE)
256    on.exit(
257      file.copy(file.path(texmf_tmp, basename(texmf)), dirname(texmf), recursive = TRUE),
258      add = TRUE
259    )
260  }
261  uninstall_tinytex()
262  install_tinytex(extra_packages = pkgs, dir = dir, ...)
263}
264
265#' @param error Whether to signal an error if TinyTeX is not found.
266#' @rdname install_tinytex
267#' @export
268tinytex_root = function(error = TRUE) {
269  tweak_path()
270  path = Sys.which('tlmgr')
271  if (path == '') return('')
272  root_dir = function(path, ...) {
273    dir = normalizePath(file.path(dirname(path), ...), mustWork = TRUE)
274    if (!'bin' %in% list.files(dir)) if (error) stop(
275      dir, ' does not seem to be the root directory of TeX Live (no "bin/" dir under it)'
276    ) else return('')
277    dir
278  }
279  if (os == 'windows') return(root_dir(path, '..', '..'))
280  if (Sys.readlink(path) == '') if (error) stop(
281    'Cannot figure out the root directory of TeX Live from ', path,
282    ' (not a symlink on ', os, ')'
283  ) else return('')
284  path = symlink_root(path)
285  root_dir(normalizePath(path), '..', '..', '..')
286}
287
288# trace a symlink to its final destination
289symlink_root = function(path) {
290  path = normalizePath(path, mustWork = TRUE)
291  path2 = Sys.readlink(path)
292  if (path2 == '') return(path)  # no longer a symlink; must be resolved now
293  # path2 may still be a _relative_ symlink
294  in_dir(dirname(path), symlink_root(path2))
295}
296
297# a helper function to open tlmgr.pl (on *nix)
298open_tlmgr = function() {
299  file.edit(symlink_root(Sys.which('tlmgr')))
300}
301
302#' Check if the LaTeX installation is TinyTeX
303#'
304#' First find the root directory of the installation via
305#' \code{\link{tinytex_root}()}. Then check if the directory name is
306#' \code{"tinytex"} (case-insensitive). If not, further check if the first line
307#' of the file \file{texmf-dist/web2c/fmtutil.cnf} under the directory contains
308#' \code{"TinyTeX"} or \code{".TinyTeX"}. If the binary version of TinyTeX was
309#' installed, \file{fmtutil.cnf} should contain a line like \samp{Generated by
310#' */TinyTeX/bin/x86_64-darwin/tlmgr on Thu Sep 17 07:13:28 2020}.
311#' @return A logical value indicating if the LaTeX installation is TinyTeX.
312#' @export
313#' @examples tinytex::is_tinytex()
314is_tinytex = function() tryCatch({
315  root = tinytex_root()
316  root != '' && (gsub('^[.]', '', tolower(basename(root))) == 'tinytex' || any(grepl(
317    '\\W[.]?TinyTeX\\W',
318    readLines(file.path(root, 'texmf-dist/web2c/fmtutil.cnf'), n = 1)
319  )))
320}, error = function(e) FALSE)
321
322dir_rename = function(from, to) {
323  # cannot rename '/foo' to '/bar' because of 'Invalid cross-device link'
324  suppressWarnings(file.rename(from, to)) || dir_copy(from, to)
325}
326
327dir_copy = function(from, to) {
328  dir.create(to, showWarnings = FALSE, recursive = TRUE)
329  all(file.copy(list.files(from, full.names = TRUE), to, recursive = TRUE)) &&
330    unlink(from, recursive = TRUE) == 0
331}
332
333download_file = function(...) {
334  xfun::download_file(..., quiet = Sys.getenv('APPVEYOR') != '')
335}
336
337# LaTeX packages that I use
338install_yihui_pkgs = function() {
339  pkgs = readLines('https://yihui.org/gh/tinytex/tools/pkgs-yihui.txt')
340  tlmgr_install(pkgs)
341}
342
343# install a prebuilt version of TinyTeX
344install_prebuilt = function(
345  pkg = '', dir = '', version = '', add_path = TRUE, extra_packages = NULL,
346  repo = 'ctan', hash = FALSE, cache = NA
347) {
348  if (need_source_install()) stop(
349    'There is no prebuilt version of TinyTeX for this platform: ',
350    paste(Sys.info()[c('sysname', 'machine')], collapse = ' '), '.'
351  )
352  dir0 = default_inst(); b = basename(dir0)
353  dir1 = xfun::normalize_path(dir)  # expected installation dir
354  if (dir1 == '') dir1 = dir0
355  # the archive is extracted to this target dir
356  target = dirname(dir1)
357  dir2 = file.path(target, b)  # path to (.)TinyTeX/ after extraction
358
359  if (xfun::file_ext(pkg) == '') {
360    if (version == 'latest') {
361      version = xfun::github_releases('yihui/tinytex-releases', version)
362    } else if (version == 'daily-github') {
363      version = ''
364      opts = options(tinytex.install.url = 'https://github.com/yihui/tinytex-releases/releases/download/daily/')
365      on.exit(options(opts), add = TRUE)
366    }
367    version = gsub('^v', '', version)
368    installer = if (pkg == '') 'TinyTeX' else pkg
369    # e.g., TinyTeX-0.zip, TinyTeX-1-v2020.10.tar.gz, ...
370    pkg = paste0(
371      installer, if (version != '') paste0('-v', version), '.',
372      c('zip', 'tar.gz', 'tgz')[os_index]
373    )
374    if (file.exists(pkg) && is.na(cache)) {
375      # invalidate cache (if unspecified) when the installer is more than one day old
376      if (as.numeric(difftime(Sys.time(), file.mtime(pkg), units = 'days')) > 1)
377        cache = FALSE
378    }
379    if (xfun::isFALSE(cache)) {
380      file.remove(pkg); on.exit(file.remove(pkg), add = TRUE)
381    }
382    if (!file.exists(pkg)) download_installer(pkg, version)
383  }
384  pkg = path.expand(pkg)
385
386  # installation dir shouldn't be a file but a directory
387  file.remove(exist_files(c(dir1, dir2)))
388  extract = if (grepl('[.]zip$', pkg)) unzip else untar
389  extract(pkg, exdir = path.expand(target))
390  # TinyTeX (or .TinyTeX) is extracted to the parent dir of `dir`; may need to rename
391  if (dir != '') {
392    if (basename(dir1) != b) file.rename(dir2, dir1)
393    opts = options(tinytex.tlmgr.path = find_tlmgr(dir1))
394    on.exit(options(opts), add = TRUE)
395  }
396  post_install_config(add_path, extra_packages, repo, hash)
397  invisible(dir1)
398}
399
400# post-install configurations
401post_install_config = function(add_path, extra_packages, repo, hash = FALSE) {
402  if (os_index == 2) {
403    dir.create('~/bin', FALSE, TRUE)
404    tlmgr(c('option', 'sys_bin', '~/bin'))
405  }
406  # fix fonts.conf: https://github.com/yihui/tinytex/issues/313
407  tlmgr(c('postaction', 'install', 'script', 'xetex'), .quiet = TRUE)
408  # do not wrap lines in latex log (#322)
409  tlmgr_conf(c('texmf', 'max_print_line', '10000'), .quiet = TRUE, stdout = FALSE)
410
411  if (add_path) tlmgr_path()
412  r_texmf(.quiet = TRUE)
413  # don't use the default random ctan mirror when installing on CI servers
414  if (repo != 'ctan' || tolower(Sys.getenv('CI')) != 'true')
415    tlmgr_repo(repo, stdout = FALSE, .quiet = TRUE)
416  tlmgr_install(setdiff(extra_packages, tl_pkgs()))
417  if (hash) {
418    texhash(); fmtutil(stdout = FALSE); updmap(); fc_cache()
419  }
420}
421
422download_installer = function(file, version) {
423  url = if (version != '') sprintf(
424    'https://github.com/yihui/tinytex-releases/releases/download/v%s/%s', version, file
425  ) else paste0(getOption('tinytex.install.url', 'https://yihui.org/tinytex/'), file)
426  download_file(url, file)
427}
428
429#' Copy TinyTeX to another location and use it in another system
430#'
431#' The function \code{copy_tinytex()} copies the existing TinyTeX installation
432#' to another directory (e.g., a portable device like a USB stick). The function
433#' \code{use_tinytex()} runs \command{tlmgr path add} to add the copy of TinyTeX
434#' in an existing folder to the \code{PATH} variable of the current system, so
435#' that you can use utilities such as \command{tlmgr} and \command{pdflatex},
436#' etc.
437#' @param from The root directory of the TinyTeX installation. For
438#'   \code{copy_tinytex()}, the default value \code{tinytex_root()} should be a
439#'   reasonable guess if you installed TinyTeX via \code{install_tinytex()}. For
440#'   \code{use_tinytex()}, if \code{from} is not provided, a dialog for choosing
441#'   the directory interactively will pop up.
442#' @param to The destination directory where you want to make a copy of TinyTeX.
443#'   Like \code{from} in \code{use_tinytex()}, a dialog will pop up if \code{to}
444#'   is not provided in \code{copy_tinytex()}.
445#' @note You can only copy TinyTeX and use it in the same system, e.g., the
446#'   Windows version of TinyTeX only works on Windows.
447#' @export
448copy_tinytex = function(from = tinytex_root(), to = select_dir('Select Destination Directory')) {
449  if (!dir_exists(from)) stop('TinyTeX does not seem to be installed.')
450  if (length(to) != 1 || !dir_exists(to))
451    stop("The destination directory '", to, "' does not exist.")
452  file.copy(from, to, recursive = TRUE)
453}
454
455#' @rdname copy_tinytex
456#' @export
457use_tinytex = function(from = select_dir('Select TinyTeX Directory')) {
458  if (length(from) != 1) stop('Please provide a valid path to the TinyTeX directory.')
459  d = list.files(file.path(from, 'bin'), full.names = TRUE)
460  d = d[dir_exists(d)]
461  if (length(d) != 1) stop("The directory '", from, "' does not contain TinyTeX.")
462  p = file.path(d, 'tlmgr')
463  if (os == 'windows') p = paste0(p, '.bat')
464  if (system2(p, c('path', 'add')) != 0) stop(
465    "Failed to add '", d, "' to your system's environment variable PATH. You may ",
466    "consider the fallback approach, i.e., set options(tinytex.tlmgr.path = '", p, "')."
467  )
468  message('Restart R and your editor and check if tinytex::tinytex_root() points to ', from)
469}
470
471select_dir = function(caption = 'Select Directory') {
472  d = tryCatch(rstudioapi::selectDirectory(caption), error = function(e) {
473    if (os == 'windows') utils::choose.dir(caption = caption) else {
474      tcltk::tk_choose.dir(caption = caption)
475    }
476  })
477  if (!is.null(d) && !is.na(d)) d
478}
479