beatrina 0.8.6
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.
- package/LICENSE +92 -0
- package/NOTICES +72 -0
- package/README.md +124 -0
- package/bin/beatrina.mjs +223 -0
- package/bin/cli.mjs +80 -0
- package/bin/failsafe.mjs +74 -0
- package/bin/identity.mjs +45 -0
- package/bin/prova-post.mjs +51 -0
- package/bin/sessions.mjs +95 -0
- package/bin/shortcut.mjs +151 -0
- package/bin/update-check.mjs +55 -0
- package/bin/upgrade.mjs +76 -0
- package/build-info.json +1 -0
- package/carmar_V0.8.6.html +1310 -0
- package/check/acceptance.mjs +278 -0
- package/check/session.mjs +215 -0
- package/engines/js/document-exec.mjs +82 -0
- package/engines/js/persist.mjs +214 -0
- package/engines/js/worker.mjs +424 -0
- package/engines/python/adapter.py +577 -0
- package/engines/python/analyze.py +814 -0
- package/engines/python/bootstrap.py +309 -0
- package/engines/python/dataview.py +735 -0
- package/engines/python/debugger.py +346 -0
- package/engines/python/document_exec.py +158 -0
- package/engines/python/engine.json +28 -0
- package/engines/python/handoff.py +118 -0
- package/engines/python/worker.py +564 -0
- package/engines/r/engine.json +25 -0
- package/engines/r/handoff.R +92 -0
- package/failsafe/ai-policy.R +255 -0
- package/failsafe/ai-store.R +373 -0
- package/failsafe/cite.R +418 -0
- package/failsafe/journal.R +684 -0
- package/failsafe/plugins.R +809 -0
- package/failsafe/serve.R +5500 -0
- package/host/ai-policy.mjs +218 -0
- package/host/deployment.mjs +160 -0
- package/host/engine-js.mjs +98 -0
- package/host/engine-pool.mjs +383 -0
- package/host/engine-python.mjs +228 -0
- package/host/engine-r.mjs +206 -0
- package/host/engine-stdio.mjs +401 -0
- package/host/journal-store.mjs +749 -0
- package/host/main.mjs +503 -0
- package/host/planes/README.md +41 -0
- package/host/planes/ai-store.mjs +327 -0
- package/host/planes/ai.mjs +467 -0
- package/host/planes/analyze.mjs +397 -0
- package/host/planes/cite.mjs +517 -0
- package/host/planes/files.mjs +0 -0
- package/host/planes/jobs.mjs +704 -0
- package/host/planes/journal.mjs +53 -0
- package/host/planes/latex.mjs +201 -0
- package/host/planes/mcp.mjs +493 -0
- package/host/planes/pair.mjs +325 -0
- package/host/planes/pipe-term.mjs +122 -0
- package/host/planes/plugins.mjs +112 -0
- package/host/planes/proc-tree.mjs +76 -0
- package/host/planes/sessions.mjs +434 -0
- package/host/planes/settings.mjs +164 -0
- package/host/planes/terminal.mjs +286 -0
- package/host/planes/test-file.mjs +80 -0
- package/host/planes/update.mjs +214 -0
- package/host/plugin-store.mjs +838 -0
- package/host/server.mjs +441 -0
- package/host/settings.mjs +379 -0
- package/host/update-record.mjs +59 -0
- package/host/user-dirs.mjs +117 -0
- package/host/windows-runtime.mjs +145 -0
- package/host/worker-plane.mjs +713 -0
- package/host/ws.mjs +190 -0
- package/kernel/analyze.R +668 -0
- package/kernel/deployment.R +165 -0
- package/kernel/examples/NOTICE.md +38 -0
- package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
- package/kernel/fileio.R +656 -0
- package/kernel/index.html +96 -0
- package/kernel/job-run.R +391 -0
- package/kernel/jobs.R +276 -0
- package/kernel/kernel-protocol +1 -0
- package/kernel/kernel-version +1 -0
- package/kernel/kernel.R +671 -0
- package/kernel/knitr-run.R +245 -0
- package/kernel/latex.R +609 -0
- package/kernel/mcp/carmar-mcp.mjs +516 -0
- package/kernel/notebook-page.R +67 -0
- package/kernel/plugins/csl/apa/apa.csl +2273 -0
- package/kernel/plugins/csl/apa/plugin.json +19 -0
- package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
- package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
- package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
- package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
- package/kernel/plugins/csl/ieee/ieee.csl +519 -0
- package/kernel/plugins/csl/ieee/plugin.json +19 -0
- package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
- package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
- package/kernel/plugins/csl/nature/nature.csl +189 -0
- package/kernel/plugins/csl/nature/plugin.json +19 -0
- package/kernel/plugins/latex/apa7/apa7.json +14 -0
- package/kernel/plugins/latex/apa7/plugin.json +19 -0
- package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
- package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
- package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
- package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
- package/kernel/project.R +131 -0
- package/kernel/settings.R +410 -0
- package/kernel/sniff.R +769 -0
- package/kernel/worker-boot.R +22 -0
- package/kernel/worker.R +3496 -0
- package/lib/agent-authoring-contract.js +547 -0
- package/lib/cell-kinds.js +108 -0
- package/lib/engine-labels.js +324 -0
- package/package.json +32 -0
package/kernel/latex.R
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
# latex.R — LaTeX compile decisions and diagnostics for the existing Jobs plane.
|
|
2
|
+
# Used by the supervisor's job spec and the isolated job-run.R child.
|
|
3
|
+
# Origin: carmar-latex-plugin/host/workbench.mjs compilerSpec and
|
|
4
|
+
# parseCompilerDiagnostics; SHA-256 fd168f3c2a70d5f010542b8c080e9173d03cb4e8e40862e7d957731f2fee519b
|
|
5
|
+
# Captured argv and observed log results: test/fixtures/latex/compiler.json.
|
|
6
|
+
# These are prototype inputs to review, not a validated engine contract.
|
|
7
|
+
# WP4 must add !/l.N diagnostics, safe output resolution, discovery,
|
|
8
|
+
# argv-only execution and tested process-group Stop through Jobs.
|
|
9
|
+
#
|
|
10
|
+
# function compilerSpec(engine, binary, rootAbsolute, outputDirectory) {
|
|
11
|
+
# const cwd = path.dirname(rootAbsolute);
|
|
12
|
+
# const input = path.basename(rootAbsolute);
|
|
13
|
+
# if (engine === "tectonic") {
|
|
14
|
+
# return {
|
|
15
|
+
# cwd,
|
|
16
|
+
# args: ["--keep-logs", "--synctex", "--outdir", outputDirectory, rootAbsolute]
|
|
17
|
+
# };
|
|
18
|
+
# }
|
|
19
|
+
# if (engine === "latexmk") {
|
|
20
|
+
# return {
|
|
21
|
+
# cwd,
|
|
22
|
+
# args: ["-pdf", "-interaction=nonstopmode", "-file-line-error", "-synctex=1", "-outdir=" + outputDirectory, input]
|
|
23
|
+
# };
|
|
24
|
+
# }
|
|
25
|
+
# return {
|
|
26
|
+
# cwd,
|
|
27
|
+
# args: ["-interaction=nonstopmode", "-file-line-error", "-synctex=1", "-output-directory=" + outputDirectory, input]
|
|
28
|
+
# };
|
|
29
|
+
# }
|
|
30
|
+
#
|
|
31
|
+
|
|
32
|
+
LATEX_ENGINES <- c("tectonic", "latexmk", "pdflatex", "xelatex", "lualatex")
|
|
33
|
+
LATEX_TIMEOUT <- 180L
|
|
34
|
+
LATEX_DIAGNOSTIC_MAX <- 500L
|
|
35
|
+
LATEX_CITATION_MAX <- 100L
|
|
36
|
+
# TeX's max_print_line: a log line of exactly this width has been hard-wrapped
|
|
37
|
+
# and continues on the next line, with no marker (LEARNINGS 2026-09-07).
|
|
38
|
+
LATEX_LOG_WRAP <- 79L
|
|
39
|
+
|
|
40
|
+
#' Compile one saved root through the shared streaming process owner.
|
|
41
|
+
#' @param target Absolute source path.
|
|
42
|
+
#' @param engine Engine choice from latex_options.
|
|
43
|
+
#' @param timeout Total deadline in seconds, including repeat passes.
|
|
44
|
+
#' @param job_id Supervisor-issued job identifier.
|
|
45
|
+
#' @param run_streaming Existing Jobs process runner.
|
|
46
|
+
#' @param emit Existing Jobs frame writer.
|
|
47
|
+
#' @param fetch Allow this ONE compile to download missing packages.
|
|
48
|
+
#' @return Invisibly the verified PDF path; errors preserve diagnostic frames.
|
|
49
|
+
latex_compile <- function(target, engine, timeout, job_id, run_streaming, emit, fetch = FALSE) {
|
|
50
|
+
stopifnot(latex_scalar(target), latex_scalar(job_id), is.function(run_streaming), is.function(emit))
|
|
51
|
+
latex_options(list(engine = engine, timeout = timeout, fetch = fetch))
|
|
52
|
+
found <- latex_discover(engine)
|
|
53
|
+
# ONE build folder per root, reused: aux files make the second pass fast,
|
|
54
|
+
# and nothing accumulates. Until 7.18 every compile made its own
|
|
55
|
+
# `compile-*` folder and kept it, so a hundred compiles were a hundred PDFs
|
|
56
|
+
# inside the user's own repository. The PDF itself lands BESIDE the root as
|
|
57
|
+
# `<stem>.pdf`, which is where every TeX tool puts it and where a reader
|
|
58
|
+
# looks for it; it is replaced only by a successful run, atomically, so a
|
|
59
|
+
# failed compile leaves the previous PDF exactly where it was.
|
|
60
|
+
stem <- sub("\\.[^.]+$", "", basename(target))
|
|
61
|
+
output <- file.path(dirname(target), ".carmar-latex", stem)
|
|
62
|
+
if (!dir.exists(output)) dir.create(output, recursive = TRUE, showWarnings = FALSE)
|
|
63
|
+
if (!dir.exists(output)) stop("Cannot create the LaTeX build folder.", call. = FALSE)
|
|
64
|
+
spec <- latex_argv(found$engine, target, output, fetch = fetch)
|
|
65
|
+
if (isTRUE(fetch)) {
|
|
66
|
+
cat("Fetching missing packages for this compile (this one reaches the network).\n")
|
|
67
|
+
flush(stdout())
|
|
68
|
+
}
|
|
69
|
+
pdf <- file.path(output, paste0(stem, ".pdf"))
|
|
70
|
+
final <- file.path(dirname(target), paste0(stem, ".pdf"))
|
|
71
|
+
# A PDF left in the build folder by an earlier run must not pass for this
|
|
72
|
+
# run's: an engine that exits 0 without writing one is a failed compile.
|
|
73
|
+
if (file.exists(pdf)) unlink(pdf)
|
|
74
|
+
log_path <- file.path(output, paste0(stem, ".log"))
|
|
75
|
+
started_at <- Sys.time(); pass <- 0L; result <- NULL
|
|
76
|
+
repeat {
|
|
77
|
+
remaining <- timeout - as.numeric(difftime(Sys.time(), started_at, units = "secs"))
|
|
78
|
+
if (remaining <= 0) stop(sprintf("LaTeX compile timed out after %s seconds.", timeout), call. = FALSE)
|
|
79
|
+
pass <- pass + 1L
|
|
80
|
+
cat(sprintf("Compiling with %s (pass %d)\n", found$engine, pass)); flush(stdout())
|
|
81
|
+
result <- run_streaming(found$binary, spec$args, wd = spec$wd, timeout = remaining)
|
|
82
|
+
if (isTRUE(result$timed_out) || !identical(as.integer(result$status), 0L) || pass >= 3L ||
|
|
83
|
+
found$engine %in% c("tectonic", "latexmk") ||
|
|
84
|
+
!any(grepl("Rerun|run LaTeX again|Label.*changed", result$lines, ignore.case = TRUE))) break
|
|
85
|
+
}
|
|
86
|
+
# Some engines write diagnostics only to the log. Read at most its final MB;
|
|
87
|
+
# the Jobs runner already bounds the retained stdout by the same limit.
|
|
88
|
+
log_lines <- if (file.exists(log_path)) local({
|
|
89
|
+
con <- file(log_path, "rb"); on.exit(close(con))
|
|
90
|
+
size <- file.info(log_path)$size
|
|
91
|
+
seek(con, max(0, size - 1000000), origin = "start")
|
|
92
|
+
value <- iconv(rawToChar(readBin(con, "raw", n = 1000000L)), from = "UTF-8", to = "UTF-8", sub = "")
|
|
93
|
+
strsplit(value, "\n", fixed = TRUE)[[1L]]
|
|
94
|
+
}) else character()
|
|
95
|
+
# Successful multi-pass engines leave the authoritative final pass in .log.
|
|
96
|
+
# Keep earlier passes in Jobs output, but do not publish resolved warnings.
|
|
97
|
+
diagnostic_lines <- if (identical(as.integer(result$status), 0L) && length(log_lines)) log_lines else c(result$lines, log_lines)
|
|
98
|
+
# Parsed once and used twice: the pane gets the rows, and the failure
|
|
99
|
+
# sentence gets to say what is in the pane rather than pointing at the log.
|
|
100
|
+
diagnostics <- latex_log_diagnostics(diagnostic_lines, target)
|
|
101
|
+
emit(list(type = "job", event = "diagnostics", rows = I(diagnostics),
|
|
102
|
+
root = target, truncated = isTRUE(result$truncated)))
|
|
103
|
+
# What could be DONE about it, sent as a fact beside the rows rather than
|
|
104
|
+
# left for the page to read out of the hint's prose. Emitted before either
|
|
105
|
+
# `stop()` below, because the frame has to reach the browser whichever way
|
|
106
|
+
# this compile turns out to have failed — and emitted for a SUCCESS too, so
|
|
107
|
+
# a page never carries a stale offer from the previous run.
|
|
108
|
+
fix <- latex_failure_fix(diagnostic_lines, found$engine)
|
|
109
|
+
emit(list(type = "job", event = "fix", fix = fix$fix, assets = I(fix$assets),
|
|
110
|
+
fetched = isTRUE(fetch)))
|
|
111
|
+
# The bibliography's verdict, as a list beside the diagnostics. A missing
|
|
112
|
+
# `.bst` under Tectonic is a compile that EXITS 0 with every citation `[?]`
|
|
113
|
+
# (LEARNINGS 2026-09-08), so this is read for a success above all — and
|
|
114
|
+
# sent for every compile, so a page never keeps the previous run's band.
|
|
115
|
+
# BibTeX's transcript is the `.blg`; Tectonic also echoes it to stdout.
|
|
116
|
+
blg_path <- file.path(output, paste0(stem, ".blg"))
|
|
117
|
+
blg_lines <- if (file.exists(blg_path)) readLines(blg_path, warn = FALSE, encoding = "UTF-8") else character()
|
|
118
|
+
citations <- latex_citation_problems(diagnostic_lines, c(blg_lines, result$lines), found$engine, fetch = isTRUE(fetch))
|
|
119
|
+
emit(list(type = "job", event = "citations", rows = I(citations), root = target))
|
|
120
|
+
if (isTRUE(result$timed_out)) stop(sprintf("LaTeX compile timed out after %s seconds.", timeout), call. = FALSE)
|
|
121
|
+
if (!identical(as.integer(result$status), 0L)) stop(sprintf("%s exited %s. %s", found$engine, result$status,
|
|
122
|
+
latex_failure_hint(diagnostic_lines, found$engine, target, diagnostics)), call. = FALSE)
|
|
123
|
+
valid_pdf <- file.exists(pdf) && !dir.exists(pdf) && local({
|
|
124
|
+
con <- file(pdf, "rb"); on.exit(close(con))
|
|
125
|
+
identical(readBin(con, "raw", n = 5L), charToRaw("%PDF-"))
|
|
126
|
+
})
|
|
127
|
+
# With the engine no longer halting, "exit 0 and no PDF" is what a genuinely
|
|
128
|
+
# fatal document looks like — so this branch, not the exit status, is now the
|
|
129
|
+
# main failure path and needs the sentence that names the fix.
|
|
130
|
+
if (!valid_pdf) stop(sprintf("The compiler finished without producing a PDF. %s",
|
|
131
|
+
latex_failure_hint(diagnostic_lines, found$engine, target, diagnostics)), call. = FALSE)
|
|
132
|
+
# Copy into a sibling temp name, then rename: a reader opening `<stem>.pdf`
|
|
133
|
+
# sees the old PDF or the new one, never a half-written file.
|
|
134
|
+
staging <- tempfile(pattern = paste0(".", stem, "-"), tmpdir = dirname(target), fileext = ".pdf")
|
|
135
|
+
placed <- file.copy(pdf, staging, overwrite = TRUE) && file.rename(staging, final)
|
|
136
|
+
if (!placed) { if (file.exists(staging)) unlink(staging); stop("The PDF was built but could not be placed beside the source.", call. = FALSE) }
|
|
137
|
+
final <- normalizePath(final, mustWork = TRUE)
|
|
138
|
+
# The count travels with the output so the PDF can be labelled. A compile
|
|
139
|
+
# that produced a document TeX had to improvise parts of is a success with a
|
|
140
|
+
# caveat, and the caveat is the whole reason continuing is allowed at all.
|
|
141
|
+
errors <- length(Filter(function(row) identical(row$severity, "error"), diagnostics))
|
|
142
|
+
emit(list(type = "job", event = "output", renderer = found$engine, path = final, format = "pdf",
|
|
143
|
+
errors = errors, citations = length(citations)))
|
|
144
|
+
invisible(final)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#' Check a scalar string before accepting an engine or path.
|
|
148
|
+
#' @param value Candidate value.
|
|
149
|
+
#' @return Logical scalar.
|
|
150
|
+
latex_scalar <- function(value) is.character(value) && length(value) == 1L && !is.na(value)
|
|
151
|
+
|
|
152
|
+
#' Validate the data-only options carried from the supervisor to a compile job.
|
|
153
|
+
#' @param opts List with engine, timeout and optional source identity.
|
|
154
|
+
#' @return Named environment strings. Invalid values raise a readable error.
|
|
155
|
+
latex_options <- function(opts = list()) {
|
|
156
|
+
stopifnot(is.list(opts))
|
|
157
|
+
engine <- if (is.null(opts$engine)) "auto" else opts$engine
|
|
158
|
+
timeout <- if (is.null(opts$timeout)) LATEX_TIMEOUT else opts$timeout
|
|
159
|
+
if (!latex_scalar(engine) || !engine %in% c("auto", LATEX_ENGINES)) {
|
|
160
|
+
stop("Choose auto, tectonic, latexmk, pdflatex, xelatex or lualatex.", call. = FALSE)
|
|
161
|
+
}
|
|
162
|
+
if (!is.numeric(timeout) || length(timeout) != 1L || !is.finite(timeout) || timeout != floor(timeout) || timeout < 1 || timeout > 600) {
|
|
163
|
+
stop("Compile timeout must be a whole number from 1 to 600 seconds.", call. = FALSE)
|
|
164
|
+
}
|
|
165
|
+
identity <- opts$identity
|
|
166
|
+
if (!is.null(identity)) {
|
|
167
|
+
number <- function(value) is.numeric(value) && length(value) == 1L && is.finite(value) && value >= 0 && value <= 9007199254740991 && value == floor(value)
|
|
168
|
+
if (!is.list(identity) || !latex_scalar(identity$uri) || !nzchar(identity$uri) || nchar(identity$uri) > 4096L
|
|
169
|
+
|| !number(identity$version) || !number(identity$generation)) stop("Invalid compile source identity.", call. = FALSE)
|
|
170
|
+
identity <- identity[c("uri", "version", "generation")]
|
|
171
|
+
}
|
|
172
|
+
# FETCH is the one option that changes what the compile may TOUCH, so it is
|
|
173
|
+
# strict where the others are lenient: exactly TRUE turns it on, anything
|
|
174
|
+
# else — absent, "1", "yes", NA — is off. An option that means "reach the
|
|
175
|
+
# network" must never be arrived at by coercion.
|
|
176
|
+
fetch <- isTRUE(opts$fetch)
|
|
177
|
+
c(CARMAR_LATEX_ENGINE = engine, CARMAR_LATEX_TIMEOUT = as.character(timeout),
|
|
178
|
+
CARMAR_LATEX_FETCH = if (fetch) "1" else "",
|
|
179
|
+
CARMAR_LATEX_IDENTITY = if (is.null(identity)) "" else jsonlite::toJSON(identity, auto_unbox = TRUE, digits = NA))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#' Is this a usable executable file?
|
|
183
|
+
#' @param path Candidate binary path.
|
|
184
|
+
#' @return Logical scalar.
|
|
185
|
+
latex_executable <- function(path) latex_scalar(path) && nzchar(path) && file.exists(path) && !dir.exists(path) && file.access(path, 1L) == 0L
|
|
186
|
+
|
|
187
|
+
#' Discover an engine through explicit environment override, PATH and installs.
|
|
188
|
+
#' @param engine Engine name or auto.
|
|
189
|
+
#' @param lookup PATH lookup, injectable for deterministic discovery checks.
|
|
190
|
+
#' @param getenv Environment lookup.
|
|
191
|
+
#' @param usable Executable predicate.
|
|
192
|
+
#' @return List with engine and binary, or an error naming the missing engine.
|
|
193
|
+
latex_discover <- function(engine = "auto", lookup = Sys.which, getenv = Sys.getenv, usable = latex_executable) {
|
|
194
|
+
stopifnot(latex_scalar(engine), engine %in% c("auto", LATEX_ENGINES))
|
|
195
|
+
engines <- if (engine == "auto") LATEX_ENGINES else engine
|
|
196
|
+
result <- NULL
|
|
197
|
+
Find(function(name) {
|
|
198
|
+
override <- getenv(paste0("CARMAR_", toupper(name)))
|
|
199
|
+
if (nzchar(override)) {
|
|
200
|
+
if (!usable(override)) stop(sprintf("CARMAR_%s does not name an executable file.", toupper(name)), call. = FALSE)
|
|
201
|
+
result <<- list(engine = name, binary = override)
|
|
202
|
+
return(TRUE)
|
|
203
|
+
}
|
|
204
|
+
candidates <- c(unname(lookup(name)), file.path(c("/opt/homebrew/bin", "/usr/local/bin", "/Library/TeX/texbin", "/usr/bin"), name),
|
|
205
|
+
if (nzchar(getenv("LOCALAPPDATA"))) file.path(getenv("LOCALAPPDATA"), "Programs", "MiKTeX", "miktex", "bin", "x64", paste0(name, ".exe")),
|
|
206
|
+
if (nzchar(getenv("ProgramFiles"))) file.path(getenv("ProgramFiles"), "MiKTeX", "miktex", "bin", "x64", paste0(name, ".exe")))
|
|
207
|
+
hit <- Find(usable, candidates)
|
|
208
|
+
if (is.null(hit)) return(FALSE)
|
|
209
|
+
result <<- list(engine = name, binary = hit)
|
|
210
|
+
TRUE
|
|
211
|
+
}, engines)
|
|
212
|
+
if (is.null(result)) stop(sprintf("%s was not found. Install a TeX engine or set its CARMAR_<ENGINE> binary path.",
|
|
213
|
+
if (engine == "auto") "A LaTeX engine" else engine), call. = FALSE)
|
|
214
|
+
result
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#' Build argv without shell interpolation. Compilation is offline by default.
|
|
218
|
+
#'
|
|
219
|
+
#' `fetch` is the ONE exception and it exists because of a mismatch nobody
|
|
220
|
+
#' chose: `--only-cached` is a strict-offline promise, and on a machine whose
|
|
221
|
+
#' only engine is Tectonic — whose whole design is fetch-on-demand — it means
|
|
222
|
+
#' the first compile of any real paper fails with a list of packages and an
|
|
223
|
+
#' instruction to go and use a terminal. That is a policy misapplied to its
|
|
224
|
+
#' engine rather than a policy doing its job.
|
|
225
|
+
#'
|
|
226
|
+
#' So the promise is restated as the one CarmaR already keeps everywhere else
|
|
227
|
+
#' (spike/cite.R's style repository): the kernel reaches the network only when
|
|
228
|
+
#' the person pressed something that said so. `fetch` is that press. It is
|
|
229
|
+
#' never a default, never a setting, and never sticky — it is passed for ONE
|
|
230
|
+
#' compile, from a button that names what it will do. `--untrusted` is kept
|
|
231
|
+
#' either way; that is the flag that matters for running someone else's .tex.
|
|
232
|
+
#'
|
|
233
|
+
#' @param engine Resolved engine name.
|
|
234
|
+
#' @param target Absolute source path.
|
|
235
|
+
#' @param output Absolute output directory, unique to this job.
|
|
236
|
+
#' @param fetch Allow the engine to download missing packages, this run only.
|
|
237
|
+
#' @return List with working directory and literal argument vector.
|
|
238
|
+
latex_argv <- function(engine, target, output, fetch = FALSE) {
|
|
239
|
+
stopifnot(latex_scalar(engine), engine %in% LATEX_ENGINES, latex_scalar(target), latex_scalar(output))
|
|
240
|
+
# NO -halt-on-error, and Tectonic's equivalent turned OFF, on purpose.
|
|
241
|
+
#
|
|
242
|
+
# TeX's own behaviour for a missing font is to substitute one and carry on
|
|
243
|
+
# with a warning; for a missing package it is to keep going and let the
|
|
244
|
+
# undefined commands surface. Halting throws that away and returns NOTHING —
|
|
245
|
+
# and measured on the owner's own CVs, six of eight failed to produce any PDF
|
|
246
|
+
# for causes TeX was fully able to work around: `lmodern.sty` not in the
|
|
247
|
+
# offline cache, a Latin Modern italic not cached, a `[pdftex]` driver option.
|
|
248
|
+
# With the engine allowed to continue, all six produce a readable PDF.
|
|
249
|
+
#
|
|
250
|
+
# This is only safe because the result is LABELLED. A silently substituted
|
|
251
|
+
# font in a CV is exactly the failure the author finds at the hiring
|
|
252
|
+
# committee, so `latex_compile()` counts the error diagnostics of a
|
|
253
|
+
# successful compile and the PDF pane says how many there were. The rule is
|
|
254
|
+
# the export doctrine one level over: a document that could not carry
|
|
255
|
+
# something says so; it does not quietly change.
|
|
256
|
+
common <- c("-interaction=nonstopmode", "-file-line-error", "-no-shell-escape")
|
|
257
|
+
args <- if (engine == "tectonic") c(if (isTRUE(fetch)) NULL else "--only-cached",
|
|
258
|
+
"--untrusted", "--print", "--keep-logs", "--color", "never",
|
|
259
|
+
"-Z", "continue-on-errors", "--outdir", output, target)
|
|
260
|
+
else if (engine == "latexmk") c("-pdf", "-norc", common, paste0("-outdir=", output), target)
|
|
261
|
+
else c(common, paste0("-output-directory=", output), target)
|
|
262
|
+
list(wd = dirname(target), args = args)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#' The sentence a person can act on, for a missing asset under either engine.
|
|
266
|
+
#'
|
|
267
|
+
#' Tectonic and a local TeX fail the same way for different reasons, so this is
|
|
268
|
+
#' the one place that difference is worded. CarmaR compiles offline by design
|
|
269
|
+
#' (`--only-cached`, so the kernel never touches the network), which means a
|
|
270
|
+
#' Tectonic failure is almost always "not fetched yet" and the fix is one
|
|
271
|
+
#' command; a local TeX failure is "not installed" and the fix is the
|
|
272
|
+
#' distribution's package manager.
|
|
273
|
+
#' @param what The asset, already named (a file, a font).
|
|
274
|
+
#' @param engine Resolved engine name.
|
|
275
|
+
#' @param target Absolute root source path.
|
|
276
|
+
#' @param plural Is `what` more than one thing? Only the verb changes.
|
|
277
|
+
#' @return One sentence.
|
|
278
|
+
latex_missing_hint <- function(what, engine, target, plural = FALSE) {
|
|
279
|
+
are <- if (isTRUE(plural)) "are" else "is"
|
|
280
|
+
if (identical(engine, "tectonic")) {
|
|
281
|
+
# THE BUTTON FIRST, the terminal second. Until 7.19 this sentence named
|
|
282
|
+
# only the terminal, because there was nothing else to name — the page had
|
|
283
|
+
# no way to fetch and the person's only route was out of the application.
|
|
284
|
+
# There is one now (latex_argv's `fetch`, offered by the failure panel), so
|
|
285
|
+
# the sentence points at what is on screen and keeps the command for
|
|
286
|
+
# somebody who would rather, or whose page is not showing the panel.
|
|
287
|
+
sprintf(paste0("%s %s not in Tectonic's package cache. CarmaR compiles offline, so nothing was ",
|
|
288
|
+
"downloaded: use Fetch and compile below to get %s for one compile, or run ",
|
|
289
|
+
"tectonic \"%s\" in a terminal (in %s)."),
|
|
290
|
+
what, are, if (isTRUE(plural)) "them" else "it", basename(target), dirname(target))
|
|
291
|
+
} else {
|
|
292
|
+
sprintf("%s %s not installed. Install %s in your TeX distribution, then compile again.",
|
|
293
|
+
what, are, if (isTRUE(plural)) "them" else "it")
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#' What CarmaR could DO about this failure, as a fact rather than a sentence.
|
|
298
|
+
#'
|
|
299
|
+
#' The hint below is prose for a person; this is the same reading of the same
|
|
300
|
+
#' log for the PAGE, because a page that decides whether to offer a button by
|
|
301
|
+
#' matching English against `latex_missing_hint`'s wording is a page that
|
|
302
|
+
#' breaks when the wording improves. One reader, two renderings — the same
|
|
303
|
+
#' split the export backends make over one parse tree.
|
|
304
|
+
#'
|
|
305
|
+
#' Only ONE fix is machine-actionable today: missing packages under Tectonic,
|
|
306
|
+
#' which CarmaR can fetch for this compile. A local TeX distribution's missing
|
|
307
|
+
#' package is not — installing it is the distribution's business, not a button
|
|
308
|
+
#' this program is entitled to press.
|
|
309
|
+
#'
|
|
310
|
+
#' @param lines Compiler stdout and log lines.
|
|
311
|
+
#' @param engine Resolved engine name.
|
|
312
|
+
#' @return list(fix = "fetch" | "", assets = character()) — `fix` is empty
|
|
313
|
+
#' whenever there is nothing to offer, which is most failures.
|
|
314
|
+
latex_failure_fix <- function(lines, engine) {
|
|
315
|
+
stopifnot(is.character(lines), latex_scalar(engine))
|
|
316
|
+
none <- list(fix = "", assets = character(0))
|
|
317
|
+
if (!identical(engine, "tectonic")) return(none)
|
|
318
|
+
hits <- regmatches(lines, regexpr("File `[^\'#]+\' not found", lines))
|
|
319
|
+
if (!length(hits)) return(none)
|
|
320
|
+
list(fix = "fetch", assets = unique(sub("^File `([^\']+)\' not found$", "\\1", hits)))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#' Turn a failed engine's output into the sentence that names the fix, when
|
|
324
|
+
#' the output names one. The common case is a missing `.sty`/`.cls` under
|
|
325
|
+
#' Tectonic: the compile is offline by design (`--only-cached`, so the kernel
|
|
326
|
+
#' never touches the network), and the package must be fetched once by hand.
|
|
327
|
+
#' "See the compile log" was the whole message until 7.25, and the owner's
|
|
328
|
+
#' first real compile stopped there.
|
|
329
|
+
#'
|
|
330
|
+
#' THE LADDER IS ORDERED BY HOW SPECIFIC THE ADVICE IS, and it ends in the
|
|
331
|
+
#' diagnostics rather than in the log. "See the compile log" points at the
|
|
332
|
+
#' pane that holds LESS than the Problems pane already holds — the log is
|
|
333
|
+
#' thousands of lines of TeX bookkeeping, while `latex_log_diagnostics()` has
|
|
334
|
+
#' already reduced it to a file, a line and a message. When nothing on the
|
|
335
|
+
#' ladder matched but the parse found errors, saying the first one and where
|
|
336
|
+
#' it is beats naming a pane. The bare pointer survives only for a failure
|
|
337
|
+
#' that produced no diagnostics at all, which is the one case where the log
|
|
338
|
+
#' really is all there is.
|
|
339
|
+
#' @param lines Compiler stdout and log lines.
|
|
340
|
+
#' @param engine Resolved engine name.
|
|
341
|
+
#' @param target Absolute root source path.
|
|
342
|
+
#' @param diagnostics Parsed rows from `latex_log_diagnostics()`; optional.
|
|
343
|
+
#' @return One sentence, never empty.
|
|
344
|
+
latex_failure_hint <- function(lines, engine, target, diagnostics = list()) {
|
|
345
|
+
stopifnot(is.character(lines), latex_scalar(engine), latex_scalar(target), is.list(diagnostics))
|
|
346
|
+
# The engine echoes the macro that raised the error (`File \`#1.#2' not
|
|
347
|
+
# found`); a name holding a parameter token is the definition, not a file.
|
|
348
|
+
hits <- regmatches(lines, regexpr("File `[^'#]+' not found", lines))
|
|
349
|
+
if (length(hits)) {
|
|
350
|
+
missing <- unique(sub("^File `([^']+)' not found$", "\\1", hits))
|
|
351
|
+
named <- paste(missing, collapse = ", ")
|
|
352
|
+
return(latex_missing_hint(named, engine, target, plural = length(missing) > 1L))
|
|
353
|
+
}
|
|
354
|
+
# Tectonic is XeTeX: a document that names the pdftex driver
|
|
355
|
+
# (\usepackage[pdftex]{graphicx}, [pdftex]{hyperref}) loads pdftex.def, whose
|
|
356
|
+
# first use of a \pdf... primitive is "Undefined control sequence" at
|
|
357
|
+
# \begin{document} — a driver mismatch, not a typo, and the log says which.
|
|
358
|
+
if (identical(engine, "tectonic") && any(grepl("^! Undefined control sequence", lines)) &&
|
|
359
|
+
any(grepl("\\\\pdf(shellescape|output|pagewidth|pageheight|compresslevel|minorversion|horigin|vorigin)", lines))) {
|
|
360
|
+
return(paste0("an undefined \\pdf... primitive: this document names the pdftex driver ([pdftex] on graphicx or hyperref) ",
|
|
361
|
+
"and Tectonic is XeTeX-based. Remove the [pdftex] option (the driver is detected automatically), then compile again."))
|
|
362
|
+
}
|
|
363
|
+
# A font TeX cannot load is the SAME condition as a missing .sty — the asset
|
|
364
|
+
# is not on this machine — and only the wording differs, which is why it
|
|
365
|
+
# shares latex_missing_hint(). Two spellings reach here:
|
|
366
|
+
#
|
|
367
|
+
# ! Font TU/lmr/m/it/12=[lmroman12-italic]:mapping=tex-text; at 12.0pt not loadable: Metric (TFM) file or installed font not found.
|
|
368
|
+
# ! Font T1/lmr/m/it/12=ec-lmri12 at 12.0pt not loadable: Metric (TFM) file or installed font not found.
|
|
369
|
+
#
|
|
370
|
+
# The name after `=` is what the person has to obtain, so that is what is
|
|
371
|
+
# reported — not the TeX-internal `TU/lmr/m/it/12`, which names a slot in a
|
|
372
|
+
# font table nobody can install. Brackets and the OpenType feature string
|
|
373
|
+
# after `:` are XeTeX's request syntax, not part of the name.
|
|
374
|
+
#
|
|
375
|
+
# THE PHRASE AND THE NAME ARE MATCHED SEPARATELY, because TeX HARD-WRAPS its
|
|
376
|
+
# log at 79 columns with no continuation marker. A long font name pushes the
|
|
377
|
+
# phrase over that edge and it is split mid-word:
|
|
378
|
+
#
|
|
379
|
+
# ! Font TU/lmr/m/it/12=[lmroman12-italic]:mapping=tex-text; at 12.0pt not loada
|
|
380
|
+
# ble: Metric (TFM) file or installed font not found.
|
|
381
|
+
#
|
|
382
|
+
# One regex spanning both would have to match "not loada" — so this rule
|
|
383
|
+
# takes the NAME off the head of the `! Font` line, which is never wrapped
|
|
384
|
+
# away, and confirms the CONDITION anywhere in the output. `Metric (TFM)`
|
|
385
|
+
# survives on the continuation line and appears intact in stdout, so either
|
|
386
|
+
# source answers. The first version required both on one line and silently
|
|
387
|
+
# missed exactly the documents whose font names were longest.
|
|
388
|
+
fonts <- regmatches(lines, regexec("^! Font [^=]+=([^ :]+)", lines))
|
|
389
|
+
named <- unique(vapply(Filter(length, fonts), function(hit) gsub("^\\[|\\]$", "", hit[[2L]]), character(1)))
|
|
390
|
+
if (length(named) && any(grepl("not loadable|Metric \\(TFM\\)", lines))) {
|
|
391
|
+
return(latex_missing_hint(sprintf("the font%s %s", if (length(named) > 1L) "s" else "", paste(named, collapse = ", ")),
|
|
392
|
+
engine, target, plural = length(named) > 1L))
|
|
393
|
+
}
|
|
394
|
+
# Nothing on the ladder matched. The parse usually still found something, and
|
|
395
|
+
# a file and a line beat the name of a pane — see the note above.
|
|
396
|
+
errors <- Filter(function(row) identical(row$severity, "error"), diagnostics)
|
|
397
|
+
if (length(errors)) {
|
|
398
|
+
first <- errors[[1L]]
|
|
399
|
+
return(sprintf("%s in Problems; the first is %s line %d: %s",
|
|
400
|
+
if (length(errors) == 1L) "1 error is listed" else sprintf("%d errors are listed", length(errors)),
|
|
401
|
+
basename(first$path), first$line, sub("[.]+$", "", first$message)))
|
|
402
|
+
}
|
|
403
|
+
"See the compile log."
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#' Normalize compiler messages, retaining 1-based code-point coordinates.
|
|
407
|
+
#' @param lines Compiler stdout or log lines.
|
|
408
|
+
#' @param root Absolute root source path.
|
|
409
|
+
#' @return Bounded diagnostic records; browser conversion owns UTF-16 offsets.
|
|
410
|
+
latex_log_diagnostics <- function(lines, root) {
|
|
411
|
+
stopifnot(is.character(lines), latex_scalar(root))
|
|
412
|
+
rows <- list(); seen <- character(); pending <- NULL; stack <- list()
|
|
413
|
+
current <- function() {
|
|
414
|
+
files <- Filter(function(value) !is.null(value) && nzchar(value), stack)
|
|
415
|
+
if (length(files)) files[[length(files)]] else root
|
|
416
|
+
}
|
|
417
|
+
resolve_file <- function(file) {
|
|
418
|
+
file <- gsub('^"|"$', "", file)
|
|
419
|
+
if (!grepl("^(/|[A-Za-z]:[/\\\\])", file)) file <- file.path(dirname(root), file)
|
|
420
|
+
if (!file.exists(file) && file.exists(paste0(file, ".tex"))) file <- paste0(file, ".tex")
|
|
421
|
+
normalizePath(file, mustWork = FALSE)
|
|
422
|
+
}
|
|
423
|
+
add <- function(file, line, message, severity = "error", col = NA_integer_) {
|
|
424
|
+
file <- resolve_file(file)
|
|
425
|
+
key <- paste(file, line, col, sub("[.]+$", "", trimws(message)), sep = "\r")
|
|
426
|
+
if (!key %in% seen && length(rows) < LATEX_DIAGNOSTIC_MAX) {
|
|
427
|
+
seen <<- c(seen, key)
|
|
428
|
+
rows[[length(rows) + 1L]] <<- list(path = file, line = as.integer(line), col = col,
|
|
429
|
+
message = trimws(message), severity = severity, source = "LaTeX compiler")
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
lapply(lines, function(line) {
|
|
433
|
+
# file-line-error and Tectonic include the file explicitly, including drives.
|
|
434
|
+
hit <- regmatches(line, regexec("^(?:(error|warning):[[:space:]]*)?(.+?):([0-9]+):(?:([0-9]+):)?[[:space:]]*(.+)$", line, perl = TRUE))[[1L]]
|
|
435
|
+
if (length(hit) && (nzchar(hit[[2]]) || grepl("\\.(tex|sty|cls|bib)$", hit[[3]]) || file.exists(resolve_file(hit[[3]])))) {
|
|
436
|
+
add(hit[[3]], as.integer(hit[[4]]), hit[[6]], if (grepl("warning", paste(hit[[2]], hit[[6]]), ignore.case = TRUE)) "warning" else "error",
|
|
437
|
+
if (nzchar(hit[[5]])) as.integer(hit[[5]]) else NA_integer_)
|
|
438
|
+
pending <<- NULL; return(NULL)
|
|
439
|
+
}
|
|
440
|
+
if (startsWith(line, "!")) { pending <<- list(file = current(), message = trimws(sub("^!", "", line))); return(NULL) }
|
|
441
|
+
location <- regmatches(line, regexec("^l\\.([0-9]+)[[:space:]]", line, perl = TRUE))[[1L]]
|
|
442
|
+
if (length(location) && !is.null(pending)) {
|
|
443
|
+
add(pending$file, as.integer(location[[2]]), pending$message); pending <<- NULL; return(NULL)
|
|
444
|
+
}
|
|
445
|
+
warning <- regmatches(line, regexec("(?:LaTeX|Package .+) Warning: (.+?)(?: on input line ([0-9]+))?\\.?$", line, perl = TRUE))[[1L]]
|
|
446
|
+
if (length(warning)) { add(current(), if (nzchar(warning[[3]])) as.integer(warning[[3]]) else 1L, warning[[2]], "warning"); return(NULL) }
|
|
447
|
+
if (grepl("^error:", line) && !grepl("^error: (halted on|unexpectedly halted)", line)) {
|
|
448
|
+
add(current(), 1L, sub("^error:[[:space:]]*", "", line)); return(NULL)
|
|
449
|
+
}
|
|
450
|
+
# Track log parentheses, including anonymous parentheses, so a closing
|
|
451
|
+
# package group returns !/l.N diagnostics to the including source.
|
|
452
|
+
offsets <- gregexpr("[()]", line, perl = TRUE)[[1L]]
|
|
453
|
+
lapply(offsets[offsets > 0L], function(at) {
|
|
454
|
+
if (substr(line, at, at) == ")") { if (length(stack)) stack <<- head(stack, -1L); return(NULL) }
|
|
455
|
+
name <- regmatches(substring(line, at + 1L), regexec('^("[^"\\n]+\\.(?:tex|sty|cls)"|[^()\\n]*?\\.(?:tex|sty|cls))(?=[[:space:])]|$)', substring(line, at + 1L), perl = TRUE))[[1L]]
|
|
456
|
+
if (!length(name)) {
|
|
457
|
+
short <- regmatches(substring(line, at + 1L), regexec('^("[^"]+"|[^[:space:]()]+)', substring(line, at + 1L), perl = TRUE))[[1L]]
|
|
458
|
+
if (length(short) && file.exists(resolve_file(short[[2]]))) name <- short
|
|
459
|
+
}
|
|
460
|
+
stack[[length(stack) + 1L]] <<- if (length(name)) name[[2]] else ""
|
|
461
|
+
})
|
|
462
|
+
NULL
|
|
463
|
+
})
|
|
464
|
+
if (!is.null(pending)) add(pending$file, 1L, pending$message)
|
|
465
|
+
rows
|
|
466
|
+
}
|
|
467
|
+
# function parseCompilerDiagnostics(log, rootRelative) {
|
|
468
|
+
# const diagnostics = [];
|
|
469
|
+
# const seen = new Set();
|
|
470
|
+
# for (const line of log.split(/\r?\n/)) {
|
|
471
|
+
# let match = line.match(/^(?:(error|warning):\s*)?(.+?\.tex):(\d+):\s*(.+)$/i);
|
|
472
|
+
# if (match) {
|
|
473
|
+
# const key = match[2] + ":" + match[3] + ":" + match[4];
|
|
474
|
+
# if (!seen.has(key)) {
|
|
475
|
+
# seen.add(key);
|
|
476
|
+
# diagnostics.push({
|
|
477
|
+
# severity: /warning/i.test(match[1] || match[4]) ? "warning" : "error",
|
|
478
|
+
# path: match[2],
|
|
479
|
+
# line: Number(match[3]),
|
|
480
|
+
# message: match[4].trim()
|
|
481
|
+
# });
|
|
482
|
+
# }
|
|
483
|
+
# continue;
|
|
484
|
+
# }
|
|
485
|
+
# match = line.match(/^error:\s*(.+)$/i);
|
|
486
|
+
# if (match) {
|
|
487
|
+
# if (/^halted on |^unexpectedly halted/i.test(match[1])) continue;
|
|
488
|
+
# const key = rootRelative + ":0:" + match[1];
|
|
489
|
+
# if (!seen.has(key)) {
|
|
490
|
+
# seen.add(key);
|
|
491
|
+
# diagnostics.push({ severity: "error", path: rootRelative, line: 1, message: match[1].trim() });
|
|
492
|
+
# }
|
|
493
|
+
# }
|
|
494
|
+
# }
|
|
495
|
+
# return diagnostics.slice(0, 500);
|
|
496
|
+
# }
|
|
497
|
+
#
|
|
498
|
+
|
|
499
|
+
#' Re-join TeX's hard-wrapped log lines. TeX breaks its transcript at
|
|
500
|
+
#' `max_print_line` (79) with NO continuation marker, splitting a phrase or a
|
|
501
|
+
#' citation key wherever the column falls; a line of exactly that width is one
|
|
502
|
+
#' that was cut. BibTeX's own output (`.blg`) is not wrapped this way, and a
|
|
503
|
+
#' 79-character line that happens to be complete costs nothing but a longer
|
|
504
|
+
#' string, so this is safe to run over both.
|
|
505
|
+
#' @param lines Character vector.
|
|
506
|
+
#' @return Character vector with wrapped continuations glued back on.
|
|
507
|
+
latex_unwrap <- function(lines) {
|
|
508
|
+
stopifnot(is.character(lines))
|
|
509
|
+
if (length(lines) < 2L) return(lines)
|
|
510
|
+
wrapped <- nchar(lines, type = "chars") == LATEX_LOG_WRAP
|
|
511
|
+
# Each line joins onto the previous one whenever the previous was cut;
|
|
512
|
+
# cumsum over "starts a new record" turns the runs into group ids.
|
|
513
|
+
starts <- c(TRUE, !wrapped[-length(wrapped)])
|
|
514
|
+
as.character(tapply(lines, cumsum(starts), paste, collapse = ""))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
#' Bibliography and citation problems that do NOT fail a compile.
|
|
518
|
+
#'
|
|
519
|
+
#' The whole reason this exists: Tectonic runs BibTeX itself and, when BibTeX
|
|
520
|
+
#' cannot open the style file, prints "errors were issued by BibTeX, but were
|
|
521
|
+
#' ignored", finishes the document, and exits 0. Every citation is then `[?]`
|
|
522
|
+
#' and the reference list is empty — a PDF that is WRONG, delivered as a
|
|
523
|
+
#' success. Nothing on the failure ladder sees it because nothing failed.
|
|
524
|
+
#' So the compile reply carries a second, structured list beside the
|
|
525
|
+
#' diagnostics: what BibTeX could not open, what it could not find, and every
|
|
526
|
+
#' key that stayed undefined in the final pass. Parsed from the `.blg` and
|
|
527
|
+
#' the transcript, never from the PDF.
|
|
528
|
+
#'
|
|
529
|
+
#' Two inputs on purpose. BibTeX's messages are read from the `.blg` plus the
|
|
530
|
+
#' engine's stdout (Tectonic's `--print` echoes the `.blg` there, and a local
|
|
531
|
+
#' `latexmk` prints BibTeX's output inline); undefined citations are read from
|
|
532
|
+
#' the FINAL pass only (`tex_lines`, the same lines the diagnostics use), or a
|
|
533
|
+
#' key the second pass resolved would be reported as broken.
|
|
534
|
+
#'
|
|
535
|
+
#' @param tex_lines The final TeX transcript (log on success, stdout+log otherwise).
|
|
536
|
+
#' @param bib_lines BibTeX's output: the `.blg` and the engine's stdout.
|
|
537
|
+
#' @param engine Resolved engine name — decides whether a missing style is
|
|
538
|
+
#' something the engine could fetch.
|
|
539
|
+
#' @param fetch Was this compile allowed to reach the network? A style still
|
|
540
|
+
#' missing after a fetch is not offered another fetch.
|
|
541
|
+
#' @return A list of rows, each `list(kind, file, key, line, message, log,
|
|
542
|
+
#' fetchable)`; `kind` is one of `missing-style`, `missing-bib`,
|
|
543
|
+
#' `missing-entry`, `bibtex-errors`, `undefined-citation`. Empty when the
|
|
544
|
+
#' bibliography is fine. Capped at LATEX_CITATION_MAX.
|
|
545
|
+
latex_citation_problems <- function(tex_lines, bib_lines, engine, fetch = FALSE) {
|
|
546
|
+
stopifnot(is.character(tex_lines), is.character(bib_lines), latex_scalar(engine), is.logical(fetch), length(fetch) == 1L)
|
|
547
|
+
tex_lines <- latex_unwrap(tex_lines); bib_lines <- latex_unwrap(bib_lines)
|
|
548
|
+
rows <- list(); seen <- character()
|
|
549
|
+
add <- function(kind, message, log, file = "", key = "", line = NA_integer_, fetchable = FALSE) {
|
|
550
|
+
id <- paste(kind, file, key, sep = "\r")
|
|
551
|
+
if (id %in% seen || length(rows) >= LATEX_CITATION_MAX) return(invisible(NULL))
|
|
552
|
+
seen <<- c(seen, id)
|
|
553
|
+
rows[[length(rows) + 1L]] <<- list(kind = kind, file = file, key = key, line = line,
|
|
554
|
+
message = message, log = trimws(log), fetchable = isTRUE(fetchable))
|
|
555
|
+
invisible(NULL)
|
|
556
|
+
}
|
|
557
|
+
tectonic <- identical(engine, "tectonic")
|
|
558
|
+
# The sentence names the RECOVERY, and which one depends on the engine and
|
|
559
|
+
# on whether the network was already tried: Tectonic fetches a style it
|
|
560
|
+
# lacks the moment it is allowed to; a local TeX never will.
|
|
561
|
+
style_sentence <- function(file) {
|
|
562
|
+
if (tectonic && !isTRUE(fetch)) sprintf(paste0("Tectonic could not find %s; it fetches it when online — retry with a ",
|
|
563
|
+
"connection, or place the file beside the document."), file)
|
|
564
|
+
else if (tectonic) sprintf("Tectonic could not find %s even when allowed to fetch; place the file beside the document.", file)
|
|
565
|
+
else sprintf("BibTeX could not find %s; install it in your TeX distribution, or place the file beside the document.", file)
|
|
566
|
+
}
|
|
567
|
+
# ── BibTeX's own complaints, in the order a reader can act on them ──────
|
|
568
|
+
styles <- regmatches(bib_lines, regexec("^I couldn't open style file ([^[:space:]]+)", bib_lines))
|
|
569
|
+
lapply(Filter(length, styles), function(hit) add("missing-style", style_sentence(hit[[2L]]), hit[[1L]],
|
|
570
|
+
file = hit[[2L]], fetchable = tectonic && !isTRUE(fetch)))
|
|
571
|
+
bibs <- regmatches(bib_lines, regexec("^I couldn't open database file ([^[:space:]]+)", bib_lines))
|
|
572
|
+
lapply(Filter(length, bibs), function(hit) add("missing-bib",
|
|
573
|
+
sprintf("BibTeX could not open %s; place it beside the document, or point \\bibliography{} at a file that exists.", hit[[2L]]),
|
|
574
|
+
hit[[1L]], file = hit[[2L]]))
|
|
575
|
+
entries <- regmatches(bib_lines, regexec("^Warning--I didn't find a database entry for \"([^\"]+)\"", bib_lines))
|
|
576
|
+
lapply(Filter(length, entries), function(hit) add("missing-entry",
|
|
577
|
+
sprintf("\\cite{%s} has no entry in the .bib files, so it prints as [?].", hit[[2L]]), hit[[1L]], key = hit[[2L]]))
|
|
578
|
+
ignored <- grep("errors were issued by BibTeX, but were ignored", bib_lines, value = TRUE)
|
|
579
|
+
if (length(ignored)) {
|
|
580
|
+
count <- regmatches(bib_lines, regexec("^\\(There (?:was|were) ([0-9]+) error messages?\\)", bib_lines))
|
|
581
|
+
count <- Filter(length, count)
|
|
582
|
+
n <- if (length(count)) as.integer(count[[length(count)]][[2L]]) else NA_integer_
|
|
583
|
+
add("bibtex-errors", sprintf("BibTeX reported %s and the compile carried on without them: the reference list was not built.",
|
|
584
|
+
if (is.na(n)) "errors" else sprintf("%d error%s", n, if (n == 1L) "" else "s")), ignored[[1L]])
|
|
585
|
+
}
|
|
586
|
+
# ── what the final pass still could not resolve ──────────────────────────
|
|
587
|
+
# Head-anchored, then the line number from the tail of the UNWRAPPED line.
|
|
588
|
+
cite_re <- "Warning: Citation `([^']+)' on page [0-9]+ undefined(?: on input line ([0-9]+))?"
|
|
589
|
+
lapply(grep(cite_re, tex_lines, perl = TRUE), function(i) {
|
|
590
|
+
hit <- regmatches(tex_lines[[i]], regexec(cite_re, tex_lines[[i]], perl = TRUE))[[1L]]
|
|
591
|
+
add("undefined-citation",
|
|
592
|
+
sprintf("\\cite{%s} is undefined: it prints as [?] and is missing from the reference list.", hit[[2L]]), tex_lines[[i]],
|
|
593
|
+
key = hit[[2L]], line = if (nzchar(hit[[3L]])) as.integer(hit[[3L]]) else NA_integer_)
|
|
594
|
+
})
|
|
595
|
+
# The summary line alone (a key too long for the wrap to save, an unusual
|
|
596
|
+
# package wording): still a problem, still said.
|
|
597
|
+
summary_line <- grep("Warning: There were undefined citations", tex_lines, value = TRUE)
|
|
598
|
+
if (length(summary_line) && !any(vapply(rows, function(row) identical(row$kind, "undefined-citation"), logical(1)))) {
|
|
599
|
+
add("undefined-citation", "There were undefined citations: they print as [?] and are missing from the reference list.", summary_line[[1L]])
|
|
600
|
+
}
|
|
601
|
+
rows
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
# ── the rapid preview ──────────────────────────────────────────────────────
|
|
605
|
+
#
|
|
606
|
+
# The live preview (a .tex through pandoc to HTML while you type) is answered by
|
|
607
|
+
# the supervisor, host/planes/latex.mjs. It lived here while the R supervisor
|
|
608
|
+
# did; it was retired with serve.R (docs/host-plan.md §13), and nothing in this
|
|
609
|
+
# file's compile half used it.
|