beatrina 0.9.83 → 1.0.0

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.
@@ -0,0 +1,673 @@
1
+ // latex-compile.mjs — compiling a LaTeX root, and reading what the compiler
2
+ // said, from Node (plans/getting_rid_of_R.md Stage 3, K7).
3
+ //
4
+ // A port of spike/latex.R's COMPILE half (`latex_compile()` and every helper
5
+ // it calls), rule for rule, so a .tex can be compiled on a machine with no R.
6
+ // The PREVIEW half of that file was ported earlier and lives in
7
+ // host/planes/latex.mjs; nothing here touches it.
8
+ //
9
+ // Two halves in one file, the split latex.R already had:
10
+ //
11
+ // · PURE — discovery (with an injectable lookup), the literal argv, the log
12
+ // parser that turns a transcript into diagnostics, the 79-column unwrap,
13
+ // severity, the failure sentence and the machine-actionable fix, and the
14
+ // bibliography verdict. Each is an exported function of its inputs, and
15
+ // test/latex-compile-node.test.mjs holds every one of them against the R
16
+ // original, value by value.
17
+ // · ORCHESTRATION — `latexCompile()`, which the Node job child
18
+ // (host/jobs/document-child.mjs) runs: one reused build folder, up to
19
+ // three passes, the diagnostics/fix/citations frames sent for EVERY
20
+ // compile (success included, so a page never keeps a stale band), and the
21
+ // PDF placed beside the source atomically, only by a successful run.
22
+ //
23
+ // Three places where JavaScript and R disagree by default, and this file sides
24
+ // with R on purpose, because the answer must be the same whichever child ran:
25
+ //
26
+ // · `trimws()` strips only space, tab, CR and LF; String#trim strips every
27
+ // Unicode space. `rTrim` is the R rule.
28
+ // · PCRE's `.` matches a carriage return and JavaScript's does not — and a
29
+ // CRLF log split on "\n" leaves one on every line. Every `.` below is
30
+ // written `[^\n]`.
31
+ // · `[[:space:]]` under R's PCRE is ASCII whitespace (no UCP), so it is
32
+ // written `[ \t\n\r\f\v]`, never `\s`.
33
+ //
34
+ // Diagnostic columns are 1-based CODE POINTS, as R reports them; the browser
35
+ // (lib/latex-diagnostics.js) converts to UTF-16 against the real line.
36
+
37
+ import fs from "node:fs";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+ import crypto from "node:crypto";
41
+ import { readEnvName } from "./env-names.mjs";
42
+ import { LATEX_ENGINES, LATEX_TIMEOUT, latexOptions } from "./planes/jobs.mjs";
43
+
44
+ export { LATEX_ENGINES, LATEX_TIMEOUT };
45
+ export const LATEX_DIAGNOSTIC_MAX = 500;
46
+ export const LATEX_SOURCE_READ_MAX = 4e6; // a source file consulted for a column
47
+ export const LATEX_CITATION_MAX = 100;
48
+ // TeX's max_print_line: a log line of exactly this width has been hard-wrapped
49
+ // and continues on the next line, with no marker (LEARNINGS 2026-09-07).
50
+ export const LATEX_LOG_WRAP = 79;
51
+ /** How much of the .log is read back: its final megabyte, as latex.R. */
52
+ export const LATEX_LOG_TAIL = 1000000;
53
+
54
+ const SPACE = "[ \\t\\n\\r\\f\\v]"; // PCRE [[:space:]], ASCII only
55
+ const scalar = (x) => typeof x === "string";
56
+ /** R's trimws(): space, tab, CR and LF only. */
57
+ export const rTrim = (s) => String(s).replace(/^[ \t\r\n]+/, "").replace(/[ \t\r\n]+$/, "");
58
+ /** R's nchar(type = "chars"): code points, not UTF-16 units. */
59
+ const chars = (s) => Array.from(s).length;
60
+ /** R's as.integer() on a digit string: NA (null) past the 32-bit range. */
61
+ const rInt = (digits) => { const n = Number(digits); return Number.isFinite(n) && n <= 2147483647 ? n : null; };
62
+ /** paste() of a possibly-NA scalar. */
63
+ const pasteNA = (v) => (v == null ? "NA" : String(v));
64
+
65
+ /**
66
+ * R's readLines(): LF, CRLF or CR end a line, a final incomplete line is kept,
67
+ * an empty file is no lines, and an embedded NUL ends its line.
68
+ * @param {string} text
69
+ * @returns {string[]}
70
+ */
71
+ export function splitReadLines(text) {
72
+ if (text === "") return [];
73
+ const out = text.split(/\r\n|\r|\n/);
74
+ if (/(\r\n|\r|\n)$/.test(text)) out.pop();
75
+ return out.map((line) => line.split("\0")[0]);
76
+ }
77
+
78
+ /**
79
+ * R's `strsplit(value, "\n", fixed = TRUE)[[1L]]`: trailing empty pieces are
80
+ * NOT kept ("a\nb\n" is two lines), and "" is no lines at all.
81
+ * @param {string} text
82
+ * @returns {string[]}
83
+ */
84
+ export function splitLog(text) {
85
+ if (text === "") return [];
86
+ const out = text.split("\n");
87
+ if (out[out.length - 1] === "") out.pop();
88
+ return out;
89
+ }
90
+
91
+ // ── discovery and argv ──────────────────────────────────────────────────────
92
+
93
+ /** Is this a usable executable file? (latex_executable) */
94
+ export function latexExecutable(p) {
95
+ if (!scalar(p) || !p) return false;
96
+ try {
97
+ if (!fs.statSync(p).isFile()) return false;
98
+ fs.accessSync(p, fs.constants.X_OK);
99
+ return true;
100
+ } catch { return false; }
101
+ }
102
+
103
+ /** `Sys.which()`: the first executable of that name on PATH, or "". */
104
+ export function whichOnPath(name, { env = process.env, platform = process.platform } = {}) {
105
+ const exts = platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
106
+ for (const dir of String(env.PATH || "").split(path.delimiter)) {
107
+ if (!dir) continue;
108
+ for (const ext of exts) {
109
+ const candidate = path.join(dir, name + ext);
110
+ if (latexExecutable(candidate)) return candidate;
111
+ }
112
+ }
113
+ return "";
114
+ }
115
+
116
+ /**
117
+ * Discover an engine through explicit environment override, PATH and installs.
118
+ *
119
+ * The override is `BEATRINA_<ENGINE>` (e.g. BEATRINA_TECTONIC) and it is a
120
+ * promise: naming a file that is not executable is an error, never a silent
121
+ * fall-through to some other copy. Then PATH, then the places TeX installs
122
+ * put binaries when nothing put them on PATH (Homebrew, MacTeX, MiKTeX).
123
+ *
124
+ * @param {string} [engine] engine name or "auto"
125
+ * @param {Object} [o]
126
+ * @param {(name: string) => string} [o.lookup] PATH lookup, "" when absent
127
+ * @param {(name: string) => string} [o.getenv] environment lookup, "" when unset
128
+ * @param {(p: string) => boolean} [o.usable] executable predicate
129
+ * @returns {{engine: string, binary: string}} or throws naming the missing engine
130
+ */
131
+ export function latexDiscover(engine = "auto", {
132
+ lookup = (name) => whichOnPath(name),
133
+ getenv = (name) => readEnvName(process.env, name),
134
+ usable = latexExecutable,
135
+ } = {}) {
136
+ if (!scalar(engine) || !["auto", ...LATEX_ENGINES].includes(engine)) throw new Error(`unknown LaTeX engine '${engine}'`);
137
+ const engines = engine === "auto" ? LATEX_ENGINES : [engine];
138
+ const env = (name) => String(getenv(name) ?? "");
139
+ for (const name of engines) {
140
+ const override = env(`BEATRINA_${name.toUpperCase()}`);
141
+ if (override) {
142
+ if (!usable(override)) throw new Error(`BEATRINA_${name.toUpperCase()} does not name an executable file.`);
143
+ return { engine: name, binary: override };
144
+ }
145
+ // file.path() joins with "/" on every platform, and so does this.
146
+ const candidates = [String(lookup(name) ?? ""),
147
+ ...["/opt/homebrew/bin", "/usr/local/bin", "/Library/TeX/texbin", "/usr/bin"].map((dir) => `${dir}/${name}`)];
148
+ if (env("LOCALAPPDATA")) candidates.push([env("LOCALAPPDATA"), "Programs", "MiKTeX", "miktex", "bin", "x64", `${name}.exe`].join("/"));
149
+ if (env("ProgramFiles")) candidates.push([env("ProgramFiles"), "MiKTeX", "miktex", "bin", "x64", `${name}.exe`].join("/"));
150
+ const hit = candidates.find((c) => usable(c));
151
+ if (hit !== undefined) return { engine: name, binary: hit };
152
+ }
153
+ throw new Error(`${engine === "auto" ? "A LaTeX engine" : engine} was not found. Install a TeX engine or set its BEATRINA_<ENGINE> binary path.`);
154
+ }
155
+
156
+ /**
157
+ * Build argv without shell interpolation. Compilation is offline by default.
158
+ *
159
+ * `fetch` is the ONE exception and it exists because of a mismatch nobody
160
+ * chose: `--only-cached` is a strict-offline promise, and on a machine whose
161
+ * only engine is Tectonic — whose whole design is fetch-on-demand — it means
162
+ * the first compile of any real paper fails with a list of packages and an
163
+ * instruction to go and use a terminal. So the promise is restated as the one
164
+ * Beatrina keeps everywhere else: the kernel reaches the network only when the
165
+ * person pressed something that said so. `fetch` is that press — never a
166
+ * default, never a setting, never sticky. `--untrusted` is kept either way;
167
+ * that is the flag that matters for running someone else's .tex.
168
+ *
169
+ * NO -halt-on-error, and Tectonic's equivalent turned OFF, on purpose. TeX's
170
+ * own behaviour for a missing font is to substitute one and carry on; for a
171
+ * missing package, to keep going and let the undefined commands surface.
172
+ * Halting throws that away and returns NOTHING — measured on the owner's own
173
+ * CVs, six of eight produced no PDF for causes TeX could work around. This is
174
+ * only safe because the result is LABELLED: `latexCompile()` counts the error
175
+ * diagnostics of a successful compile and the PDF pane says how many.
176
+ *
177
+ * @param {string} engine resolved engine name
178
+ * @param {string} target absolute source path
179
+ * @param {string} output absolute output directory
180
+ * @param {boolean} [fetch] allow the engine to download missing packages, this run only
181
+ * @returns {{wd: string, args: string[]}}
182
+ */
183
+ export function latexArgv(engine, target, output, fetch = false) {
184
+ if (!LATEX_ENGINES.includes(engine) || !scalar(target) || !scalar(output)) throw new Error("latexArgv: invalid engine, target or output");
185
+ const common = ["-interaction=nonstopmode", "-file-line-error", "-no-shell-escape"];
186
+ const args = engine === "tectonic"
187
+ ? [...(fetch === true ? [] : ["--only-cached"]), "--untrusted", "--print", "--keep-logs", "--color", "never",
188
+ "-Z", "continue-on-errors", "--outdir", output, target]
189
+ : engine === "latexmk"
190
+ ? ["-pdf", "-norc", ...common, `-outdir=${output}`, target]
191
+ : [...common, `-output-directory=${output}`, target];
192
+ return { wd: path.dirname(target), args };
193
+ }
194
+
195
+ // ── what went wrong, in words and as a fact ─────────────────────────────────
196
+
197
+ /**
198
+ * The sentence a person can act on, for a missing asset under either engine.
199
+ * Tectonic and a local TeX fail the same way for different reasons: a Tectonic
200
+ * failure is almost always "not fetched yet" (the compile is offline), a local
201
+ * TeX failure is "not installed". THE BUTTON FIRST, the terminal second.
202
+ */
203
+ export function latexMissingHint(what, engine, target, plural = false) {
204
+ const are = plural === true ? "are" : "is";
205
+ if (engine === "tectonic") {
206
+ return `${what} ${are} not in Tectonic's package cache. Beatrina compiles offline, so nothing was `
207
+ + `downloaded: use Fetch and compile below to get ${plural === true ? "them" : "it"} for one compile, or run `
208
+ + `tectonic "${path.basename(target)}" in a terminal (in ${path.dirname(target)}).`;
209
+ }
210
+ return `${what} ${are} not installed. Install ${plural === true ? "them" : "it"} in your TeX distribution, then compile again.`;
211
+ }
212
+
213
+ // The engine echoes the macro that raised the error (`File \`#1.#2' not
214
+ // found`); a name holding a parameter token is the definition, not a file.
215
+ const MISSING_FILE = /File `[^'#]+' not found/;
216
+ const missingFiles = (lines) => {
217
+ const hits = lines.map((l) => (l.match(MISSING_FILE) || [null])[0]).filter((h) => h != null);
218
+ return [...new Set(hits.map((h) => h.replace(/^File `([^']+)' not found$/, "$1")))];
219
+ };
220
+
221
+ /**
222
+ * What Beatrina could DO about this failure, as a fact rather than a sentence.
223
+ * One reader, two renderings: the hint is prose for a person, this is the same
224
+ * reading for the PAGE, which must not decide on a button by matching English.
225
+ * Only ONE fix is machine-actionable: missing packages under Tectonic.
226
+ * @param {string[]} lines
227
+ * @param {string} engine
228
+ * @returns {{fix: ""|"fetch", assets: string[]}}
229
+ */
230
+ export function latexFailureFix(lines, engine) {
231
+ if (!Array.isArray(lines) || !scalar(engine)) throw new Error("latexFailureFix: lines and engine are required");
232
+ const none = { fix: "", assets: [] };
233
+ if (engine !== "tectonic") return none;
234
+ const assets = missingFiles(lines);
235
+ return assets.length ? { fix: "fetch", assets } : none;
236
+ }
237
+
238
+ const PDF_PRIMITIVE = /\\pdf(shellescape|output|pagewidth|pageheight|compresslevel|minorversion|horigin|vorigin)/;
239
+
240
+ /**
241
+ * Turn a failed engine's output into the sentence that names the fix. THE
242
+ * LADDER IS ORDERED BY HOW SPECIFIC THE ADVICE IS, and it ends in the
243
+ * diagnostics rather than in the log: "see the compile log" points at the pane
244
+ * that holds LESS than Problems already holds. The bare pointer survives only
245
+ * for a failure that produced no diagnostics at all.
246
+ * @param {string[]} lines compiler stdout and log lines
247
+ * @param {string} engine
248
+ * @param {string} target
249
+ * @param {object[]} [diagnostics] rows from latexLogDiagnostics()
250
+ * @returns {string} one sentence, never empty
251
+ */
252
+ export function latexFailureHint(lines, engine, target, diagnostics = []) {
253
+ if (!Array.isArray(lines) || !scalar(engine) || !scalar(target) || !Array.isArray(diagnostics)) {
254
+ throw new Error("latexFailureHint: invalid arguments");
255
+ }
256
+ const missing = missingFiles(lines);
257
+ if (missing.length) return latexMissingHint(missing.join(", "), engine, target, missing.length > 1);
258
+ // Tectonic is XeTeX: a document that names the pdftex driver loads
259
+ // pdftex.def, whose first \pdf... primitive is "Undefined control sequence"
260
+ // at \begin{document} — a driver mismatch, not a typo.
261
+ if (engine === "tectonic" && lines.some((l) => /^! Undefined control sequence/.test(l)) && lines.some((l) => PDF_PRIMITIVE.test(l))) {
262
+ return "an undefined \\pdf... primitive: this document names the pdftex driver ([pdftex] on graphicx or hyperref) "
263
+ + "and Tectonic is XeTeX-based. Remove the [pdftex] option (the driver is detected automatically), then compile again.";
264
+ }
265
+ // A font TeX cannot load is the SAME condition as a missing .sty. The name
266
+ // after `=` is what the person has to obtain (brackets and XeTeX's feature
267
+ // string after `:` are request syntax). THE PHRASE AND THE NAME ARE MATCHED
268
+ // SEPARATELY, because TeX hard-wraps its log at 79 columns and a long font
269
+ // name splits "not loadable" mid-word onto the next line.
270
+ const named = [...new Set(lines.map((l) => l.match(/^! Font [^=]+=([^ :]+)/)).filter(Boolean)
271
+ .map((m) => m[1].replace(/^\[|\]$/g, "")))];
272
+ if (named.length && lines.some((l) => /not loadable|Metric \(TFM\)/.test(l))) {
273
+ return latexMissingHint(`the font${named.length > 1 ? "s" : ""} ${named.join(", ")}`, engine, target, named.length > 1);
274
+ }
275
+ const errors = diagnostics.filter((row) => row && row.severity === "error");
276
+ if (errors.length) {
277
+ const first = errors[0];
278
+ // A diagnostic may carry no line; "line NA" is not a place.
279
+ const where = first.line == null ? path.basename(first.path) : `${path.basename(first.path)} line ${first.line}`;
280
+ return `${errors.length === 1 ? "1 error is listed" : `${errors.length} errors are listed`} in Problems; the first is ${where}: ${String(first.message).replace(/[.]+$/, "")}`;
281
+ }
282
+ return "See the compile log.";
283
+ }
284
+
285
+ // ── the log parser ──────────────────────────────────────────────────────────
286
+
287
+ /**
288
+ * Severity of a compiler message. The engine's own `error:`/`warning:` prefix
289
+ * is authoritative; without one, only a LEADING warning marker counts — an
290
+ * error whose text merely contains the word "warning" is still an error.
291
+ */
292
+ export function latexSeverity(prefix, message) {
293
+ const p = rTrim(prefix == null ? "" : String(prefix)).toLowerCase();
294
+ if (p === "warning") return "warning";
295
+ if (p === "error") return "error";
296
+ return new RegExp(`^(?:LaTeX|Package[^:]*|Class[^:]*|pdfTeX|Font)${SPACE}+[Ww]arning:`).test(message == null ? "" : String(message))
297
+ ? "warning" : "error";
298
+ }
299
+
300
+ /**
301
+ * The 1-based CODE POINT column an `l.N` echo points at, or null.
302
+ *
303
+ * The echo is LOSSY: measured against tectonic 0.15.0, a source `\U1D538` is
304
+ * logged as `\UD538`, so a column counted in the echo lands inside a surrogate
305
+ * pair. The echo is only ever a HINT, and a column is returned only when it
306
+ * is an EXACT prefix of the genuine source line. A column that cannot be
307
+ * justified is worse than none.
308
+ */
309
+ export function latexEchoColumn(echo, sourceLine) {
310
+ if (!scalar(echo) || !echo || !scalar(sourceLine)) return null;
311
+ if (!sourceLine.startsWith(echo)) return null;
312
+ return chars(echo) + 1;
313
+ }
314
+
315
+ /** R's normalizePath(mustWork = FALSE) on a Unix: realpath when it resolves, else as given. */
316
+ function normalizePath(p) {
317
+ try { return fs.realpathSync.native(p); } catch { return p; }
318
+ }
319
+ const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
320
+
321
+ const FILE_LINE = new RegExp(`^(?:(error|warning):${SPACE}*)?([^\\n]+?):([0-9]+):(?:([0-9]+):)?${SPACE}*([^\\n]+)$`);
322
+ const LOCATION = new RegExp(`^l\\.([0-9]+)${SPACE}([^\\n]*)$`);
323
+ const WARNING = /(?:LaTeX|Package [^\n]+) Warning: ([^\n]+?)(?: on input line ([0-9]+))?\.?$/;
324
+ const OPENED = new RegExp(`^("[^"\\n]+\\.(?:tex|sty|cls)"|[^()\\n]*?\\.(?:tex|sty|cls))(?=[ \\t\\n\\r\\f\\v)]|$)`);
325
+ const SHORT = /^("[^"]+"|[^ \t\n\r\f\v()]+)/;
326
+
327
+ /**
328
+ * Normalize compiler messages into bounded diagnostic rows, retaining 1-based
329
+ * code-point coordinates (the browser owns the UTF-16 conversion).
330
+ *
331
+ * @param {string[]} lines compiler stdout or log lines
332
+ * @param {string} root absolute root source path
333
+ * @returns {{path: string, line: number|null, col: number|null, message: string,
334
+ * severity: "error"|"warning", source: string, log: string}[]}
335
+ */
336
+ export function latexLogDiagnostics(lines, root) {
337
+ if (!Array.isArray(lines) || !scalar(root)) throw new Error("latexLogDiagnostics: lines and root are required");
338
+ const rows = [];
339
+ const seen = new Set();
340
+ let pending = null;
341
+ const stack = [];
342
+ // Scoped to THIS call on purpose: a cache outliving one compile would
343
+ // compute a column from the text as it was before the edit that triggered
344
+ // the rebuild.
345
+ const sourceCache = new Map();
346
+ const sourceLine = (file, line) => {
347
+ if (line == null || !Number.isFinite(line) || line < 1) return null;
348
+ if (!sourceCache.has(file)) {
349
+ // Unreadable, absent, oversized or binary: no column. The documented safe
350
+ // outcome, not a swallowed failure — the row is still reported.
351
+ let value = [];
352
+ try {
353
+ const st = fs.statSync(file);
354
+ if (st.isFile() && st.size <= LATEX_SOURCE_READ_MAX) value = splitReadLines(fs.readFileSync(file, "utf8"));
355
+ } catch { value = []; }
356
+ sourceCache.set(file, value);
357
+ }
358
+ const text = sourceCache.get(file);
359
+ return line > text.length ? null : text[line - 1];
360
+ };
361
+ const current = () => {
362
+ const files = stack.filter((v) => v != null && v !== "");
363
+ return files.length ? files[files.length - 1] : root;
364
+ };
365
+ const resolveFile = (file) => {
366
+ let f = file.replace(/^"|"$/g, "");
367
+ if (!/^(\/|[A-Za-z]:[/\\])/.test(f)) f = path.dirname(root) + "/" + f;
368
+ if (!exists(f) && exists(`${f}.tex`)) f = `${f}.tex`;
369
+ return normalizePath(f);
370
+ };
371
+ // The log lines AROUND a finding, bounded: TeX says what it was reading
372
+ // (`l.12 \badcommand`) and what it did about it on the lines that follow.
373
+ // At most 8 non-blank lines and 600 characters.
374
+ const excerpt = (from, to) => {
375
+ if (from == null || to == null) return "";
376
+ const span = lines.slice(Math.max(1, from) - 1, Math.min(lines.length, Math.max(from, to)))
377
+ .filter((l) => rTrim(l) !== "").slice(0, 8);
378
+ return Array.from(span.join("\n")).slice(0, 600).join("");
379
+ };
380
+ const add = (file, line, message, severity = "error", col = null, from = null, to = null) => {
381
+ const resolved = resolveFile(file);
382
+ const key = [resolved, pasteNA(line), pasteNA(col), rTrim(message).replace(/[.]+$/, "")].join("\r");
383
+ if (!seen.has(key) && rows.length < LATEX_DIAGNOSTIC_MAX) {
384
+ seen.add(key);
385
+ rows.push({ path: resolved, line, col, message: rTrim(message), severity, source: "LaTeX compiler", log: excerpt(from, to) });
386
+ }
387
+ };
388
+ lines.forEach((line, index) => {
389
+ const i = index + 1; // 1-based, as the R
390
+ // file-line-error and Tectonic include the file explicitly, including drives.
391
+ const hit = line.match(FILE_LINE);
392
+ if (hit) {
393
+ const [, prefix = "", file, lineNo, colNo = "", message] = hit;
394
+ if (prefix || /\.(tex|sty|cls|bib)$/.test(file) || exists(resolveFile(file))) {
395
+ add(file, rInt(lineNo), message, latexSeverity(prefix, message), colNo ? rInt(colNo) : null, i, i + 4);
396
+ pending = null;
397
+ return;
398
+ }
399
+ }
400
+ if (line.startsWith("!")) { pending = { file: current(), message: rTrim(line.replace(/^!/, "")), at: i }; return; }
401
+ const location = line.match(LOCATION);
402
+ if (location && pending) {
403
+ const atLine = rInt(location[1]);
404
+ // The echo is a hint; the genuine source line is the authority.
405
+ const col = latexEchoColumn(location[2], sourceLine(resolveFile(pending.file), atLine));
406
+ add(pending.file, atLine, pending.message, "error", col, pending.at, i + 2);
407
+ pending = null;
408
+ return;
409
+ }
410
+ // A message with no `on input line` has NO line, and saying 1 would be
411
+ // inventing one; null travels as a file-level diagnostic.
412
+ const warning = line.match(WARNING);
413
+ if (warning) { add(current(), warning[2] ? rInt(warning[2]) : null, warning[1], "warning"); return; }
414
+ if (/^error:/.test(line) && !/^error: (halted on|unexpectedly halted)/.test(line)) {
415
+ add(current(), null, line.replace(/^error:[ \t\n\r\f\v]*/, ""));
416
+ return;
417
+ }
418
+ // Track log parentheses, including anonymous parentheses, so a closing
419
+ // package group returns !/l.N diagnostics to the including source.
420
+ for (let at = 0; at < line.length; at += 1) {
421
+ const c = line[at];
422
+ if (c === ")") { stack.pop(); continue; }
423
+ if (c !== "(") continue;
424
+ const rest = line.slice(at + 1);
425
+ let name = rest.match(OPENED);
426
+ if (!name) {
427
+ const short = rest.match(SHORT);
428
+ if (short && exists(resolveFile(short[1]))) name = short;
429
+ }
430
+ stack.push(name ? name[1] : "");
431
+ }
432
+ });
433
+ if (pending) add(pending.file, 1, pending.message);
434
+ return rows;
435
+ }
436
+
437
+ // ── the bibliography verdict ────────────────────────────────────────────────
438
+
439
+ /**
440
+ * Re-join TeX's hard-wrapped log lines: a line of exactly 79 characters was
441
+ * cut, and the next line continues it with no marker. BibTeX's .blg is not
442
+ * wrapped this way, and a complete 79-character line costs only a longer
443
+ * string, so this is safe over both.
444
+ * @param {string[]} lines
445
+ * @returns {string[]}
446
+ */
447
+ export function latexUnwrap(lines) {
448
+ if (!Array.isArray(lines)) throw new Error("latexUnwrap: lines must be an array");
449
+ if (lines.length < 2) return lines.slice();
450
+ const out = [];
451
+ lines.forEach((line, i) => {
452
+ if (i > 0 && chars(lines[i - 1]) === LATEX_LOG_WRAP) out[out.length - 1] += line;
453
+ else out.push(line);
454
+ });
455
+ return out;
456
+ }
457
+
458
+ /**
459
+ * Bibliography and citation problems that do NOT fail a compile.
460
+ *
461
+ * Tectonic runs BibTeX itself and, when BibTeX cannot open the style file,
462
+ * prints "errors were issued by BibTeX, but were ignored", finishes, and exits
463
+ * 0 — every citation `[?]`, the reference list empty: a WRONG PDF delivered
464
+ * as a success. So the compile carries a second list beside the diagnostics.
465
+ * BibTeX's messages are read from `bibLines` (the .blg plus the engine's
466
+ * stdout); undefined citations from the FINAL pass only (`texLines`), or a key
467
+ * the second pass resolved would be reported as broken.
468
+ *
469
+ * @param {string[]} texLines
470
+ * @param {string[]} bibLines
471
+ * @param {string} engine
472
+ * @param {boolean} [fetch] was this compile allowed to reach the network?
473
+ * @returns {{kind: string, file: string, key: string, line: number|null,
474
+ * message: string, log: string, fetchable: boolean}[]}
475
+ */
476
+ export function latexCitationProblems(texLines, bibLines, engine, fetch = false) {
477
+ if (!Array.isArray(texLines) || !Array.isArray(bibLines) || !scalar(engine) || typeof fetch !== "boolean") {
478
+ throw new Error("latexCitationProblems: invalid arguments");
479
+ }
480
+ const tex = latexUnwrap(texLines);
481
+ const bib = latexUnwrap(bibLines);
482
+ const rows = [];
483
+ const seen = new Set();
484
+ const add = (kind, message, log, { file = "", key = "", line = null, fetchable = false } = {}) => {
485
+ const id = [kind, file, key].join("\r");
486
+ if (seen.has(id) || rows.length >= LATEX_CITATION_MAX) return;
487
+ seen.add(id);
488
+ rows.push({ kind, file, key, line, message, log: rTrim(log), fetchable: fetchable === true });
489
+ };
490
+ const tectonic = engine === "tectonic";
491
+ // The sentence names the RECOVERY, and which one depends on the engine and
492
+ // on whether the network was already tried.
493
+ const styleSentence = (file) => (tectonic && !fetch
494
+ ? `Tectonic could not find ${file}; it fetches it when online — retry with a connection, or place the file beside the document.`
495
+ : tectonic
496
+ ? `Tectonic could not find ${file} even when allowed to fetch; place the file beside the document.`
497
+ : `BibTeX could not find ${file}; install it in your TeX distribution, or place the file beside the document.`);
498
+ const matches = (re) => bib.map((l) => l.match(re)).filter(Boolean);
499
+ // ── BibTeX's own complaints, in the order a reader can act on them ──
500
+ matches(/^I couldn't open style file ([^ \t\n\r\f\v]+)/).forEach((m) =>
501
+ add("missing-style", styleSentence(m[1]), m[0], { file: m[1], fetchable: tectonic && !fetch }));
502
+ matches(/^I couldn't open database file ([^ \t\n\r\f\v]+)/).forEach((m) =>
503
+ add("missing-bib", `BibTeX could not open ${m[1]}; place it beside the document, or point \\bibliography{} at a file that exists.`, m[0], { file: m[1] }));
504
+ matches(/^Warning--I didn't find a database entry for "([^"]+)"/).forEach((m) =>
505
+ add("missing-entry", `\\cite{${m[1]}} has no entry in the .bib files, so it prints as [?].`, m[0], { key: m[1] }));
506
+ const ignored = bib.filter((l) => /errors were issued by BibTeX, but were ignored/.test(l));
507
+ if (ignored.length) {
508
+ const counts = matches(/^\(There (?:was|were) ([0-9]+) error messages?\)/);
509
+ const n = counts.length ? rInt(counts[counts.length - 1][1]) : null;
510
+ add("bibtex-errors", `BibTeX reported ${n == null ? "errors" : `${n} error${n === 1 ? "" : "s"}`} and the compile carried on without them: the reference list was not built.`, ignored[0]);
511
+ }
512
+ // ── what the final pass still could not resolve ──
513
+ const citeRe = /Warning: Citation `([^']+)' on page [0-9]+ undefined(?: on input line ([0-9]+))?/;
514
+ tex.forEach((l) => {
515
+ const m = l.match(citeRe);
516
+ if (m) add("undefined-citation", `\\cite{${m[1]}} is undefined: it prints as [?] and is missing from the reference list.`, l,
517
+ { key: m[1], line: m[2] ? rInt(m[2]) : null });
518
+ });
519
+ // The summary line alone (a key too long for the wrap to save): still said.
520
+ const summary = tex.filter((l) => /Warning: There were undefined citations/.test(l));
521
+ if (summary.length && !rows.some((r) => r.kind === "undefined-citation")) {
522
+ add("undefined-citation", "There were undefined citations: they print as [?] and are missing from the reference list.", summary[0]);
523
+ }
524
+ return rows;
525
+ }
526
+
527
+ // ── the compile ─────────────────────────────────────────────────────────────
528
+
529
+ /** The negative-signal convention processx reports for a killed process. */
530
+ export function exitStatus(result) {
531
+ if (result.code != null) return result.code;
532
+ if (result.signal && os.constants.signals[result.signal]) return -os.constants.signals[result.signal];
533
+ return null;
534
+ }
535
+
536
+ /**
537
+ * Read at most the final `max` bytes of a log as lines. Invalid UTF-8 (a seek
538
+ * that lands mid-character, a latin1 font name) is DROPPED, as R's
539
+ * `iconv(sub = "")` does, rather than turned into U+FFFD.
540
+ * @param {string} file
541
+ * @returns {string[]}
542
+ */
543
+ export function readLogTail(file, max = LATEX_LOG_TAIL) {
544
+ let fd;
545
+ try {
546
+ fd = fs.openSync(file, "r");
547
+ const size = fs.fstatSync(fd).size;
548
+ const start = Math.max(0, size - max);
549
+ const buf = Buffer.alloc(Math.min(size, max));
550
+ const n = fs.readSync(fd, buf, 0, buf.length, start);
551
+ const bytes = buf.subarray(0, n);
552
+ // Valid UTF-8 (the usual case) is decoded as it is, a genuine U+FFFD
553
+ // included; only a buffer that FAILS strict decoding has its replacement
554
+ // characters removed — the bytes iconv(sub = "") would have dropped.
555
+ let text;
556
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { text = new TextDecoder("utf-8").decode(bytes).replace(/\uFFFD/g, ""); }
557
+ return splitLog(text);
558
+ } finally {
559
+ if (fd !== undefined) fs.closeSync(fd);
560
+ }
561
+ }
562
+
563
+ /**
564
+ * Compile one saved root. Emits `diagnostics`, `fix` and `citations` for every
565
+ * compile, then `output` for a successful one; throws (with the sentence that
566
+ * names the fix) for a failed one, after those frames went out.
567
+ *
568
+ * @param {Object} o
569
+ * @param {string} o.target absolute source path
570
+ * @param {string} o.engine engine choice ("auto" or a name)
571
+ * @param {number} o.timeout total deadline in seconds, including repeat passes
572
+ * @param {boolean} [o.fetch] allow this ONE compile to download missing packages
573
+ * @param {(bin: string, args: string[], o: {cwd: string, timeoutMs: number}) =>
574
+ * Promise<{code: number|null, signal?: string|null, lines: string[], timedOut: boolean,
575
+ * truncated?: boolean, error?: string}>} o.runStreaming
576
+ * @param {(frame: object) => void} o.emit
577
+ * @param {(text: string) => void} [o.print] the job's log (stdout)
578
+ * @param {Object} [o.discover] options for latexDiscover (tests)
579
+ * @returns {Promise<string>} the verified PDF path
580
+ */
581
+ export async function latexCompile({ target, engine, timeout, fetch = false, runStreaming, emit,
582
+ print = (text) => process.stdout.write(text), discover = {} }) {
583
+ if (!scalar(target) || typeof runStreaming !== "function" || typeof emit !== "function") throw new Error("latexCompile: invalid arguments");
584
+ latexOptions({ engine, timeout, fetch });
585
+ const found = latexDiscover(engine, discover);
586
+ // ONE build folder per root, reused: aux files make the second pass fast, and
587
+ // nothing accumulates. The PDF lands BESIDE the root as `<stem>.pdf`, where
588
+ // every TeX tool puts it, and is replaced only by a successful run,
589
+ // atomically, so a failed compile leaves the previous PDF where it was.
590
+ const stem = path.basename(target).replace(/\.[^.]+$/, "");
591
+ const here = path.dirname(target);
592
+ const output = path.join(here, ".beatrina-latex", stem);
593
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
594
+ if (!isDir(output)) { try { fs.mkdirSync(output, { recursive: true }); } catch { /* said on the next line */ } }
595
+ if (!isDir(output)) throw new Error("Cannot create the LaTeX build folder.");
596
+ const spec = latexArgv(found.engine, target, output, fetch === true);
597
+ if (fetch === true) print("Fetching missing packages for this compile (this one reaches the network).\n");
598
+ const pdf = path.join(output, `${stem}.pdf`);
599
+ const final = path.join(here, `${stem}.pdf`);
600
+ // A PDF left in the build folder by an earlier run must not pass for this
601
+ // run's: an engine that exits 0 without writing one is a failed compile.
602
+ try { fs.rmSync(pdf, { force: true }); } catch { /* a folder by that name: unlink() leaves it too, and it is no PDF */ }
603
+ const logPath = path.join(output, `${stem}.log`);
604
+ const startedAt = Date.now();
605
+ let pass = 0;
606
+ let result = null;
607
+ for (;;) {
608
+ const remaining = timeout - (Date.now() - startedAt) / 1000;
609
+ if (remaining <= 0) throw new Error(`LaTeX compile timed out after ${timeout} seconds.`);
610
+ pass += 1;
611
+ print(`Compiling with ${found.engine} (pass ${pass})\n`);
612
+ result = await runStreaming(found.binary, spec.args, { cwd: spec.wd, timeoutMs: remaining * 1000 });
613
+ // processx raises when the binary cannot be started; so does this.
614
+ if (result.error) throw new Error(`${found.engine} could not be started: ${result.error}`);
615
+ if (result.timedOut || exitStatus(result) !== 0 || pass >= 3 || ["tectonic", "latexmk"].includes(found.engine)
616
+ || !result.lines.some((l) => /Rerun|run LaTeX again|Label[^\n]*changed/i.test(l))) break;
617
+ }
618
+ const status = result.timedOut ? null : exitStatus(result);
619
+ // Some engines write diagnostics only to the log. Read at most its final MB.
620
+ const logLines = exists(logPath) ? readLogTail(logPath) : [];
621
+ // Successful multi-pass engines leave the authoritative final pass in .log.
622
+ // Keep earlier passes in Jobs output, but do not publish resolved warnings.
623
+ const diagnosticLines = status === 0 && logLines.length ? logLines : [...result.lines, ...logLines];
624
+ // Parsed once and used twice: the pane gets the rows, and the failure
625
+ // sentence gets to say what is in the pane rather than pointing at the log.
626
+ const diagnostics = latexLogDiagnostics(diagnosticLines, target);
627
+ // `engine`: a FAILED compile emits no `output` frame, and the moment an
628
+ // explanation needs the engine is exactly that one.
629
+ emit({ type: "job", event: "diagnostics", rows: diagnostics, root: target, truncated: result.truncated === true, engine: found.engine });
630
+ // What could be DONE about it, sent as a fact beside the rows — for a
631
+ // success too, so a page never carries a stale offer from the previous run.
632
+ const fix = latexFailureFix(diagnosticLines, found.engine);
633
+ emit({ type: "job", event: "fix", fix: fix.fix, assets: fix.assets, fetched: fetch === true });
634
+ // The bibliography's verdict. A missing `.bst` under Tectonic EXITS 0 with
635
+ // every citation `[?]`, so this is read for a success above all.
636
+ const blgPath = path.join(output, `${stem}.blg`);
637
+ let blgLines = [];
638
+ if (exists(blgPath)) blgLines = splitReadLines(fs.readFileSync(blgPath, "utf8"));
639
+ const citations = latexCitationProblems(diagnosticLines, [...blgLines, ...result.lines], found.engine, fetch === true);
640
+ emit({ type: "job", event: "citations", rows: citations, root: target });
641
+ if (result.timedOut) throw new Error(`LaTeX compile timed out after ${timeout} seconds.`);
642
+ if (status !== 0) {
643
+ throw new Error(`${found.engine} exited ${pasteNA(status)}. ${latexFailureHint(diagnosticLines, found.engine, target, diagnostics)}`);
644
+ }
645
+ let validPdf = false;
646
+ try {
647
+ if (fs.statSync(pdf).isFile()) {
648
+ const fd = fs.openSync(pdf, "r");
649
+ const head = Buffer.alloc(5);
650
+ try { fs.readSync(fd, head, 0, 5, 0); } finally { fs.closeSync(fd); }
651
+ validPdf = head.toString("latin1") === "%PDF-";
652
+ }
653
+ } catch { validPdf = false; }
654
+ // With the engine no longer halting, "exit 0 and no PDF" is what a genuinely
655
+ // fatal document looks like, so this branch needs the sentence too.
656
+ if (!validPdf) throw new Error(`The compiler finished without producing a PDF. ${latexFailureHint(diagnosticLines, found.engine, target, diagnostics)}`);
657
+ // Copy into a sibling temp name, then rename: a reader opening `<stem>.pdf`
658
+ // sees the old PDF or the new one, never a half-written file.
659
+ const staging = path.join(here, `.${stem}-${crypto.randomBytes(6).toString("hex")}.pdf`);
660
+ try {
661
+ fs.copyFileSync(pdf, staging);
662
+ fs.renameSync(staging, final);
663
+ } catch {
664
+ fs.rmSync(staging, { force: true });
665
+ throw new Error("The PDF was built but could not be placed beside the source.");
666
+ }
667
+ const placed = fs.realpathSync.native(final);
668
+ // The count travels with the output so the PDF can be labelled: a document
669
+ // TeX had to improvise parts of is a success with a caveat.
670
+ const errors = diagnostics.filter((row) => row.severity === "error").length;
671
+ emit({ type: "job", event: "output", renderer: found.engine, path: placed, format: "pdf", errors, citations: citations.length });
672
+ return placed;
673
+ }