Skip to content

Commit 9bb9dfc

Browse files
universeaclaude
andcommitted
fix(compile): parse LaTeX errors with file:line, fix bibtex pipeline
- Add parseLatexErrors() to extract structured errors (message, line, file) from LaTeX log instead of raw '!' lines - Fix bibtex not running: detect bibliography need via .aux \bibdata/\citation instead of hardcoded references.bib check - Fix bibtex exit code 2 (warnings) killing the entire recompile pipeline — now bibtex and pdflatex are caught independently - Full build: pdflatex → bibtex → pdflatex → pdflatex for proper references - Fix 'which' → 'where' on Windows for compiler detection - Frontend: show errors with file:line in dark code panel, three-state result (green success / amber warnings / red failure) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5fab1ba commit 9bb9dfc

2 files changed

Lines changed: 153 additions & 44 deletions

File tree

‎packages/app/src/routes/manuscript.ts‎

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,59 @@ function param(val: string | string[]): string {
2020
return Array.isArray(val) ? val[0] : val
2121
}
2222

23+
interface LatexError {
24+
message: string
25+
line: number | null
26+
file: string | null
27+
}
28+
29+
function parseLatexErrors(log: string): LatexError[] {
30+
const errors: LatexError[] = []
31+
const lines = log.split('\n')
32+
// Track which file TeX is currently processing via (file.tex patterns
33+
const fileStack: string[] = []
34+
35+
for (let i = 0; i < lines.length; i++) {
36+
// Track file context: TeX logs (path/file.tex when entering a file
37+
const opens = lines[i].match(/\(([^()]*\.tex)/g)
38+
if (opens) {
39+
for (const m of opens) fileStack.push(m.slice(1))
40+
}
41+
const closes = (lines[i].match(/\)/g) || []).length
42+
for (let c = 0; c < closes && fileStack.length > 0; c++) fileStack.pop()
43+
44+
if (!lines[i].startsWith('!')) continue
45+
46+
const msg = lines[i].slice(2).trim()
47+
let line: number | null = null
48+
let file: string | null = fileStack.length > 0 ? fileStack[fileStack.length - 1] : null
49+
50+
// Look ahead for `l.NNN` line indicator
51+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
52+
const lm = lines[j].match(/^l\.(\d+)\s/)
53+
if (lm) {
54+
line = parseInt(lm[1], 10)
55+
break
56+
}
57+
}
58+
59+
// Normalize file path to just the basename for display
60+
if (file) {
61+
const base = file.replace(/\\/g, '/').split('/').pop()
62+
if (base) file = base
63+
}
64+
65+
// Deduplicate consecutive identical messages
66+
const last = errors[errors.length - 1]
67+
if (last && last.message === msg && last.line === line && last.file === file) continue
68+
69+
errors.push({ message: msg, line, file })
70+
if (errors.length >= 20) break
71+
}
72+
73+
return errors
74+
}
75+
2376
interface FileEntry {
2477
name: string
2578
path: string
@@ -252,48 +305,65 @@ export function createManuscriptRoutes(workspaceDir?: string): Router {
252305
const texFile = path.join(dir, texPath)
253306
if (!fs.existsSync(texFile)) { res.status(404).json({ error: `${texPath} not found` }); return }
254307

255-
try {
256-
// Try pdflatex first, fall back to xelatex
257-
let compiler = 'pdflatex'
258-
try { await execFileAsync('which', ['pdflatex']) } catch {
259-
try { await execFileAsync('which', ['xelatex']); compiler = 'xelatex' } catch {
260-
res.json({ success: false, log: 'No LaTeX compiler found. Install TeX Live or BasicTeX.', errors: ['pdflatex/xelatex not found'], pdf_path: null })
261-
return
262-
}
308+
// Try pdflatex first, fall back to xelatex
309+
const whichCmd = process.platform === 'win32' ? 'where' : 'which'
310+
let compiler = 'pdflatex'
311+
try { await execFileAsync(whichCmd, ['pdflatex']) } catch {
312+
try { await execFileAsync(whichCmd, ['xelatex']); compiler = 'xelatex' } catch {
313+
res.json({ success: false, log: 'No LaTeX compiler found. Install TeX Live or BasicTeX.', errors: [{ message: 'pdflatex/xelatex not found in PATH', line: null, file: null }], pdf_path: null })
314+
return
263315
}
316+
}
264317

318+
// Run compiler — nonstopmode may exit non-zero but still produce output
319+
let log = ''
320+
try {
265321
const { stdout, stderr } = await execFileAsync(compiler, [
266322
'-interaction=nonstopmode',
267323
'-synctex=1',
268324
'-output-directory=' + dir,
269325
texFile,
270326
], { cwd: dir, timeout: 60000, maxBuffer: 5 * 1024 * 1024 })
327+
log = stdout + '\n' + stderr
328+
} catch (err) {
329+
const e = err as Error & { stdout?: string; stderr?: string }
330+
log = (e.stdout || '') + '\n' + (e.stderr || '')
331+
if (!log.trim()) log = e.message || 'Compilation failed'
332+
}
271333

272-
const log = stdout + '\n' + stderr
273-
const baseName = path.basename(texPath, '.tex')
274-
const pdfPath = `${baseName}.pdf`
275-
const pdfFull = path.join(dir, pdfPath)
276-
277-
if (fs.existsSync(pdfFull)) {
278-
// Run bibtex + second pass if references exist
279-
const bibFile = path.join(dir, 'references.bib')
280-
if (fs.existsSync(bibFile)) {
281-
try {
282-
await execFileAsync('bibtex', [path.join(dir, baseName)], { cwd: dir, timeout: 30000 })
283-
await execFileAsync(compiler, ['-interaction=nonstopmode', '-output-directory=' + dir, texFile], { cwd: dir, timeout: 60000, maxBuffer: 5 * 1024 * 1024 })
284-
} catch { /* bibtex errors are non-fatal */ }
285-
}
334+
const baseName = path.basename(texPath, '.tex')
335+
const pdfPath = `${baseName}.pdf`
336+
const pdfFull = path.join(dir, pdfPath)
337+
const errors = parseLatexErrors(log)
338+
339+
if (fs.existsSync(pdfFull)) {
340+
// Check if bibliography is needed by looking for \bibdata in .aux
341+
const auxFile = path.join(dir, `${baseName}.aux`)
342+
let needsBibtex = false
343+
if (fs.existsSync(auxFile)) {
344+
const auxContent = fs.readFileSync(auxFile, 'utf-8')
345+
needsBibtex = auxContent.includes('\\bibdata{') || auxContent.includes('\\citation{')
346+
}
286347

287-
res.json({ success: true, pdf_path: pdfPath, log, errors: [] })
288-
} else {
289-
// Extract errors from log
290-
const errors = log.split('\n').filter(l => l.startsWith('!')).slice(0, 10)
291-
res.json({ success: false, pdf_path: null, log, errors })
348+
if (needsBibtex) {
349+
// Full LaTeX build: pdflatex → bibtex → pdflatex → pdflatex
350+
// bibtex may exit non-zero for warnings (repeated entries etc.) but still produce valid .bbl
351+
try { await execFileAsync('bibtex', [path.join(dir, baseName)], { cwd: dir, timeout: 30000 }) } catch { /* non-fatal */ }
352+
try {
353+
await execFileAsync(compiler, ['-interaction=nonstopmode', '-output-directory=' + dir, texFile], { cwd: dir, timeout: 60000, maxBuffer: 5 * 1024 * 1024 })
354+
const final = await execFileAsync(compiler, ['-interaction=nonstopmode', '-output-directory=' + dir, texFile], { cwd: dir, timeout: 60000, maxBuffer: 5 * 1024 * 1024 })
355+
log = final.stdout + '\n' + final.stderr
356+
} catch (e) {
357+
const ex = e as Error & { stdout?: string; stderr?: string }
358+
if (ex.stdout) log = ex.stdout + '\n' + (ex.stderr || '')
359+
}
292360
}
293-
} catch (err) {
294-
const msg = err instanceof Error ? err.message : 'Compilation failed'
295-
const stderr = (err as NodeJS.ErrnoException & { stderr?: string }).stderr || ''
296-
res.json({ success: false, pdf_path: null, log: msg + '\n' + stderr, errors: [msg] })
361+
362+
const finalErrors = parseLatexErrors(log)
363+
const hasWarnings = finalErrors.length > 0
364+
res.json({ success: true, pdf_path: pdfPath, log, errors: hasWarnings ? finalErrors : [] })
365+
} else {
366+
res.json({ success: false, pdf_path: null, log, errors: errors.length > 0 ? errors : [{ message: 'Compilation failed — no PDF produced', line: null, file: null }] })
297367
}
298368
})
299369

‎packages/desktop/src/renderer/components/SubmitPanel.tsx‎

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,17 @@ interface FileEntry {
2828
children: FileEntry[]
2929
}
3030

31+
interface LatexError {
32+
message: string
33+
line: number | null
34+
file: string | null
35+
}
36+
3137
interface CompileResult {
3238
success: boolean
3339
pdf_path: string | null
3440
log: string
35-
errors: string[]
41+
errors: LatexError[]
3642
}
3743

3844
const MODULES: Array<{ key: ModuleKey; label: string; Icon: typeof FileText; color: string }> = [
@@ -100,15 +106,23 @@ export default function SubmitPanel({ projectId, projectName }: SubmitPanelProps
100106
setCompileResult(result)
101107
setShowLog(true)
102108
if (result.success) {
103-
message.success('Compiled to PDF')
109+
if (result.errors.length > 0) {
110+
message.warning(`Compiled with ${result.errors.length} warning(s)`)
111+
} else {
112+
message.success('Compiled to PDF')
113+
}
104114
} else {
105-
message.error(result.errors[0] || 'Compilation failed')
115+
const firstErr = result.errors[0]
116+
const errMsg = firstErr
117+
? `${firstErr.file || 'main.tex'}${firstErr.line ? `:${firstErr.line}` : ''} — ${firstErr.message}`
118+
: 'Compilation failed'
119+
message.error(errMsg)
106120
}
107121
await refreshTree()
108122
} catch (err) {
109123
const detail = err instanceof Error ? err.message : 'Unknown error'
110124
message.error(`Compile failed: ${detail}`)
111-
setCompileResult({ success: false, pdf_path: null, log: detail, errors: [detail] })
125+
setCompileResult({ success: false, pdf_path: null, log: detail, errors: [{ message: detail, line: null, file: null }] })
112126
setShowLog(true)
113127
} finally {
114128
setCompiling(false)
@@ -302,36 +316,61 @@ export default function SubmitPanel({ projectId, projectName }: SubmitPanelProps
302316
{/* Compile result */}
303317
{compileResult && (
304318
<div style={{
305-
border: `1px solid ${compileResult.success ? '#22c55e40' : '#ef444440'}`,
319+
border: `1px solid ${compileResult.success && compileResult.errors.length === 0 ? '#22c55e40' : compileResult.success ? '#f59e0b40' : '#ef444440'}`,
306320
borderRadius: 12,
307-
background: compileResult.success ? '#f0fdf4' : '#fef2f2',
321+
background: compileResult.success && compileResult.errors.length === 0 ? '#f0fdf4' : compileResult.success ? '#fffbeb' : '#fef2f2',
308322
padding: '12px 16px',
309323
marginBottom: 18,
310324
}}>
311325
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
312326
<span style={{
313327
fontSize: 13, fontWeight: 600,
314-
color: compileResult.success ? '#16a34a' : '#dc2626',
328+
color: compileResult.success && compileResult.errors.length === 0 ? '#16a34a' : compileResult.success ? '#d97706' : '#dc2626',
315329
}}>
316-
{compileResult.success ? '✓ Compilation succeeded' : '✗ Compilation failed'}
330+
{compileResult.success && compileResult.errors.length === 0
331+
? '✓ Compilation succeeded'
332+
: compileResult.success
333+
? `⚠ Compiled with ${compileResult.errors.length} error(s) — PDF may be incomplete`
334+
: `✗ Compilation failed — ${compileResult.errors.length} error(s)`}
317335
</span>
318336
<span style={{ flex: 1 }} />
319337
<a onClick={() => setShowLog((v) => !v)} style={{ fontSize: 12, cursor: 'pointer' }}>
320338
{showLog ? 'Hide log' : 'Show log'}
321339
</a>
322340
</div>
323341
{compileResult.errors.length > 0 && (
324-
<ul style={{ margin: '8px 0 0', paddingLeft: 20, fontSize: 12, color: '#dc2626' }}>
325-
{compileResult.errors.slice(0, 5).map((e, i) => (
326-
<li key={i} style={{ fontFamily: "'SF Mono', monospace" }}>{e}</li>
342+
<div style={{ marginTop: 10, borderRadius: 6, overflow: 'hidden', border: '1px solid var(--border)', background: '#1e293b' }}>
343+
<div style={{ padding: '6px 12px', fontSize: 11, fontWeight: 600, color: '#94a3b8', borderBottom: '1px solid #334155', background: '#0f172a' }}>
344+
{compileResult.errors.length} error{compileResult.errors.length > 1 ? 's' : ''} found
345+
</div>
346+
{compileResult.errors.slice(0, 10).map((e, i) => (
347+
<div key={i} style={{
348+
padding: '6px 12px', fontSize: 12,
349+
fontFamily: "'SF Mono', Consolas, monospace",
350+
borderBottom: i < compileResult.errors.length - 1 ? '1px solid #334155' : 'none',
351+
display: 'flex', gap: 8, alignItems: 'baseline',
352+
}}>
353+
<span style={{ color: '#ef4444', flexShrink: 0 }}>●</span>
354+
{(e.file || e.line) && (
355+
<span style={{ color: '#60a5fa', flexShrink: 0, whiteSpace: 'nowrap' }}>
356+
{e.file || 'main.tex'}{e.line ? `:${e.line}` : ''}
357+
</span>
358+
)}
359+
<span style={{ color: '#e2e8f0' }}>{e.message}</span>
360+
</div>
327361
))}
328-
</ul>
362+
{compileResult.errors.length > 10 && (
363+
<div style={{ padding: '4px 12px', fontSize: 11, color: '#64748b' }}>
364+
… and {compileResult.errors.length - 10} more
365+
</div>
366+
)}
367+
</div>
329368
)}
330369
{showLog && compileResult.log && (
331370
<pre style={{
332371
marginTop: 10, padding: 12, borderRadius: 6,
333372
background: '#1e293b', color: '#e2e8f0',
334-
fontSize: 11, fontFamily: "'SF Mono', monospace",
373+
fontSize: 11, fontFamily: "'SF Mono', Consolas, monospace",
335374
maxHeight: 320, overflow: 'auto', whiteSpace: 'pre-wrap',
336375
}}>
337376
{compileResult.log}

0 commit comments

Comments
 (0)