Repository Public Research preview
1/**2 * LaTeX build and QA engine. No dependencies beyond Node; shared by the MCP server and the skill CLI.3 *4 * Safety model5 * - every path is resolved inside one root directory (symlinks included);6 * - child processes are started with execFile (no shell), with a timeout;7 * - TeX engines always get -no-shell-escape, latexmk always gets -norc (a project latexmkrc is Perl code);8 * - build products go to a build directory marked by this engine; only a marked directory is ever removed.9 */10import { execFile } from "node:child_process";11import { accessSync, constants, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";12import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";13import { explainError, parseBibLog, parseLog } from "./logparse.mjs";1415export { explainError, parseBibLog, parseLog };1617export const BUILD_DIR = ".latex-build";18const MARKER = ".latex-mcp-build";19const ENGINES = { pdflatex: "-pdf", xelatex: "-xelatex", lualatex: "-lualatex" };20const TOOLS = ["latexmk", "pdflatex", "xelatex", "lualatex", "bibtex", "biber", "pandoc", "texcount", "chktex", "pdftotext", "pdfinfo"];21const VERSION_ARGS = { pdftotext: ["-v"], pdfinfo: ["-v"], chktex: ["--version"], texcount: ["-version"] };2223export class LatexError extends Error {24 constructor(code, message, extra) { super(message); this.code = code; if (extra) Object.assign(this, extra); }25}2627/* ───────── helpers on TeX source text ───────── */2829/** Read a {braced group} starting at s[i] === "{". Returns { text, end } (end = index after the closing brace) or null. */30export function braceArg(s, i) {31 if (s[i] !== "{") return null;32 let depth = 0;33 for (let j = i; j < s.length; j++) {34 if (s[j] === "\\") { j++; continue; }35 if (s[j] === "{") depth++;36 else if (s[j] === "}" && --depth === 0) return { text: s.slice(i + 1, j), end: j + 1 };37 }38 return null;39}40function skipOptional(s, i) {41 for (;;) {42 while (s[i] === " " || s[i] === "\n" || s[i] === "\t") i++;43 const close = s[i] === "[" ? "]" : s[i] === "<" ? ">" : null;44 if (!close) return i;45 const j = s.indexOf(close, i);46 if (j < 0) return i;47 i = j + 1;48 }49}50const stripComment = (line) => line.replace(/(^|[^\\])%.*$/, "$1");51const plain = (tex) => tex.replace(/\\(?:textbf|textit|emph|texttt|textsc|textrm|mathrm|alert|structure)\s*\{([^{}]*)\}/g, "$1").replace(/\\[a-zA-Z]+\*?\s*/g, "").replace(/[{}~]/g, " ").replace(/\s+/g, " ").trim();52const REF_KINDS = { figure: "Figure", table: "Table", equation: "Equation", section: "Section", subsection: "Section", subsubsection: "Section", chapter: "Chapter", appendix: "Appendix", algorithm: "Algorithm", theorem: "Theorem", lemma: "Lemma", proposition: "Proposition", corollary: "Corollary", definition: "Definition", listing: "Listing", footnote: "Footnote", page: "Page", part: "Part", item: "Item", enumi: "Item" };53const MATH_ENVS = ["equation", "align", "gather", "multline", "eqnarray", "flalign", "alignat"];5455export class Latex {56 constructor(env = process.env) {57 this.env = env;58 const root = env.LATEX_MCP_ROOT || process.cwd();59 if (!existsSync(root)) throw new LatexError("bad_root", `LATEX_MCP_ROOT does not exist: ${root}`);60 this.root = realpathSync(root);61 this.timeout = Math.max(5, Number(env.LATEX_MCP_TIMEOUT_S) || 120) * 1000;62 // GUI clients start servers with a minimal PATH: add the usual TeX locations.63 const extra = ["/Library/TeX/texbin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];64 try { for (const y of readdirSync("/usr/local/texlive")) { const b = join("/usr/local/texlive", y, "bin"); if (/^\d{4}$/.test(y) && existsSync(b)) for (const a of readdirSync(b)) extra.unshift(join(b, a)); } } catch { /* no TeX Live there */ }65 this.path = [...new Set([...(env.PATH || "").split(delimiter).filter(Boolean), ...extra])];66 this.found = new Map();67 }6869 /* ───────── paths ───────── */7071 /** Resolve a path inside the root; symlinks are followed before the check. */72 inside(p, { mustExist = true } = {}) {73 if (typeof p !== "string" || !p || p.includes("\0")) throw new LatexError("bad_path", "A path is required.");74 const abs = isAbsolute(p) ? resolve(p) : resolve(this.root, p);75 let real;76 if (existsSync(abs)) real = realpathSync(abs);77 else {78 if (mustExist) throw new LatexError("not_found", `No such file: ${p}`);79 let parent = dirname(abs); const tail = [basename(abs)];80 while (!existsSync(parent)) { tail.unshift(basename(parent)); parent = dirname(parent); }81 real = join(realpathSync(parent), ...tail);82 }83 if (real !== this.root && !real.startsWith(this.root + sep)) throw new LatexError("outside_root", `Path is outside the allowed root (${this.root}): ${p}`);84 return real;85 }86 rel(abs) { return relative(this.root, abs) || "."; }8788 /** Main .tex file plus the names derived from it. */89 job(file, buildDir) {90 const main = this.inside(file);91 if (!statSync(main).isFile() || !/\.(tex|ltx)$/i.test(main)) throw new LatexError("bad_file", `Not a .tex file: ${file}`);92 const dir = dirname(main), name = basename(main, extname(main));93 const build = buildDir ? this.inside(isAbsolute(buildDir) ? buildDir : join(dir, buildDir), { mustExist: false }) : join(dir, BUILD_DIR);94 if (build === dir || build === this.root) throw new LatexError("bad_build_dir", "The build directory must be a separate directory, not the source directory.");95 return { main, dir, name, build, log: join(build, `${name}.log`), aux: join(build, `${name}.aux`), pdf: join(build, `${name}.pdf`), blg: join(build, `${name}.blg`), nav: join(build, `${name}.nav`) };96 }97 need(path, what) {98 if (!existsSync(path)) throw new LatexError("no_build", `No ${what} found (${this.rel(path)}). Run compile first.`);99 return readFileSync(path, "utf8");100 }101 readLog(path) { return this.need(path, "log").replace(/�/g, "?"); }102103 /* ───────── processes ───────── */104105 which(tool) {106 if (this.found.has(tool)) return this.found.get(tool);107 let hit = null;108 for (const d of this.path) { const p = join(d, tool); try { accessSync(p, constants.X_OK); if (statSync(p).isFile()) { hit = p; break; } } catch { /* next */ } }109 this.found.set(tool, hit);110 return hit;111 }112 require(tool, hint) {113 const p = this.which(tool);114 if (!p) throw new LatexError("tool_missing", `${tool} is not installed or not on PATH. ${hint ?? "Install TeX Live / MacTeX, or add its bin directory to PATH."}`);115 return p;116 }117 run(tool, args, { cwd, timeout = this.timeout, env = {} } = {}) {118 const bin = this.require(tool);119 return new Promise((done) => {120 execFile(bin, args, { cwd, timeout, maxBuffer: 64 * 1024 * 1024, killSignal: "SIGKILL", env: { ...this.env, PATH: this.path.join(delimiter), max_print_line: "1000", error_line: "254", half_error_line: "238", ...env } }, (err, stdout, stderr) => {121 done({ code: err ? (typeof err.code === "number" ? err.code : 1) : 0, timed_out: !!err?.killed, stdout: String(stdout), stderr: String(stderr) });122 });123 });124 }125126 async toolchainStatus() {127 const tools = {};128 await Promise.all(TOOLS.map(async (t) => {129 const path = this.which(t);130 if (!path) { tools[t] = { installed: false }; return; }131 const r = await this.run(t, VERSION_ARGS[t] ?? ["--version"], { timeout: 15000 });132 const first = (r.stdout + "\n" + r.stderr).split("\n").map((l) => l.trim()).find((l) => /\d/.test(l)) ?? "";133 tools[t] = { installed: true, path, version: first.slice(0, 120) };134 }));135 const has = (t) => tools[t].installed;136 return {137 root: this.root, build_dir: BUILD_DIR, timeout_s: this.timeout / 1000, tools,138 engines_available: Object.keys(ENGINES).filter(has),139 capabilities: {140 compile: Object.keys(ENGINES).some(has), compile_driver: has("latexmk") ? "latexmk" : "built-in run loop",141 bibliography: [has("bibtex") && "bibtex", has("biber") && "biber"].filter(Boolean),142 to_docx: has("pandoc"), word_count: has("texcount") ? "texcount" : "built-in approximation", lint: has("chktex"), pdf_text: has("pdftotext"),143 },144 shell_escape: "always disabled (-no-shell-escape)",145 };146 }147148 /* ───────── sources ───────── */149150 /**151 * Expand \input / \include / \subfile from the main file. Returns one record per source line:152 * { file (relative to the main file's directory), line, text (comments removed outside verbatim) }.153 */154 sources(job) {155 const lines = [], files = [], skipped = [];156 const seen = new Set();157 const walk = (abs, depth) => {158 const relFile = relative(job.dir, abs);159 files.push(relFile);160 let verbatim = null;161 const raw = readFileSync(abs, "utf8").split(/\r?\n/);162 for (let n = 0; n < raw.length; n++) {163 let text = raw[n];164 if (verbatim) { if (text.includes(`\\end{${verbatim}}`)) verbatim = null; lines.push({ file: relFile, line: n + 1, text, verbatim: true }); continue; }165 const v = /\\begin\{(verbatim\*?|lstlisting|minted|Verbatim|comment)\}/.exec(text);166 if (v && !text.includes(`\\end{${v[1]}}`)) { verbatim = v[1]; lines.push({ file: relFile, line: n + 1, text, verbatim: true }); continue; }167 text = stripComment(text);168 lines.push({ file: relFile, line: n + 1, text });169 if (depth === 0 && /\\end\{document\}/.test(text)) return true;170 for (const m of text.matchAll(/\\(input|include|subfile)\s*\{([^}]+)\}/g)) {171 let target = m[2].trim();172 if (!/\.[A-Za-z]+$/.test(target)) target += ".tex";173 let child;174 try { child = this.inside(join(job.dir, target)); } catch (e) { skipped.push({ name: m[2], file: relFile, line: n + 1, reason: e.code === "outside_root" ? "outside the allowed root" : "not found" }); continue; }175 if (seen.has(child) || depth > 12) continue;176 seen.add(child);177 walk(child, depth + 1);178 }179 }180 return false;181 };182 seen.add(job.main);183 walk(job.main, 0);184 return { lines, files, skipped };185 }186187 /* ───────── compile ───────── */188189 prepareBuild(job, src) {190 if (existsSync(job.build) && !existsSync(join(job.build, MARKER)) && readdirSync(job.build).length > 0) throw new LatexError("build_dir_in_use", `${this.rel(job.build)} exists and was not created by this tool. Choose another build_dir.`);191 mkdirSync(job.build, { recursive: true });192 writeFileSync(join(job.build, MARKER), "Build directory created by latex-build-mcp. Safe to delete.\n");193 // \include writes one .aux per file, in a matching subdirectory of the output directory.194 for (const l of src.lines) for (const m of l.text.matchAll(/\\include\s*\{([^}]+)\}/g)) { const d = dirname(m[1].trim()); if (d !== "." && !d.startsWith("..") && !isAbsolute(d)) mkdirSync(join(job.build, d), { recursive: true }); }195 }196197 async compile({ file, engine = "pdflatex", build_dir, clean = false, driver = "auto", halt_on_error = true, timeout_s } = {}) {198 if (!ENGINES[engine]) throw new LatexError("bad_engine", `engine must be one of ${Object.keys(ENGINES).join(", ")}`);199 const job = this.job(file, build_dir);200 this.require(engine, `Install TeX Live / MacTeX (it provides ${engine}).`);201 const src = this.sources(job);202 if (clean) this.removeBuild(job);203 this.prepareBuild(job, src);204 const timeout = timeout_s ? Math.min(Math.max(5, timeout_s), 1800) * 1000 : this.timeout;205 const out = relative(job.dir, job.build), mainRel = basename(job.main);206 const texArgs = ["-interaction=nonstopmode", ...(halt_on_error ? ["-halt-on-error"] : []), "-file-line-error", "-no-shell-escape"];207 const useLatexmk = driver === "latexmk" || (driver === "auto" && !!this.which("latexmk"));208 const started = Date.now();209 let steps = [], timedOut = false, exit = 0;210 if (useLatexmk) {211 const r = await this.run("latexmk", ["-norc", ENGINES[engine], ...texArgs, ...(halt_on_error ? [] : ["-f"]), `-outdir=${out}`, mainRel], { cwd: job.dir, timeout });212 steps.push({ tool: "latexmk", exit_code: r.code }); timedOut = r.timed_out; exit = r.code;213 } else {214 const tex = async () => { const r = await this.run(engine, [...texArgs, `-output-directory=${out}`, mainRel], { cwd: job.dir, timeout }); steps.push({ tool: engine, exit_code: r.code }); timedOut ||= r.timed_out; exit = r.code; return r.code === 0 || !halt_on_error; };215 if (await tex()) {216 const aux = existsSync(job.aux) ? readFileSync(job.aux, "utf8") : "";217 let bib = false;218 if (existsSync(join(job.build, `${job.name}.bcf`))) {219 if (this.which("biber")) { const r = await this.run("biber", ["--input-directory", out, "--output-directory", out, job.name], { cwd: job.dir, timeout }); steps.push({ tool: "biber", exit_code: r.code }); bib = true; }220 else steps.push({ tool: "biber", skipped: "not installed" });221 } else if (/\\bibdata\{/.test(aux)) {222 if (this.which("bibtex")) { const r = await this.run("bibtex", [join(out, job.name)], { cwd: job.dir, timeout }); steps.push({ tool: "bibtex", exit_code: r.code }); bib = true; }223 else steps.push({ tool: "bibtex", skipped: "not installed" });224 }225 for (let i = 0; i < (bib ? 3 : 2) && !timedOut; i++) {226 if (i > 0 && !parseLog(readFileSync(job.log, "utf8")).rerun_needed && !(bib && i === 1)) break;227 if (!(await tex())) break;228 }229 }230 }231 const logText = existsSync(job.log) ? this.readLog(job.log) : "";232 const log = parseLog(logText);233 const pdfOk = existsSync(job.pdf) && statSync(job.pdf).mtimeMs >= started - 2000;234 const head = logText.slice(0, 600);235 const success = pdfOk && log.errors.length === 0 && !timedOut;236 return {237 success, engine, driver: useLatexmk ? "latexmk" : "built-in", exit_code: exit, timed_out: timedOut, duration_s: Math.round((Date.now() - started) / 100) / 10,238 pdf: pdfOk ? this.rel(job.pdf) : null, pages: pdfOk ? log.pages : null, build_dir: this.rel(job.build), log: existsSync(job.log) ? this.rel(job.log) : null,239 shell_escape: !logText ? "unknown" : /restricted \\write18 enabled/.test(head) ? "restricted" : /\\write18 enabled/.test(head) ? "enabled" : "disabled", shell_commands_blocked: log.shell_commands_blocked, shell_commands_executed: log.shell_commands_executed,240 steps, summary: log.summary, errors: log.errors.slice(0, 20).map((e) => ({ ...e, explanation: explainError(e.message).cause })),241 undefined_references: log.undefined_references, undefined_citations: log.undefined_citations, duplicate_labels: log.duplicate_labels, missing_files: log.missing_files,242 overfull_worst: [...log.overfull].sort((a, b) => b.amount_pt - a.amount_pt).slice(0, 10),243 ...(timedOut ? { note: `Stopped after ${timeout / 1000} s. Raise timeout_s or LATEX_MCP_TIMEOUT_S.` } : {}),244 };245 }246247 parseLogFile({ file, build_dir, log_path, max_items = 200 } = {}) {248 const path = log_path ? this.inside(log_path) : this.job(file, build_dir).log;249 const r = parseLog(this.readLog(path));250 const cap = (a) => a.slice(0, max_items);251 return { log: this.rel(path), ...r, errors: cap(r.errors).map((e) => ({ ...e, explanation: explainError(e.message) })), warnings: cap(r.warnings), overfull: cap(r.overfull), underfull: cap(r.underfull) };252 }253254 /* ───────── Beamer ───────── */255256 isBeamer(src) { return src.lines.some((l) => /\\documentclass(\[[^\]]*\])?\{beamer\}/.test(l.text)); }257258 /** Frames in source order: { number, title, file, line, options }. */259 frames(src) {260 const frames = [];261 for (let i = 0; i < src.lines.length; i++) {262 const l = src.lines[i];263 if (l.verbatim) continue;264 for (const m of l.text.matchAll(/\\begin\{frame\}/g)) {265 // Join the following lines so a title on the next line or a \frametitle inside the frame is found.266 let body = l.text.slice(m.index + m[0].length), end = i;267 while (!/\\end\{frame\}/.test(body) && end + 1 < src.lines.length && end - i < 400) body += "\n" + src.lines[++end].text;268 body = body.split("\\end{frame}")[0];269 const optStart = body.search(/\S/);270 const opt = /^\s*(?:<[^>]*>)?\s*\[([^\]]*)\]/.exec(body);271 let j = skipOptional(body, Math.max(0, optStart)), title = null, subtitle = null;272 const g = braceArg(body, j);273 if (g) { title = g.text; const g2 = braceArg(body, g.end); if (g2) subtitle = g2.text; }274 const ft = /\\frametitle\s*(?:<[^>]*>)?\s*(?:\[[^\]]*\])?\s*\{/.exec(body);275 if (!title && ft) title = braceArg(body, ft.index + ft[0].length - 1)?.text ?? null;276 frames.push({ number: frames.length + 1, title: title ? plain(title) : null, ...(subtitle ? { subtitle: plain(subtitle) } : {}), file: l.file, line: l.line, end_line: src.lines[end].file === l.file ? src.lines[end].line : null, options: opt ? opt[1] : null });277 }278 }279 return frames;280 }281282 /** Page ranges per frame from the .nav file written by Beamer. */283 framePages(job) {284 if (!existsSync(job.nav)) return null;285 const pages = [];286 for (const m of readFileSync(job.nav, "utf8").matchAll(/\\beamer@framepages\s*\{(\d+)\}\{(\d+)\}/g)) pages.push({ first: Number(m[1]), last: Number(m[2]) });287 return pages;288 }289290 beamerReport({ file, build_dir, vbox_threshold_pt = 0, hbox_threshold_pt = 1 } = {}) {291 const job = this.job(file, build_dir), src = this.sources(job);292 if (!this.isBeamer(src)) throw new LatexError("not_beamer", "The main file does not use \\documentclass{beamer}.");293 const frames = this.frames(src);294 const log = parseLog(this.readLog(job.log));295 const pages = this.framePages(job);296 const exact = !!pages && pages.length === frames.length;297 const frameOfPage = (p) => { if (!pages) return null; const i = pages.findIndex((r) => p >= r.first && p <= r.last); return i < 0 ? null : i + 1; };298 const rows = frames.map((f, i) => ({ ...f, pages: exact ? pages[i] : null, slides: exact ? pages[i].last - pages[i].first + 1 : null, overflow_vertical_pt: 0, overfull_hbox_pt: 0 }));299 const unmapped = [];300 for (const b of log.overfull) {301 if (b.box === "vbox" ? b.amount_pt <= vbox_threshold_pt : b.amount_pt <= hbox_threshold_pt) continue;302 // Prefer the page (exact when the .nav matches the source); fall back to the source line.303 let n = exact ? frameOfPage(b.page) : null;304 if (n == null && b.line_start != null) n = rows.find((f) => (f.file === b.file || b.file == null) && f.end_line != null && b.line_start >= f.line && b.line_start <= f.end_line)?.number ?? null;305 const row = n ? rows[n - 1] : null;306 if (!row) { unmapped.push(b); continue; }307 if (b.box === "vbox") row.overflow_vertical_pt = Math.max(row.overflow_vertical_pt, b.amount_pt);308 else row.overfull_hbox_pt = Math.max(row.overfull_hbox_pt, b.amount_pt);309 }310 const overflowing = rows.filter((f) => f.overflow_vertical_pt > 0);311 return {312 file: this.rel(job.main), frames_total: rows.length, pages_total: log.pages, frame_page_mapping: exact ? "exact (.nav)" : pages ? "approximate: the .nav frame count differs from the source (frames made by macros, \\againframe or conditionals)" : "unavailable: no .nav file, compile first",313 overflowing_frames: overflowing.map((f) => ({ number: f.number, title: f.title, file: f.file, line: f.line, overflow_pt: f.overflow_vertical_pt, pages: f.pages })),314 frames_with_overfull_hbox: rows.filter((f) => f.overfull_hbox_pt > 0).map((f) => ({ number: f.number, title: f.title, file: f.file, line: f.line, overfull_pt: f.overfull_hbox_pt })),315 frames_without_title: rows.filter((f) => !f.title && !/\\(titlepage|maketitle|tableofcontents)/.test(src.lines.filter((l) => l.file === f.file && l.line >= f.line && l.line <= (f.end_line ?? f.line)).map((l) => l.text).join(" "))).map((f) => ({ number: f.number, file: f.file, line: f.line })),316 unmapped_boxes: unmapped, frames: rows,317 note: "A frame overflows when Beamer reports an overfull \\vbox for its page. Content pushed past the bottom edge by absolute positioning (textpos, TikZ overlay) raises no warning and is not detected.",318 };319 }320321 checkOverflow({ file, build_dir, hbox_threshold_pt = 1, vbox_threshold_pt = 0 } = {}) {322 const job = this.job(file, build_dir), src = this.sources(job);323 const log = parseLog(this.readLog(job.log));324 const hbox = log.overfull.filter((b) => b.box === "hbox" && b.amount_pt > hbox_threshold_pt).sort((a, b) => b.amount_pt - a.amount_pt);325 const vbox = log.overfull.filter((b) => b.box === "vbox" && b.amount_pt > vbox_threshold_pt).sort((a, b) => b.amount_pt - a.amount_pt);326 const textOf = (b) => { const l = src.lines.find((x) => x.file === b.file && x.line === b.line_start); return l ? l.text.trim().slice(0, 120) : null; };327 const res = {328 file: this.rel(job.main), thresholds_pt: { hbox: hbox_threshold_pt, vbox: vbox_threshold_pt }, passed: hbox.length === 0 && vbox.length === 0,329 overfull_hbox: hbox.map((b) => ({ ...b, source: textOf(b) })), overfull_vbox: vbox,330 ignored_below_threshold: log.overfull.length - hbox.length - vbox.length, underfull_count: log.underfull.length,331 };332 if (this.isBeamer(src)) {333 const rep = this.beamerReport({ file, build_dir, vbox_threshold_pt, hbox_threshold_pt });334 res.beamer = { frames_total: rep.frames_total, frame_page_mapping: rep.frame_page_mapping, overflowing_frames: rep.overflowing_frames, frames_with_overfull_hbox: rep.frames_with_overfull_hbox };335 }336 return res;337 }338339 /* ───────── labels, references, citations ───────── */340341 bibFiles(job, src) {342 const names = [];343 for (const l of src.lines) if (!l.verbatim) for (const m of l.text.matchAll(/\\(?:bibliography|addbibresource|addglobalbib)\s*(?:\[[^\]]*\])?\{([^}]+)\}/g)) for (const n of m[1].split(",")) names.push(n.trim());344 const files = [], missing = [];345 for (const n of [...new Set(names)]) {346 const p = join(job.dir, /\.bib$/i.test(n) ? n : `${n}.bib`);347 try { files.push(this.inside(p)); } catch { missing.push(n); }348 }349 return { files, missing };350 }351 bibEntries(files) {352 const entries = [];353 for (const f of files) {354 const lines = readFileSync(f, "utf8").split(/\r?\n/);355 lines.forEach((t, i) => { const m = /^\s*@([A-Za-z]+)\s*[{(]\s*([^,\s]+)\s*,/.exec(t); if (m && !/^(comment|string|preamble)$/i.test(m[1])) entries.push({ key: m[2], type: m[1].toLowerCase(), file: this.rel(f), line: i + 1 }); });356 }357 return entries;358 }359360 auditReferences({ file, build_dir } = {}) {361 const job = this.job(file, build_dir), src = this.sources(job);362 const labels = [], refs = [], cites = [], bibitems = [];363 const envStack = [];364 let nociteAll = false;365 for (const l of src.lines) {366 if (l.verbatim) continue;367 const events = [];368 for (const m of l.text.matchAll(/\\(begin|end)\{([A-Za-z*]+)\}/g)) events.push({ at: m.index, kind: m[1], env: m[2].replace(/\*$/, "") });369 for (const m of l.text.matchAll(/\\label\s*\{([^}]+)\}/g)) events.push({ at: m.index, kind: "label", key: m[1].trim() });370 events.sort((a, b) => a.at - b.at);371 for (const e of events) {372 if (e.kind === "begin") envStack.push(e.env);373 else if (e.kind === "end") { const i = envStack.lastIndexOf(e.env); if (i >= 0) envStack.splice(i); }374 else { const fl = [...envStack].reverse().find((x) => /^(figure|table|subfigure|subtable|wrapfigure|sidewaysfigure|sidewaystable|longtable|algorithm|listing)$/.test(x)); labels.push({ label: e.key, file: l.file, line: l.line, float: fl ?? null }); }375 }376 for (const m of l.text.matchAll(/\\(ref|eqref|autoref|cref|Cref|pageref|nameref|vref|Vref|cpageref|Cpageref|labelcref|namecref|crefrange|Crefrange|subref|fullref|hyperref)\*?\s*(?:\[([^\]]*)\])?\s*(\{[^}]*\}(?:\s*\{[^}]*\})?)?/g)) {377 const keys = m[1] === "hyperref" ? (m[2] ? [m[2]] : []) : (m[3] ?? "").replace(/^\{|\}$/g, "").split(/\}\s*\{|,/);378 for (const k of keys) if (k.trim()) refs.push({ label: k.trim(), command: m[1], file: l.file, line: l.line });379 }380 for (const m of l.text.matchAll(/\\([A-Za-z]*cite[A-Za-z]*)\*?\s*((?:\[[^\]]*\]\s*)*)\{([^}]*)\}/g)) {381 if (m[1] === "bibcite") continue;382 for (const k of m[3].split(",")) { const key = k.trim(); if (key === "*") nociteAll = true; else if (key) cites.push({ key, command: m[1], file: l.file, line: l.line }); }383 }384 for (const m of l.text.matchAll(/\\bibitem\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g)) bibitems.push(m[1].trim());385 }386 const byLabel = new Map();387 for (const x of labels) byLabel.set(x.label, [...(byLabel.get(x.label) ?? []), x]);388 const referenced = new Set(refs.map((r) => r.label));389 const bib = this.bibFiles(job, src), entries = this.bibEntries(bib.files);390 const known = new Set([...entries.map((e) => e.key), ...bibitems]);391 const cited = new Set(cites.map((c) => c.key));392 const byKey = new Map();393 for (const e of entries) byKey.set(e.key.toLowerCase(), [...(byKey.get(e.key.toLowerCase()) ?? []), e]);394 const hasBib = bib.files.length > 0 || bibitems.length > 0;395 const unused = labels.filter((x) => !referenced.has(x.label));396 const res = {397 file: this.rel(job.main), files_scanned: src.files, counts: { labels: labels.length, references: refs.length, citations: cites.length, bib_entries: entries.length },398 undefined_references: refs.filter((r) => !byLabel.has(r.label)),399 duplicate_labels: [...byLabel].filter(([, v]) => v.length > 1).map(([label, v]) => ({ label, locations: v.map(({ file, line }) => ({ file, line })) })),400 unused_labels: unused.map(({ label, file, line, float }) => ({ label, file, line, ...(float ? { float } : {}) })),401 unreferenced_floats: unused.filter((x) => x.float).map(({ label, file, line, float }) => ({ label, float, file, line })),402 undefined_citations: hasBib ? cites.filter((c) => !known.has(c.key)) : [],403 unused_bib_entries: nociteAll ? [] : entries.filter((e) => !cited.has(e.key)),404 duplicate_bib_keys: [...byKey].filter(([, v]) => v.length > 1).map(([, v]) => ({ key: v[0].key, locations: v.map(({ file, line }) => ({ file, line })) })),405 missing_bib_files: bib.missing, skipped_inputs: src.skipped,406 note: "Static scan of the sources: labels and keys built by macros, and references inside conditionals or verbatim, are not seen. Unused section labels are often harmless; unreferenced floats usually are not." + (hasBib ? "" : " No bibliography database was found, so citations were not checked."),407 };408 res.passed = res.undefined_references.length === 0 && res.duplicate_labels.length === 0 && res.undefined_citations.length === 0 && res.unreferenced_floats.length === 0;409 return res;410 }411412 bibAudit({ file, build_dir } = {}) {413 const job = this.job(file, build_dir), src = this.sources(job);414 const bib = this.bibFiles(job, src), entries = this.bibEntries(bib.files);415 const usesBiblatex = src.lines.some((l) => /\\usepackage(\[[^\]]*\])?\{biblatex\}/.test(l.text));416 const backendOpt = /backend\s*=\s*(biber|bibtex8?)/.exec(src.lines.filter((l) => /\{biblatex\}/.test(l.text)).map((l) => l.text).join(" "));417 const expected = usesBiblatex ? (backendOpt ? backendOpt[1].replace("8", "") : "biber") : src.lines.some((l) => /\\bibliography\s*\{/.test(l.text)) ? "bibtex" : null;418 const res = { file: this.rel(job.main), expected_backend: expected, bib_files: bib.files.map((f) => this.rel(f)), missing_bib_files: bib.missing, entries: entries.length };419 const required = { article: ["author", "title", "year"], book: ["title", "year"], inproceedings: ["author", "title", "year"], incollection: ["author", "title", "year"], phdthesis: ["author", "title", "year"], techreport: ["author", "title", "year"] };420 res.incomplete_entries = [];421 for (const f of bib.files) {422 const text = readFileSync(f, "utf8");423 for (const m of text.matchAll(/@([A-Za-z]+)\s*\{\s*([^,\s]+)\s*,/g)) {424 const need = required[m[1].toLowerCase()]; if (!need) continue;425 const body = braceArg(text, m.index + m[0].indexOf("{"))?.text ?? "";426 const missing = need.filter((k) => !new RegExp(`(^|[\\s,])${k}\\s*=`, "i").test(body) && !(k === "year" && /(^|[\s,])date\s*=/i.test(body)) && !(k === "author" && /(^|[\s,])editor\s*=/i.test(body)));427 if (missing.length) res.incomplete_entries.push({ key: m[2], type: m[1].toLowerCase(), file: this.rel(f), missing_fields: missing });428 }429 }430 if (existsSync(job.blg)) {431 const log = parseBibLog(readFileSync(job.blg, "utf8"));432 Object.assign(res, { log: this.rel(job.blg), backend_run: log.backend, missing_entries: log.missing_entries, warnings: log.warnings, errors: log.errors, missing_databases: log.missing_databases });433 if (expected && log.backend !== "unknown" && log.backend !== expected) res.backend_mismatch = `The document expects ${expected} but the log in the build directory comes from ${log.backend}.`;434 } else {435 res.log = null;436 res.note = expected ? `No .blg in the build directory: ${expected} has not run (compile first; a build that stops on an error never reaches the bibliography step).` : "The document declares no bibliography.";437 }438 res.passed = !!res.log && res.missing_entries.length === 0 && res.errors.length === 0 && !res.backend_mismatch && bib.missing.length === 0;439 return res;440 }441442 /* ───────── counting, lint ───────── */443444 async wordCount({ file } = {}) {445 const job = this.job(file);446 if (this.which("texcount")) {447 const r = await this.run("texcount", ["-inc", "-total", "-brief", "-utf8", basename(job.main)], { cwd: job.dir, timeout: 60000 });448 const m = /(\d+)\+(\d+)\+(\d+)\s+\((\d+)\/(\d+)\/(\d+)\/(\d+)\)/.exec(r.stdout);449 if (m) {450 const [text, headers, captions, nHeaders, floats, inlineMath, displayMath] = m.slice(1).map(Number);451 return { file: this.rel(job.main), method: "texcount", words_text: text, words_headers: headers, words_captions: captions, words_total: text + headers + captions, headers: nHeaders, floats, math_inline: inlineMath, math_display: displayMath, note: "texcount rules: included files are counted (-inc); the bibliography, macros' expansions and text inside unknown environments are counted by texcount's defaults." };452 }453 }454 const src = this.sources(job);455 let text = src.lines.filter((l) => !l.verbatim).map((l) => l.text).join("\n");456 const start = text.indexOf("\\begin{document}"), stop = text.indexOf("\\end{document}");457 if (start >= 0) text = text.slice(start + 16, stop > start ? stop : undefined);458 for (const e of [...MATH_ENVS, "tabular", "tikzpicture", "thebibliography"]) text = text.replace(new RegExp(`\\\\begin\\{${e}\\*?\\}[\\s\\S]*?\\\\end\\{${e}\\*?\\}`, "g"), " ");459 text = text.replace(/\$\$[\s\S]*?\$\$|\$[^$]*\$|\\\[[\s\S]*?\\\]|\\\([\s\S]*?\\\)/g, " ").replace(/\\(?:label|ref|eqref|cite[a-z]*|autoref|cref|Cref|input|include|includegraphics|bibliography|bibliographystyle|usepackage|begin|end|vspace|hspace)\*?(?:\[[^\]]*\])?\{[^}]*\}/g, " ").replace(/\\[a-zA-Z@]+\*?/g, " ").replace(/[{}\[\]~\\&_^]/g, " ");460 const words = text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length;461 return { file: this.rel(job.main), method: "built-in approximation", words_total: words, note: "texcount is not installed. This fallback drops math, tables, TikZ and command names, and counts what is left, captions and headings included; expect a few percent of difference with texcount." };462 }463464 async lint({ file, all = false, max_items = 100 } = {}) {465 const job = this.job(file);466 this.require("chktex", "chktex ships with TeX Live (tlmgr install chktex).");467 const r = await this.run("chktex", ["-q", "-v0", basename(job.main)], { cwd: job.dir, timeout: 60000 });468 const items = [];469 for (const line of r.stdout.split("\n")) { const m = /^(.+?):(\d+):(\d+):(\d+):(.*)$/.exec(line); if (m) items.push({ file: m[1].replace(/^\.\//, ""), line: Number(m[2]), column: Number(m[3]), rule: Number(m[4]), message: m[5].trim() }); }470 const kept = all ? items : items.filter((i) => LINT_RULES[i.rule]);471 const byRule = {};472 for (const i of kept) byRule[i.rule] = (byRule[i.rule] ?? 0) + 1;473 return { file: this.rel(job.main), mode: all ? "all chktex rules" : "curated subset", total: kept.length, hidden_by_curation: items.length - kept.length, by_rule: Object.entries(byRule).map(([rule, count]) => ({ rule: Number(rule), count, why: LINT_RULES[rule] ?? null })), items: kept.slice(0, max_items) };474 }475476 /* ───────── numbering from the .aux ───────── */477478 readAux(job) {479 const labels = new Map(), cites = new Map(), captions = [];480 const seen = new Set();481 const read = (path) => {482 if (seen.has(path) || !existsSync(path)) return;483 seen.add(path);484 for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {485 let m;486 if (line.startsWith("\\newlabel{")) {487 const k = braceArg(line, 9); if (!k) continue;488 const body = braceArg(line, k.end); if (!body) continue;489 if (k.text.endsWith("@cref")) {490 const t = /^\{\[([^\]]+)\]/.exec(body.text);491 if (t) { const key = k.text.slice(0, -5); labels.set(key, { ...(labels.get(key) ?? {}), kind: t[1] }); }492 continue;493 }494 const parts = []; let i = 0;495 while (i < body.text.length && parts.length < 5) { const g = braceArg(body.text, i); if (!g) break; parts.push(g.text); i = g.end; }496 const anchor = parts[3] ?? "";497 const kind = labels.get(k.text)?.kind ?? (/^([A-Za-z]+)\*?\./.exec(anchor)?.[1] ?? null);498 labels.set(k.text, { number: plain(parts[0] ?? ""), page: parts[1] ?? null, title: parts[2] ? plain(parts[2]) : null, kind: kind === "AMS" ? "equation" : kind });499 } else if ((m = /^\\bibcite\{([^}]+)\}\{(.*)\}$/.exec(line))) cites.set(m[1], plain(m[2].split("}{")[0]));500 else if ((m = /^\\@writefile\{(lof|lot)\}\{\\contentsline \{(figure|table)\}\{\\numberline \{([^}]*)\}/.exec(line))) {501 const g = braceArg(line, line.indexOf("{\\numberline") ); const tail = line.slice(g ? g.end : 0); const page = braceArg(tail, 0)?.text ?? null;502 const cap = g ? g.text.replace(/^\\numberline \{[^}]*\}/, "").replace(/\\ignorespaces\s*/g, "").replace(/\\relax\s*/g, "") : "";503 captions.push({ kind: m[2], number: m[3], caption: plain(cap.replace(/^\{|\}$/g, "")), page });504 } else if ((m = /^\\@input\{([^}]+)\}/.exec(line))) read(join(dirname(path), m[1]));505 }506 };507 read(job.aux);508 return { labels, cites, captions };509 }510511 /** Kind of each label from the environment that contains it in the source (used when the .aux has no hyperref anchors). */512 labelKinds(src) {513 const kinds = new Map(), stack = [];514 let lastSection = null;515 for (const l of src.lines) {516 if (l.verbatim) continue;517 const ev = [];518 for (const m of l.text.matchAll(/\\(begin|end)\{([A-Za-z*]+)\}/g)) ev.push({ at: m.index, t: m[1], v: m[2].replace(/\*$/, "") });519 for (const m of l.text.matchAll(/\\(part|chapter|section|subsection|subsubsection)\*?\s*[\[{]/g)) ev.push({ at: m.index, t: "sec", v: m[1] });520 for (const m of l.text.matchAll(/\\label\s*\{([^}]+)\}/g)) ev.push({ at: m.index, t: "label", v: m[1].trim() });521 ev.sort((a, b) => a.at - b.at);522 for (const e of ev) {523 if (e.t === "begin") stack.push(e.v);524 else if (e.t === "end") { const i = stack.lastIndexOf(e.v); if (i >= 0) stack.splice(i); }525 else if (e.t === "sec") lastSection = e.v;526 else {527 const env = [...stack].reverse().find((x) => x !== "document" && x !== "center" && x !== "minipage" && x !== "frame");528 const k = !env ? lastSection : /figure/.test(env) ? "figure" : /table/.test(env) ? "table" : MATH_ENVS.includes(env) || env === "subequations" ? "equation" : /^(enumerate|itemize)$/.test(env) ? "item" : env;529 kinds.set(e.v, k);530 }531 }532 }533 return kinds;534 }535536 numbering(job, src) {537 this.need(job.aux, ".aux file");538 const aux = this.readAux(job), kinds = this.labelKinds(src);539 const labels = [...aux.labels].filter(([, v]) => v.number != null).map(([label, v]) => ({ label, number: v.number, page: v.page, kind: v.kind ?? kinds.get(label) ?? null, ...(v.title ? { title: v.title } : {}) }));540 return { labels, cites: aux.cites, captions: aux.captions };541 }542543 extractNumbering({ file, build_dir } = {}) {544 const job = this.job(file, build_dir), src = this.sources(job);545 const n = this.numbering(job, src);546 const dup = new Map();547 for (const l of n.labels) if (l.kind && l.number) { const k = `${l.kind}|${l.number}`; dup.set(k, [...(dup.get(k) ?? []), l.label]); }548 const byKind = {};549 for (const l of n.labels) (byKind[l.kind ?? "unknown"] ??= []).push(l.number);550 return {551 file: this.rel(job.main), aux: this.rel(job.aux), labels: n.labels, counts_by_kind: Object.fromEntries(Object.entries(byKind).map(([k, v]) => [k, v.length])),552 captions: n.captions, citations: [...n.cites].map(([key, number]) => ({ key, label: number })),553 same_number_shared: [...dup].filter(([, v]) => v.length > 1).map(([k, v]) => ({ kind: k.split("|")[0], number: k.split("|")[1], labels: v })),554 note: "Numbers are those of the last compile. Two labels sharing one number are legitimate when both sit in the same float or section; otherwise a \\label was placed before its \\caption.",555 };556 }557558 /* ───────── LaTeX → Word ───────── */559560 async toDocx({ file, build_dir, output, number_sections = true, csl, overwrite = false } = {}) {561 const job = this.job(file, build_dir), src = this.sources(job);562 this.require("pandoc", "Install pandoc (brew install pandoc).");563 const n = this.numbering(job, src);564 const byLabel = new Map(n.labels.map((l) => [l.label, l]));565 const unresolved = new Set();566 const numOf = (k) => { const l = byLabel.get(k.trim()); if (!l) { unresolved.add(k.trim()); return "??"; } return l.number; };567 const word = (k, cap) => { const kind = byLabel.get(k.trim())?.kind; const w = REF_KINDS[kind] ?? (kind ? kind[0].toUpperCase() + kind.slice(1) : ""); return cap ? w : w.toLowerCase(); };568 const many = (keys, cap, withWord) => {569 const ks = keys.split(",").map((k) => k.trim()).filter(Boolean);570 const nums = ks.map(numOf);571 const list = nums.length > 2 ? `${nums.slice(0, -1).join(", ")} and ${nums.at(-1)}` : nums.join(" and ");572 if (!withWord) return list;573 let w = word(ks[0], cap);574 if (w && ks.length > 1) w += "s";575 return w ? `${w}~${list}` : list;576 };577 let tex = src.lines.map((l) => l.text.replace(/\\(input|include|subfile)\s*\{[^}]+\}/g, "")).join("\n");578 const stats = { references: 0, equations: 0, captions: 0 };579 // Equation numbers: pandoc drops them, so write each number in the equation itself.580 for (const env of MATH_ENVS) {581 tex = tex.replace(new RegExp(`(\\\\begin\\{${env}\\}(?:\\{[^}]*\\})?)([\\s\\S]*?)(\\\\end\\{${env}\\})`, "g"), (all, a, body, z) => {582 if (!/\\label\s*\{/.test(body)) return all;583 const rows = body.split(/(\\\\(?:\[[^\]]*\])?)/);584 const outRows = rows.map((row) => { const m = /\\label\s*\{([^}]+)\}/.exec(row); if (!m) return row; stats.equations++; return `${row.replace(m[0], "").replace(/\s+$/, "")} \\qquad (${numOf(m[1])})\n`; });585 return a + outRows.join("") + z;586 });587 }588 // Captions: prefix with the number of the label found in the same float.589 tex = tex.replace(/\\begin\{(figure|table|sidewaysfigure|sidewaystable|wrapfigure)\*?\}([\s\S]*?)\\end\{\1\*?\}/g, (all, env) => {590 const lab = /\\label\s*\{([^}]+)\}/.exec(all), cap = /\\caption\s*(?:\[[^\]]*\])?\s*\{/.exec(all);591 if (!lab || !cap || !byLabel.has(lab[1].trim())) return all;592 stats.captions++;593 const at = cap.index + cap[0].length;594 return `${all.slice(0, at)}${/table/.test(env) ? "Table" : "Figure"} ${numOf(lab[1])}. ${all.slice(at)}`;595 });596 tex = tex.replace(/\\(eqref|ref|pageref|autoref|cref|Cref|labelcref|vref|Vref|nameref)\*?\s*\{([^}]+)\}/g, (all, cmd, keys) => {597 stats.references++;598 if (cmd === "eqref") return `(${many(keys, false, false)})`;599 if (cmd === "pageref") { const l = byLabel.get(keys.trim()); if (!l) { unresolved.add(keys.trim()); return "??"; } return l.page ?? "??"; }600 if (cmd === "nameref") { const l = byLabel.get(keys.trim()); if (!l?.title) { unresolved.add(keys.trim()); return "??"; } return l.title; }601 if (cmd === "ref" || cmd === "labelcref") return many(keys, false, false);602 return many(keys, cmd !== "cref" && cmd !== "vref", true);603 });604 const flat = join(job.build, `${job.name}.pandoc.tex`);605 writeFileSync(flat, tex);606 let out = join(job.build, `${job.name}.docx`);607 if (output) {608 out = this.inside(output, { mustExist: false });609 if (!/\.docx$/i.test(out)) throw new LatexError("bad_output", "output must end in .docx");610 if (existsSync(out) && !overwrite) throw new LatexError("exists", `${this.rel(out)} already exists. Pass overwrite: true or choose another name.`);611 mkdirSync(dirname(out), { recursive: true });612 }613 const bib = this.bibFiles(job, src);614 const args = ["-f", "latex", "-t", "docx", `--resource-path=${job.dir}`, ...(number_sections ? ["--number-sections"] : []), "-o", out];615 if (bib.files.length) { args.push("--citeproc"); if (csl) args.push(`--csl=${this.inside(csl)}`); }616 args.push(flat);617 const r = await this.run("pandoc", args, { cwd: job.dir, timeout: Math.max(this.timeout, 120000) });618 if (r.code !== 0 || !existsSync(out)) throw new LatexError("pandoc_failed", `pandoc failed (exit ${r.code}): ${r.stderr.trim().slice(0, 500)}`);619 return {620 docx: this.rel(out), flattened_source: this.rel(flat), resolved: stats, unresolved_labels: [...unresolved],621 bibliography: bib.files.length ? { engine: "pandoc citeproc", style: csl ? this.rel(this.inside(csl)) : "pandoc default (Chicago author-date)", note: "Citations are re-rendered by citeproc: their form will differ from the PDF unless you pass the matching CSL file." } : null,622 pandoc_warnings: r.stderr.split("\n").filter((l) => l.startsWith("[WARNING]")).map((l) => l.slice(10).trim().slice(0, 200)).slice(0, 20),623 does_not_survive: [624 "Cross-references become plain text: they no longer update in Word.",625 "Equation numbers are typed at the right of each equation, not Word equation numbering.",626 "Section numbers come from pandoc (--number-sections), not from the .aux: they differ from the PDF when the document has unnumbered sections, an appendix or a custom counter.",627 "TikZ / pgfplots pictures, custom macros with arguments pandoc cannot expand, page layout, floats placement, fonts and footnote styles.",628 "Images in PDF or EPS are linked as-is and often do not display in Word: convert them to PNG first.",629 "Citation style (see bibliography).",630 ],631 };632 }633634 /* ───────── PDF text ───────── */635636 async pdfText({ file, build_dir, pdf_path, first_page = 1, last_page, layout = true, max_chars = 20000 } = {}) {637 const pdf = pdf_path ? this.inside(pdf_path) : this.job(file, build_dir).pdf;638 if (!existsSync(pdf)) throw new LatexError("no_build", `No PDF found (${this.rel(pdf)}). Run compile first.`);639 if (!/\.pdf$/i.test(pdf)) throw new LatexError("bad_file", "Not a .pdf file.");640 this.require("pdftotext", "Install poppler (brew install poppler).");641 const last = last_page ?? first_page + 4;642 const r = await this.run("pdftotext", ["-f", String(first_page), "-l", String(last), ...(layout ? ["-layout"] : []), "-enc", "UTF-8", pdf, "-"], { timeout: 60000 });643 if (r.code !== 0) throw new LatexError("pdftotext_failed", r.stderr.trim().slice(0, 300) || `pdftotext exit ${r.code}`);644 const pages = r.stdout.split("\f");645 if (pages.at(-1)?.trim() === "") pages.pop();646 let used = 0;647 const outPages = [];648 for (let i = 0; i < pages.length; i++) { const t = pages[i].replace(/\s+$/, ""); if (used + t.length > max_chars) { outPages.push({ page: first_page + i, text: t.slice(0, Math.max(0, max_chars - used)), truncated: true }); break; } used += t.length; outPages.push({ page: first_page + i, text: t }); }649 return { pdf: this.rel(pdf), first_page, last_page: first_page + pages.length - 1, pages: outPages, truncated: outPages.some((p) => p.truncated) };650 }651652 /* ───────── cleaning ───────── */653654 removeBuild(job) {655 if (!existsSync(job.build)) return { removed: false, reason: "no build directory" };656 if (!existsSync(join(job.build, MARKER))) throw new LatexError("not_ours", `${this.rel(job.build)} was not created by this tool (no ${MARKER} marker). It is left untouched.`);657 const real = realpathSync(job.build);658 if (real !== job.build || real === this.root || real === job.dir) throw new LatexError("not_ours", "Refusing to remove this directory.");659 let files = 0;660 const count = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) e.isDirectory() ? count(join(d, e.name)) : files++; };661 count(real);662 rmSync(real, { recursive: true, force: true });663 return { removed: true, files };664 }665 cleanBuild({ file, build_dir, confirm } = {}) {666 const job = this.job(file, build_dir);667 if (confirm !== true) throw new LatexError("confirm_required", `Removing ${this.rel(job.build)} needs confirm: true.`);668 return { build_dir: this.rel(job.build), ...this.removeBuild(job) };669 }670}671672/** chktex rules kept by default, with the reason each one matters. */673export const LINT_RULES = {674 2: "A reference or citation can be separated from its word by a line break: use ~ before \\ref and \\cite.",675 9: "Mismatched brackets or parentheses.",676 10: "An opening bracket or environment has no match.",677 11: "Write \\ldots (or \\dots) instead of three periods.",678 15: "No match for a closing bracket.",679 16: "Math mode still on at the end of an environment or file.",680 17: "The numbers of opening and closing brackets differ.",681 18: "Use `` and '' for quotation marks, not the straight double quote.",682 24: "A space before \\label can produce a wrong page reference.",683 31: "Text after \\end{document} or on a line that TeX ignores.",684 35: "Write \\sin, \\log, \\max … so the operator is set upright.",685 45: "Use \\[ … \\] instead of $$ … $$ (spacing and fleqn break with $$).",686 49: "An expression such as $x-y$ in text mode uses a hyphen where a minus sign is meant.",687};688