@@ -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 ( / \( ( [ ^ ( ) ] * \. t e x ) / 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+
2376interface 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
0 commit comments