SPB Git

Repository Public Research preview

engine.mjs 46.1 KB 688 lines javascript

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
Files main
Menu

Theme