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.
- package/app/Beatrina.app/Contents/Info.plist +86 -0
- package/app/Beatrina.app/Contents/MacOS/beatrina +0 -0
- package/app/Beatrina.app/Contents/Resources/icon.icns +0 -0
- package/app/beatrina.png +0 -0
- package/app/linux-arm64/beatrina +0 -0
- package/app/win-x64/Beatrina-setup.exe +0 -0
- package/beatrina_V1.0.0.html +1906 -0
- package/beatrina_V1.0.0.html.inputs.json +1 -0
- package/bin/beatrina.mjs +200 -12
- package/bin/cli.mjs +8 -3
- package/bin/finder.mjs +1 -1
- package/bin/shortcut.mjs +15 -15
- package/build-info.json +1 -1
- package/host/engine-node-job.mjs +35 -0
- package/host/engine-pool.mjs +153 -26
- package/host/engine-r.mjs +8 -1
- package/host/jobs/document-child.mjs +271 -0
- package/host/latex-compile.mjs +673 -0
- package/host/main.mjs +58 -18
- package/host/planes/analyze.mjs +18 -2
- package/host/planes/jobs.mjs +38 -5
- package/host/planes/sessions.mjs +5 -1
- package/host/pwa/icon-192.png +0 -0
- package/host/pwa/icon-512.png +0 -0
- package/host/quarto.mjs +118 -0
- package/host/server.mjs +83 -2
- package/host/worker-plane.mjs +7 -1
- package/kernel/check/acceptance.mjs +43 -12
- package/kernel/check/knit-file.mjs +46 -14
- package/kernel/check/session.mjs +19 -4
- package/kernel/kernel-version +1 -1
- package/lib/engine-labels.js +7 -1
- package/menu/Beatrina Menu.app/Contents/Info.plist +2 -2
- package/menu/Beatrina Menu.app/Contents/MacOS/Beatrina Menu +0 -0
- package/package.json +4 -3
- package/beatrina_V0.9.83.html +0 -1903
- package/beatrina_V0.9.83.html.inputs.json +0 -1
|
@@ -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
|
+
}
|