Repository Public Research preview
1/**2 * Parser for TeX engine logs (pdfTeX, XeTeX, LuaTeX) and for BibTeX / Biber logs.3 * Written against real logs produced with max_print_line=1000 and -file-line-error, which is how4 * this package runs the engines; wrapped 79-column logs are parsed too, with less precise locations.5 * Pure functions, no I/O.6 */78const MESSAGE_LINE = /^(runsystem|Overfull|Underfull|LaTeX|Package|Class|pdfTeX|Missing character|! |l\.\d+|\S+:\d+: |Output written|Transcript written|Runaway|No file |Dimensions|Type |\*\*|This is|entering extended mode|restricted \\write18| \\write18|LaTeX2e|L3 programming|Document Class|File: |Library |\\[A-Za-z@]+=)/;9const FILE_TOKEN = /^(\.{0,2}\/|[A-Za-z]:[\\/])?[^\s(){}"]+\.[A-Za-z0-9]{1,6}/;1011function normFile(f) {12 return f ? f.replace(/^\.\//, "") : null;13}1415/** Follow the "(file ... )" nesting of a log so each message can be attributed to the file being read. */16class FileStack {17 constructor() { this.stack = []; }18 feed(line) {19 for (let i = 0; i < line.length; i++) {20 const c = line[i];21 if (c === "(") {22 const m = FILE_TOKEN.exec(line.slice(i + 1));23 if (m && /^(\.{0,2}\/|[A-Za-z]:)/.test(m[0])) { this.stack.push(m[0]); i += m[0].length; }24 else if (m && /\.(tex|ltx|sty|cls|def|cfg|clo|fd|aux|toc|lof|lot|nav|out|bbl|ind|gls|tikz|pgf|snm|vrb|mkii|enc|ldf|lbx|cbx|bbx|code\.tex)$/i.test(m[0])) { this.stack.push(m[0]); i += m[0].length; }25 else this.stack.push(null);26 } else if (c === ")") this.stack.pop();27 }28 }29 /** Innermost user-level source file: skips system files and generated auxiliaries. */30 current() {31 for (let i = this.stack.length - 1; i >= 0; i--) {32 const f = this.stack[i];33 if (f && !f.startsWith("/") && /\.(tex|ltx|tikz|pgf)$/i.test(f)) return normFile(f);34 }35 return null;36 }37}3839const num = (s) => Math.round(parseFloat(s) * 100) / 100;4041export function parseLog(text) {42 const lines = String(text).split(/\r?\n/);43 const stack = new FileStack();44 const out = {45 errors: [], warnings: [], overfull: [], underfull: [], undefined_references: [], undefined_citations: [], duplicate_labels: [],46 missing_files: [], missing_fonts: [], missing_characters: [], shell_commands_blocked: [], shell_commands_executed: [], rerun_needed: false, rerun_reasons: [], pages: null, output_file: null, engine: null,47 };48 const m0 = /^This is (pdfTeX|XeTeX|LuaTeX|LuaHBTeX|e-pTeX|pTeX)/.exec(lines[0] ?? "");49 if (m0) out.engine = m0[1];50 let page = 0, printed = 0, inDump = false; // page: pages shipped so far (position in the PDF); printed: the number TeX printed51 const seenDup = new Set(), seenFont = new Set(), seenFile = new Set(), seenChar = new Set();5253 for (let i = 0; i < lines.length; i++) {54 const line = lines[i];55 let m;5657 // Shipped pages look like "[3" or "[3]" possibly followed by file names in braces.58 if (inDump) { if (line === "") inDump = false; continue; }59 if (/^\(\S+\)\s{2,}/.test(line)) continue; // continuation of a package message60 for (const pm of line.matchAll(/\[(\d+)(?=[\]\s{<]|$)/g)) { const p = Number(pm[1]); if (p === printed + 1 || (p === 1 && printed > 1)) { printed = p; page++; } }6162 if ((m = /^(.+?):(\d+): (.*)$/.exec(line)) && !/^(Overfull|Underfull|LaTeX|Package)/.test(line)) {63 // -file-line-error form. Gather the context line TeX prints ("l.12 \foo").64 let context = null;65 for (let j = i + 1; j < Math.min(i + 12, lines.length); j++) { const c = /^l\.(\d+) (.*)$/.exec(lines[j]); if (c) { context = c[2].trim().slice(0, 200); break; } }66 const message = m[3].replace(/^LaTeX Error: /, "").replace(/^Package (\S+) Error: /, "$1: ").trim();67 if (/^==> Fatal error occurred/.test(message)) continue; // consequence of the error above68 out.errors.push({ file: normFile(m[1]), line: Number(m[2]), message, context });69 const mf = /File `([^']+)' not found/.exec(m[3]);70 if (mf && !seenFile.has(mf[1])) { seenFile.add(mf[1]); out.missing_files.push({ name: mf[1], file: normFile(m[1]), line: Number(m[2]) }); }71 continue;72 }73 if ((m = /^! (.*)$/.exec(line))) {74 let ln = null, context = null;75 for (let j = i + 1; j < Math.min(i + 15, lines.length); j++) { const c = /^l\.(\d+) (.*)$/.exec(lines[j]); if (c) { ln = Number(c[1]); context = c[2].trim().slice(0, 200); break; } }76 const message = m[1].replace(/^LaTeX Error: /, "").replace(/^Package (\S+) Error: /, "$1: ").trim();77 if (message !== "Emergency stop." || out.errors.length === 0) out.errors.push({ file: stack.current(), line: ln, message, context });78 const mf = /File `([^']+)' not found/.exec(m[1]);79 if (mf && !seenFile.has(mf[1])) { seenFile.add(mf[1]); out.missing_files.push({ name: mf[1], file: stack.current(), line: ln }); }80 const ft = /Font \\?\S*=?([^ ]+).* not loadable|The font "([^"]+)" cannot be found/.exec(m[1]);81 if (ft) { const f = ft[1] ?? ft[2]; if (!seenFont.has(f)) { seenFont.add(f); out.missing_fonts.push({ font: f }); } }82 continue;83 }84 if ((m = /^runsystem\((.*)\)\.\.\.(disabled|executed|not allowed)/.exec(line))) { if (m[2] === "executed") out.shell_commands_executed.push(m[1].slice(0, 200)); else out.shell_commands_blocked.push(m[1].slice(0, 200)); continue; }85 if ((m = /^(Overfull|Underfull) \\([hv])box \((?:([\d.]+)pt too (?:wide|high)|badness (\d+))\) (.*)$/.exec(line))) {86 const where = m[5];87 const para = /at lines (\d+)--(\d+)/.exec(where), det = /detected at line (\d+)/.exec(where);88 const item = {89 box: m[2] === "h" ? "hbox" : "vbox", file: stack.current(),90 line_start: para ? Number(para[1]) : det ? Number(det[1]) : null, line_end: para ? Number(para[2]) : det ? Number(det[1]) : null,91 page: page + 1, while_output: /while \\output is active/.test(where),92 };93 if (m[1] === "Overfull") out.overfull.push({ ...item, amount_pt: num(m[3]) });94 else out.underfull.push({ ...item, badness: Number(m[4]) });95 if (/^(\[\]|\\|\s)/.test(lines[i + 1] ?? "")) inDump = true; // TeX prints the offending box, then a blank line96 continue;97 }98 if ((m = /^LaTeX Warning: Reference [`']([^']+)' on page (\d+) undefined on input line (\d+)/.exec(line))) { out.undefined_references.push({ label: m[1], page: Number(m[2]), file: stack.current(), line: Number(m[3]) }); continue; }99 if ((m = /^LaTeX Warning: Citation [`']([^']+)' on page (\d+) undefined on input line (\d+)/.exec(line))) { out.undefined_citations.push({ key: m[1], page: Number(m[2]), file: stack.current(), line: Number(m[3]) }); continue; }100 if ((m = /^Package natbib Warning: Citation [`']([^']+)' on page (\d+) undefined on input line (\d+)/.exec(line))) { out.undefined_citations.push({ key: m[1], page: Number(m[2]), file: stack.current(), line: Number(m[3]) }); continue; }101 if ((m = /^LaTeX Warning: Label [`']([^']+)' multiply defined/.exec(line))) { if (!seenDup.has(m[1])) { seenDup.add(m[1]); out.duplicate_labels.push({ label: m[1] }); } continue; }102 if ((m = /^LaTeX Warning: File `([^']+)' not found on input line (\d+)/.exec(line))) { if (!seenFile.has(m[1])) { seenFile.add(m[1]); out.missing_files.push({ name: m[1], file: stack.current(), line: Number(m[2]) }); } continue; }103 if ((m = /^No file (\S+)\.$/.exec(line))) { if (!/\.(aux|toc|lof|lot|nav|out|snm|bbl|ind|gls)$/.test(m[1]) && !seenFile.has(m[1])) { seenFile.add(m[1]); out.missing_files.push({ name: m[1], file: stack.current(), line: null }); } continue; }104 if ((m = /^LaTeX Font Warning: Font shape `([^']+)' undefined/.exec(line))) { if (!seenFont.has(m[1])) { seenFont.add(m[1]); out.missing_fonts.push({ font: m[1], substituted: true }); } continue; }105 if ((m = /^Missing character: There is no (.+?) (?:\(U\+[0-9A-F]+\) )?in font (.+?)!?$/.exec(line))) { const k = `${m[1]}|${m[2]}`; if (!seenChar.has(k)) { seenChar.add(k); out.missing_characters.push({ character: m[1], font: m[2].slice(0, 80) }); } continue; }106 if (/^LaTeX Warning: (Label\(s\) may have changed|There were undefined references)|Rerun to get|\(re\)run (Biber|BibTeX)|Please rerun LaTeX|Rerun LaTeX/i.test(line)) {107 if (/may have changed|Rerun to get|rerun LaTeX/i.test(line)) { out.rerun_needed = true; out.rerun_reasons.push(line.replace(/^(LaTeX|Package \S+) Warning: /, "").trim().slice(0, 160)); }108 if (/\(re\)run (Biber|BibTeX)/i.test(line)) { out.rerun_needed = true; out.rerun_reasons.push(line.replace(/^Package \S+ Warning: /, "").trim().slice(0, 160)); }109 continue;110 }111 if ((m = /^(LaTeX|Package|Class)(?: (\S+))? Warning: (.*)$/.exec(line))) {112 // Multi-line package warnings continue on lines starting with "(package)".113 let msg = m[3];114 for (let j = i + 1; j < lines.length && /^\(\S+\)\s+/.test(lines[j]); j++) msg += " " + lines[j].replace(/^\(\S+\)\s+/, "");115 const ln = /on input line (\d+)/.exec(msg);116 if (!/There were (undefined references|multiply-defined labels)/.test(msg)) out.warnings.push({ source: m[2] ?? m[1], message: msg.trim().slice(0, 400), file: stack.current(), line: ln ? Number(ln[1]) : null });117 continue;118 }119 if ((m = /^Output written on (.+?) \((\d+) pages?/.exec(line))) { out.output_file = m[1]; out.pages = Number(m[2]); continue; }120 if (!MESSAGE_LINE.test(line)) stack.feed(line);121 }122 out.summary = {123 errors: out.errors.length, warnings: out.warnings.length, overfull: out.overfull.length, underfull: out.underfull.length,124 undefined_references: out.undefined_references.length, undefined_citations: out.undefined_citations.length, duplicate_labels: out.duplicate_labels.length,125 missing_files: out.missing_files.length, missing_fonts: out.missing_fonts.length, missing_characters: out.missing_characters.length, rerun_needed: out.rerun_needed,126 };127 return out;128}129130/** BibTeX (.blg) and Biber (.blg) logs. */131export function parseBibLog(text) {132 const lines = String(text).split(/\r?\n/);133 const backend = /^\[\d+\] .*This is Biber|INFO - This is Biber/m.test(text) ? "biber" : /This is BibTeX/.test(text) ? "bibtex" : "unknown";134 const out = { backend, missing_entries: [], warnings: [], errors: [], missing_databases: [] };135 for (let i = 0; i < lines.length; i++) {136 const line = lines[i];137 let m;138 if (backend === "biber" || backend === "unknown") {139 if ((m = /(WARN|ERROR) - (.*)$/.exec(line))) {140 const msg = m[2].trim();141 const miss = /didn't find a database entry for '([^']+)'/.exec(msg);142 const db = /Cannot find '([^']+)'|Data source '([^']+)' not found/i.exec(msg);143 if (miss) out.missing_entries.push(miss[1]);144 else if (db) out.missing_databases.push(db[1] ?? db[2]);145 else (m[1] === "ERROR" ? out.errors : out.warnings).push({ message: msg.slice(0, 300) });146 continue;147 }148 }149 if ((m = /^Warning--I didn't find a database entry for "([^"]+)"/.exec(line))) { out.missing_entries.push(m[1]); continue; }150 if ((m = /^Warning--(.*)$/.exec(line))) { const k = /\bin ([^\s]+)$/.exec(m[1]); out.warnings.push({ message: m[1].trim().slice(0, 300), key: k ? k[1] : null }); continue; }151 if ((m = /^I couldn't open (?:database|style) file (\S+)/.exec(line))) { out.missing_databases.push(m[1]); out.errors.push({ message: line.trim() }); continue; }152 if ((m = /^(.*)---line (\d+) of file (\S+)/.exec(line))) { out.errors.push({ message: (m[1] || lines[i - 1] || "").trim().slice(0, 300), line: Number(m[2]), file: m[3] }); continue; }153 if ((m = /^I found no (\\\w+) command/.exec(line))) { out.errors.push({ message: line.trim() }); continue; }154 }155 out.missing_entries = [...new Set(out.missing_entries)];156 out.summary = { backend, missing_entries: out.missing_entries.length, warnings: out.warnings.length, errors: out.errors.length };157 return out;158}159160/** Common LaTeX errors: cause and fix. Matched in order; the first hit wins. */161const EXPLANATIONS = [162 [/File `([^']+)' not found|File ended while scanning|No file .*\.tex/i, "A file named in the source cannot be found.", "Check the path and spelling relative to the main file; add the missing package with tlmgr if it is a .sty; for images give the extension or a \\graphicspath."],163 [/Undefined control sequence/i, "A command is used that no loaded package defines.", "Check the spelling, or load the package that defines it. The context line shows where TeX stopped reading."],164 [/Missing \$ inserted/i, "Math-only material (^, _, \\alpha …) appears outside math mode, or a $ is unbalanced.", "Wrap the expression in $…$, or escape the character (\\_ for an underscore in text)."],165 [/Missing \\begin\{document\}/i, "Text or a stray character occurs in the preamble.", "Look for characters before \\begin{document}, or a file saved with a byte-order mark."],166 [/\\begin\{([^}]+)\} on input line (\d+) ended by \\end\{([^}]+)\}/i, "Environments are closed in the wrong order.", "Close the inner environment first; check the line given for the unmatched \\begin."],167 [/Extra alignment tab has been changed to \\cr|Misplaced alignment tab character &/i, "A table row has more & separators than the column specification allows, or an & is used in text.", "Count the columns in the tabular preamble; write \\& for a literal ampersand."],168 [/Environment (\S+) undefined/i, "The environment is not defined by any loaded package.", "Load the package (amsmath for align, booktabs is commands only, etc.) or fix the name."],169 [/Too many \}'s|Extra \}, or forgotten \$|Missing \} inserted|Missing \{ inserted|Runaway argument|Paragraph ended before/i, "Braces are unbalanced, or a blank line sits inside a command argument.", "Find the unmatched brace near the reported line; remove blank lines inside arguments such as \\caption{…}."],170 [/Option clash for package (\S+)/i, "A package is loaded twice with different options (often by a class and by you).", "Pass the options before the class loads it: \\PassOptionsToPackage{opts}{pkg} before \\documentclass."],171 [/Command (\\\S+) already defined/i, "Two packages (or you) define the same command.", "Use \\renewcommand, load only one of the packages, or check the load order."],172 [/Unicode character|Invalid UTF-8|inputenc Error|not set up for use with LaTeX/i, "The source contains a character the engine or font encoding cannot handle.", "With pdfLaTeX declare \\usepackage[utf8]{inputenc} and \\usepackage[T1]{fontenc}, or define the character; or compile with XeLaTeX / LuaLaTeX."],173 [/font .* cannot be found|Font .* not loadable|fontspec error/i, "A font named with fontspec is not installed (or the engine is pdfLaTeX).", "Install the font or pick an installed one (fc-list lists them); fontspec needs XeLaTeX or LuaLaTeX."],174 [/Please \(re\)run Biber|backend=biber|\.bcf/i, "The document uses biblatex with the Biber backend but the bibliography was processed by BibTeX (or not at all).", "Run biber, not bibtex — or load biblatex with backend=bibtex. Delete the stale .bbl after switching."],175 [/I found no \\(citation|bibdata|bibstyle) command/i, "BibTeX was run on a document that has no \\cite, no \\bibliography or no \\bibliographystyle — or the document uses biblatex.", "For biblatex run biber; otherwise add the missing command."],176 [/Missing \\endcsname inserted/i, "A character that is not allowed in a name reached a label or a counter name. The usual case: babel-french under pdfLaTeX makes : ; ! ? active, and \\cref{fig:a} then fails although \\ref{fig:a} works.", "Use label names without a colon (fig-a), or compile with LuaLaTeX, or put \\shorthandoff{:;!?} after \\begin{document} (this also turns off the automatic French spacing before those marks)."],177 [/cleveref.*(must be loaded after|before)|\\cref.*undefined/i, "cleveref was loaded before hyperref or before babel finished its language setup.", "Load cleveref last: babel, then hyperref, then cleveref."],178 [/babel.*Unknown option|You haven't (defined|specified) the language|No hyphenation patterns/i, "The babel language is misspelled, or its hyphenation patterns are not installed.", "Use the exact babel name (french, british, ngerman); install the language collection with tlmgr."],179 [/pgfplots.*compat|running in backwards compatibility mode/i, "pgfplots runs in a compatibility mode because no version is set.", "Add \\pgfplotsset{compat=1.18} (or your installed version) to the preamble."],180 [/Dimension too large/i, "A computed length exceeds TeX's maximum (often a pgfplots axis with huge values).", "Rescale the data, or let pgfplots handle it with a logarithmic or scaled axis."],181 [/TeX capacity exceeded/i, "TeX ran out of memory, usually through infinite recursion in a macro or a runaway argument.", "Look for a macro that calls itself, or a missing closing brace before the reported line."],182 [/Float\(s\) lost|Too many unprocessed floats/i, "Floats accumulated faster than LaTeX could place them.", "Add \\clearpage at intervals, loosen placement ([htbp]), or use the placeins package."],183 [/There's no line here to end/i, "\\\\ is used where there is no line to break (after a blank line or at the start of a paragraph).", "Remove the \\\\, or use \\vspace for vertical space."],184 [/Not in outer par mode/i, "A float or \\marginpar is used inside a box, a minipage or another float.", "Move the float out of the box, or use the [H] placement from the float package."],185 [/Something's wrong--perhaps a missing \\item/i, "A list environment has text before its first \\item, or a thebibliography is empty.", "Start the list with \\item; for an empty bibliography, run the bibliography tool again."],186 [/Emergency stop|Fatal error occurred/i, "The engine stopped after an earlier error.", "Fix the first error in the log; later ones are usually consequences."],187 [/shell escape|write18/i, "The document asks to run an external program, which is disabled here.", "This package always compiles with -no-shell-escape. Pre-generate the external output (minted cache, gnuplot tables, externalised TikZ) outside it."],188];189190export function explainError(message) {191 const msg = String(message ?? "");192 for (const [re, cause, fix] of EXPLANATIONS) if (re.test(msg)) return { matched: true, cause, fix };193 return { matched: false, cause: null, fix: "No known pattern matched. Read the first error in the log and the two lines after it: the line starting with l.<number> shows where TeX stopped." };194}195