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.
Files changed (114) hide show
  1. package/LICENSE +92 -0
  2. package/NOTICES +72 -0
  3. package/README.md +124 -0
  4. package/bin/beatrina.mjs +223 -0
  5. package/bin/cli.mjs +80 -0
  6. package/bin/failsafe.mjs +74 -0
  7. package/bin/identity.mjs +45 -0
  8. package/bin/prova-post.mjs +51 -0
  9. package/bin/sessions.mjs +95 -0
  10. package/bin/shortcut.mjs +151 -0
  11. package/bin/update-check.mjs +55 -0
  12. package/bin/upgrade.mjs +76 -0
  13. package/build-info.json +1 -0
  14. package/carmar_V0.8.6.html +1310 -0
  15. package/check/acceptance.mjs +278 -0
  16. package/check/session.mjs +215 -0
  17. package/engines/js/document-exec.mjs +82 -0
  18. package/engines/js/persist.mjs +214 -0
  19. package/engines/js/worker.mjs +424 -0
  20. package/engines/python/adapter.py +577 -0
  21. package/engines/python/analyze.py +814 -0
  22. package/engines/python/bootstrap.py +309 -0
  23. package/engines/python/dataview.py +735 -0
  24. package/engines/python/debugger.py +346 -0
  25. package/engines/python/document_exec.py +158 -0
  26. package/engines/python/engine.json +28 -0
  27. package/engines/python/handoff.py +118 -0
  28. package/engines/python/worker.py +564 -0
  29. package/engines/r/engine.json +25 -0
  30. package/engines/r/handoff.R +92 -0
  31. package/failsafe/ai-policy.R +255 -0
  32. package/failsafe/ai-store.R +373 -0
  33. package/failsafe/cite.R +418 -0
  34. package/failsafe/journal.R +684 -0
  35. package/failsafe/plugins.R +809 -0
  36. package/failsafe/serve.R +5500 -0
  37. package/host/ai-policy.mjs +218 -0
  38. package/host/deployment.mjs +160 -0
  39. package/host/engine-js.mjs +98 -0
  40. package/host/engine-pool.mjs +383 -0
  41. package/host/engine-python.mjs +228 -0
  42. package/host/engine-r.mjs +206 -0
  43. package/host/engine-stdio.mjs +401 -0
  44. package/host/journal-store.mjs +749 -0
  45. package/host/main.mjs +503 -0
  46. package/host/planes/README.md +41 -0
  47. package/host/planes/ai-store.mjs +327 -0
  48. package/host/planes/ai.mjs +467 -0
  49. package/host/planes/analyze.mjs +397 -0
  50. package/host/planes/cite.mjs +517 -0
  51. package/host/planes/files.mjs +0 -0
  52. package/host/planes/jobs.mjs +704 -0
  53. package/host/planes/journal.mjs +53 -0
  54. package/host/planes/latex.mjs +201 -0
  55. package/host/planes/mcp.mjs +493 -0
  56. package/host/planes/pair.mjs +325 -0
  57. package/host/planes/pipe-term.mjs +122 -0
  58. package/host/planes/plugins.mjs +112 -0
  59. package/host/planes/proc-tree.mjs +76 -0
  60. package/host/planes/sessions.mjs +434 -0
  61. package/host/planes/settings.mjs +164 -0
  62. package/host/planes/terminal.mjs +286 -0
  63. package/host/planes/test-file.mjs +80 -0
  64. package/host/planes/update.mjs +214 -0
  65. package/host/plugin-store.mjs +838 -0
  66. package/host/server.mjs +441 -0
  67. package/host/settings.mjs +379 -0
  68. package/host/update-record.mjs +59 -0
  69. package/host/user-dirs.mjs +117 -0
  70. package/host/windows-runtime.mjs +145 -0
  71. package/host/worker-plane.mjs +713 -0
  72. package/host/ws.mjs +190 -0
  73. package/kernel/analyze.R +668 -0
  74. package/kernel/deployment.R +165 -0
  75. package/kernel/examples/NOTICE.md +38 -0
  76. package/kernel/examples/tna-complete-tutorial.Rmd +210 -0
  77. package/kernel/fileio.R +656 -0
  78. package/kernel/index.html +96 -0
  79. package/kernel/job-run.R +391 -0
  80. package/kernel/jobs.R +276 -0
  81. package/kernel/kernel-protocol +1 -0
  82. package/kernel/kernel-version +1 -0
  83. package/kernel/kernel.R +671 -0
  84. package/kernel/knitr-run.R +245 -0
  85. package/kernel/latex.R +609 -0
  86. package/kernel/mcp/carmar-mcp.mjs +516 -0
  87. package/kernel/notebook-page.R +67 -0
  88. package/kernel/plugins/csl/apa/apa.csl +2273 -0
  89. package/kernel/plugins/csl/apa/plugin.json +19 -0
  90. package/kernel/plugins/csl/chicago-author-date/chicago-author-date.csl +4216 -0
  91. package/kernel/plugins/csl/chicago-author-date/plugin.json +19 -0
  92. package/kernel/plugins/csl/harvard-cite-them-right/harvard-cite-them-right.csl +316 -0
  93. package/kernel/plugins/csl/harvard-cite-them-right/plugin.json +19 -0
  94. package/kernel/plugins/csl/ieee/ieee.csl +519 -0
  95. package/kernel/plugins/csl/ieee/plugin.json +19 -0
  96. package/kernel/plugins/csl/modern-language-association/modern-language-association.csl +1184 -0
  97. package/kernel/plugins/csl/modern-language-association/plugin.json +19 -0
  98. package/kernel/plugins/csl/nature/nature.csl +189 -0
  99. package/kernel/plugins/csl/nature/plugin.json +19 -0
  100. package/kernel/plugins/latex/apa7/apa7.json +14 -0
  101. package/kernel/plugins/latex/apa7/plugin.json +19 -0
  102. package/kernel/plugins/latex/elsarticle/elsarticle.json +14 -0
  103. package/kernel/plugins/latex/elsarticle/plugin.json +19 -0
  104. package/kernel/plugins/latex/ieeetran/ieeetran.json +10 -0
  105. package/kernel/plugins/latex/ieeetran/plugin.json +19 -0
  106. package/kernel/project.R +131 -0
  107. package/kernel/settings.R +410 -0
  108. package/kernel/sniff.R +769 -0
  109. package/kernel/worker-boot.R +22 -0
  110. package/kernel/worker.R +3496 -0
  111. package/lib/agent-authoring-contract.js +547 -0
  112. package/lib/cell-kinds.js +108 -0
  113. package/lib/engine-labels.js +324 -0
  114. 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.