Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/latexmk/pdf.ts
3587 views
1
/*
2
* pdf.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { dirname, join } from "../../../deno_ral/path.ts";
8
import { existsSync, safeRemoveSync } from "../../../deno_ral/fs.ts";
9
10
import { PdfEngine } from "../../../config/types.ts";
11
import { LatexmkOptions } from "./types.ts";
12
13
import { dirAndStem } from "../../../core/path.ts";
14
import { ProcessResult } from "../../../core/process-types.ts";
15
16
import { hasTexLive, TexLiveContext, texLiveContext } from "./texlive.ts";
17
import { runBibEngine, runIndexEngine, runPdfEngine } from "./latex.ts";
18
import { PackageManager, packageManager } from "./pkgmgr.ts";
19
import {
20
findIndexError,
21
findLatexError,
22
findMissingFontsAndPackages,
23
findMissingHyphenationFiles,
24
kMissingFontLog,
25
needsRecompilation,
26
} from "./parse-error.ts";
27
import { error, info, warning } from "../../../deno_ral/log.ts";
28
import { logProgress } from "../../../core/log.ts";
29
import { isWindows } from "../../../deno_ral/platform.ts";
30
31
export async function generatePdf(mkOptions: LatexmkOptions): Promise<string> {
32
if (!mkOptions.quiet) {
33
logProgress("\nRendering PDF");
34
logProgress(
35
`running ${mkOptions.engine.pdfEngine} - 1`,
36
);
37
}
38
39
// Get the working directory and file name stem
40
const [cwd, stem] = dirAndStem(mkOptions.input);
41
const workingDir = mkOptions.outputDir ? join(cwd, mkOptions.outputDir) : cwd;
42
43
// Ensure that working directory exists
44
if (!existsSync(workingDir)) {
45
Deno.mkdirSync(workingDir);
46
} else {
47
// Clean the working directory of any leftover artifacts
48
cleanup(workingDir, stem);
49
}
50
51
// Determine whether we support automatic updating (TexLive is available)
52
const allowUpdate = await hasTexLive();
53
mkOptions.autoInstall = mkOptions.autoInstall && allowUpdate;
54
55
// Create the TexLive context for this compilation
56
const texLive = await texLiveContext(mkOptions.tinyTex !== false);
57
58
// The package manager used to find and install packages
59
const pkgMgr = packageManager(mkOptions, texLive);
60
61
// Render the PDF, detecting whether any packages need to be installed
62
const response = await initialCompileLatex(
63
mkOptions.input,
64
mkOptions.engine,
65
pkgMgr,
66
texLive,
67
mkOptions.outputDir,
68
mkOptions.texInputDirs,
69
mkOptions.quiet,
70
);
71
const initialCompileNeedsRerun = needsRecompilation(response.log);
72
73
const indexIntermediateFile = indexIntermediate(workingDir, stem);
74
let indexCreated = false;
75
if (indexIntermediateFile) {
76
// When building large and complex indexes, it
77
// may be required to run the PDF engine again prior to building
78
// the index (or page numbers may be incorrect).
79
// See: https://github.com/rstudio/bookdown/issues/1274
80
info(" Re-compiling document for index");
81
await runPdfEngine(
82
mkOptions.input,
83
mkOptions.engine,
84
texLive,
85
mkOptions.outputDir,
86
mkOptions.texInputDirs,
87
pkgMgr,
88
mkOptions.quiet,
89
);
90
91
// Generate the index information, if needed
92
indexCreated = await makeIndexIntermediates(
93
indexIntermediateFile,
94
pkgMgr,
95
texLive,
96
mkOptions.engine.indexEngine,
97
mkOptions.engine.indexEngineOpts,
98
mkOptions.quiet,
99
);
100
}
101
102
// Generate the bibliography intermediaries
103
const bibliographyCreated = await makeBibliographyIntermediates(
104
mkOptions.input,
105
mkOptions.engine.bibEngine || "citeproc",
106
pkgMgr,
107
texLive,
108
mkOptions.outputDir,
109
mkOptions.texInputDirs,
110
mkOptions.quiet,
111
);
112
113
// Recompile the Latex if required
114
// we have already run the engine one time (hence subtracting one run from min and max)
115
const minRuns = (mkOptions.minRuns || 1) - 1;
116
const maxRuns = (mkOptions.maxRuns || 10) - 1;
117
if (
118
(indexCreated || bibliographyCreated || minRuns ||
119
initialCompileNeedsRerun) && maxRuns > 0
120
) {
121
await recompileLatexUntilComplete(
122
mkOptions.input,
123
mkOptions.engine,
124
pkgMgr,
125
mkOptions.minRuns || 1,
126
maxRuns,
127
texLive,
128
mkOptions.outputDir,
129
mkOptions.texInputDirs,
130
mkOptions.quiet,
131
);
132
}
133
134
// cleanup if requested
135
if (mkOptions.clean) {
136
cleanup(workingDir, stem);
137
}
138
139
if (!mkOptions.quiet) {
140
info("");
141
}
142
143
return mkOptions.outputDir
144
? join(mkOptions.outputDir, stem + ".pdf")
145
: join(cwd, stem + ".pdf");
146
}
147
148
// The first pass compilation of the latex with the ability to discover
149
// missing packages (and subsequently retrying the compilation)
150
async function initialCompileLatex(
151
input: string,
152
engine: PdfEngine,
153
pkgMgr: PackageManager,
154
texLive: TexLiveContext,
155
outputDir?: string,
156
texInputDirs?: string[],
157
quiet?: boolean,
158
) {
159
let packagesUpdated = false;
160
while (true) {
161
// Run the pdf engine
162
const response = await runPdfEngine(
163
input,
164
engine,
165
texLive,
166
outputDir,
167
texInputDirs,
168
pkgMgr,
169
quiet,
170
);
171
172
// Check whether it suceeded. We'll consider it a failure if there is an error status or output is missing despite a success status
173
// (PNAS Template may eat errors when missing packages exists)
174
// See: https://github.com/rstudio/tinytex/blob/6c0078f2c3c1319a48b71b61753f09c3ec079c0a/R/latex.R#L216
175
const success = response.result.code === 0 &&
176
(!response.output || existsSync(response.output));
177
178
if (success) {
179
// See whether there are warnings about hyphenation
180
// See (https://github.com/rstudio/tinytex/commit/0f2007426f730a6ed9d45369233c1349a69ddd29)
181
const logText = Deno.readTextFileSync(response.log);
182
const missingHyphenationFile = findMissingHyphenationFiles(logText);
183
if (missingHyphenationFile) {
184
// try to install it, unless auto install is opted out
185
if (pkgMgr.autoInstall) {
186
logProgress("Installing missing hyphenation file...");
187
if (await pkgMgr.installPackages([missingHyphenationFile])) {
188
// We installed hyphenation files, retry
189
continue;
190
} else {
191
logProgress("Installing missing hyphenation file failed.");
192
}
193
}
194
// Let's just through a warning, but it may not be fatal for the compilation
195
// and we can end normally
196
warning(
197
`Possibly missing hyphenation file: '${missingHyphenationFile}'. See more in logfile (by setting 'latex-clean: false').\n`,
198
);
199
}
200
} else if (pkgMgr.autoInstall) {
201
// try autoinstalling
202
// First be sure all packages are up to date
203
if (!packagesUpdated) {
204
if (!quiet) {
205
logProgress("updating tlmgr");
206
}
207
await pkgMgr.updatePackages(false, true);
208
info("");
209
210
if (!quiet) {
211
logProgress("updating existing packages");
212
}
213
await pkgMgr.updatePackages(true, false);
214
packagesUpdated = true;
215
}
216
217
// Try to find and install packages
218
const packagesInstalled = await findAndInstallPackages(
219
pkgMgr,
220
response.log,
221
response.result.stderr,
222
quiet,
223
);
224
225
if (packagesInstalled) {
226
// try the intial compile again
227
continue;
228
} else {
229
// We failed to install packages (but there are missing packages), give up
230
displayError(
231
"missing packages (automatic installation failed)",
232
response.log,
233
response.result,
234
);
235
return Promise.reject();
236
}
237
} else {
238
// Failed, but no auto-installation, just display the error
239
displayError(
240
"missing packages (automatic installed disabled)",
241
response.log,
242
response.result,
243
);
244
return Promise.reject();
245
}
246
247
// If we get here, we aren't installing packages (or we've already installed them)
248
return Promise.resolve(response);
249
}
250
}
251
252
function displayError(title: string, log: string, result: ProcessResult) {
253
if (existsSync(log)) {
254
// There is a log file, so read that and try to find the error
255
const logText = Deno.readTextFileSync(log);
256
writeError(
257
title,
258
findLatexError(logText, result.stderr),
259
log,
260
);
261
} else {
262
// There is no log file, just display an unknown error
263
writeError(title);
264
}
265
}
266
267
function indexIntermediate(dir: string, stem: string) {
268
const indexFile = join(dir, `${stem}.idx`);
269
if (existsSync(indexFile)) {
270
return indexFile;
271
} else {
272
return undefined;
273
}
274
}
275
276
async function makeIndexIntermediates(
277
indexFile: string,
278
pkgMgr: PackageManager,
279
texLive: TexLiveContext,
280
engine?: string,
281
args?: string[],
282
quiet?: boolean,
283
) {
284
// If there is an idx file, we need to run makeindex to create the index data
285
if (indexFile) {
286
if (!quiet) {
287
logProgress("making index");
288
}
289
290
// Make the index
291
try {
292
const response = await runIndexEngine(
293
indexFile,
294
texLive,
295
engine,
296
args,
297
pkgMgr,
298
quiet,
299
);
300
301
// Indexing Failed
302
const indexLogExists = existsSync(response.log);
303
if (response.result.code !== 0) {
304
writeError(
305
`result code ${response.result.code}`,
306
"",
307
response.log,
308
);
309
return Promise.reject();
310
} else if (indexLogExists) {
311
// The command succeeded, but there is an indexing error in the lgo
312
const logText = Deno.readTextFileSync(response.log);
313
const error = findIndexError(logText);
314
if (error) {
315
writeError(
316
`error generating index`,
317
error,
318
response.log,
319
);
320
return Promise.reject();
321
}
322
}
323
return true;
324
} catch {
325
writeError(
326
`error generating index`,
327
);
328
return Promise.reject();
329
}
330
} else {
331
return false;
332
}
333
}
334
335
async function makeBibliographyIntermediates(
336
input: string,
337
engine: string,
338
pkgMgr: PackageManager,
339
texLive: TexLiveContext,
340
outputDir?: string,
341
texInputDirs?: string[],
342
quiet?: boolean,
343
) {
344
// Generate bibliography (including potentially installing missing packages)
345
// By default, we'll use citeproc which requires no additional processing,
346
// but if the user would like to use natbib or biblatex, we do need additional
347
// processing (including explicitly calling the processing tool)
348
const bibCommand = engine === "natbib" ? "bibtex" : "biber";
349
350
const [cwd, stem] = dirAndStem(input);
351
352
while (true) {
353
// If biber, look for a bcf file, otherwise look for aux file
354
const auxBibFile = bibCommand === "biber" ? `${stem}.bcf` : `${stem}.aux`;
355
const auxBibPath = outputDir ? join(outputDir, auxBibFile) : auxBibFile;
356
const auxBibFullPath = join(cwd, auxBibPath);
357
358
if (existsSync(auxBibFullPath)) {
359
const auxFileData = Deno.readTextFileSync(auxBibFullPath);
360
361
const requiresProcessing = bibCommand === "biber"
362
? true
363
: containsBiblioData(auxFileData);
364
365
if (requiresProcessing) {
366
if (!quiet) {
367
logProgress("generating bibliography");
368
}
369
370
// If we're on windows and auto-install isn't enabled,
371
// fix up the aux file
372
//
373
if (isWindows) {
374
if (bibCommand !== "biber" && !hasTexLive()) {
375
// See https://github.com/rstudio/tinytex/blob/b2d1bae772f3f979e77fca9fb5efda05855b39d2/R/latex.R#L284
376
// Strips the '.bib' from any match and returns the string without the bib extension
377
// Replace any '.bib' in bibdata in windows auxData
378
const fixedAuxFileData = auxFileData.replaceAll(
379
/(^\\bibdata{.+)\.bib(.*})$/gm,
380
(
381
_substr: string,
382
prefix: string,
383
postfix: string,
384
) => {
385
return prefix + postfix;
386
},
387
);
388
389
// Rewrite the corrected file
390
Deno.writeTextFileSync(auxBibFullPath, fixedAuxFileData);
391
}
392
}
393
394
// If natbib, only use bibtex, otherwise, could use biber or bibtex
395
const response = await runBibEngine(
396
bibCommand,
397
auxBibPath,
398
cwd,
399
texLive,
400
pkgMgr,
401
texInputDirs,
402
quiet,
403
);
404
405
if (response.result.code !== 0 && pkgMgr.autoInstall) {
406
// Biblio generation failed, see whether we should install anything to try to resolve
407
// Find the missing packages
408
const log = join(dirname(auxBibFullPath), `${stem}.blg`);
409
410
if (existsSync(log)) {
411
const logOutput = Deno.readTextFileSync(log);
412
const match = logOutput.match(/.* open style file ([^ ]+).*/);
413
414
if (match) {
415
const file = match[1];
416
if (
417
await findAndInstallPackages(
418
pkgMgr,
419
file,
420
response.result.stderr,
421
quiet,
422
)
423
) {
424
continue;
425
} else {
426
// TODO: read error out of blg file
427
// TODO: writeError that doesn't require logText?
428
writeError(`error generating bibliography`, "", log);
429
return Promise.reject();
430
}
431
}
432
}
433
}
434
return true;
435
}
436
}
437
438
return false;
439
}
440
}
441
442
async function findAndInstallPackages(
443
pkgMgr: PackageManager,
444
logFile: string,
445
stderr?: string,
446
_quiet?: boolean,
447
) {
448
if (existsSync(logFile)) {
449
// Read the log file itself
450
const logText = Deno.readTextFileSync(logFile);
451
452
const searchTerms = findMissingFontsAndPackages(logText, dirname(logFile));
453
if (searchTerms.length > 0) {
454
const packages = await pkgMgr.searchPackages(searchTerms);
455
if (packages.length > 0) {
456
const packagesInstalled = await pkgMgr.installPackages(
457
packages,
458
);
459
if (packagesInstalled) {
460
// Try again
461
return true;
462
} else {
463
writeError(
464
"package installation error",
465
findLatexError(logText, stderr),
466
logFile,
467
);
468
return Promise.reject();
469
}
470
} else {
471
writeError(
472
"no matching packages",
473
findLatexError(logText, stderr),
474
logFile,
475
);
476
return Promise.reject();
477
}
478
} else {
479
writeError("error", findLatexError(logText, stderr), logFile);
480
return Promise.reject();
481
}
482
}
483
return false;
484
}
485
486
function writeError(primary: string, secondary?: string, logFile?: string) {
487
error(
488
`\ncompilation failed- ${primary}`,
489
);
490
491
if (secondary) {
492
info(secondary);
493
}
494
495
if (logFile) {
496
info(`see ${logFile} for more information.`);
497
}
498
}
499
500
async function recompileLatexUntilComplete(
501
input: string,
502
engine: PdfEngine,
503
pkgMgr: PackageManager,
504
minRuns: number,
505
maxRuns: number,
506
texLive: TexLiveContext,
507
outputDir?: string,
508
texInputDirs?: string[],
509
quiet?: boolean,
510
) {
511
// Run the engine until the bibliography is fully resolved
512
let runCount = 0;
513
514
// convert to zero based minimum
515
minRuns = minRuns - 1;
516
while (true) {
517
// If we've exceeded maximum runs break
518
if (runCount >= maxRuns) {
519
if (!quiet) {
520
warning(
521
`maximum number of runs (${maxRuns}) reached`,
522
);
523
}
524
break;
525
}
526
527
if (!quiet) {
528
logProgress(
529
`running ${engine.pdfEngine} - ${runCount + 2}`,
530
);
531
}
532
533
const result = await runPdfEngine(
534
input,
535
engine,
536
texLive,
537
outputDir,
538
texInputDirs,
539
pkgMgr,
540
quiet,
541
);
542
543
if (!result.result.success) {
544
// Failed
545
displayError("Error compiling latex", result.log, result.result);
546
return Promise.reject();
547
} else {
548
runCount = runCount + 1;
549
// If we haven't reached the minimum or the bibliography still needs to be rerun
550
// go again.
551
if (
552
existsSync(result.log) && needsRecompilation(result.log) ||
553
runCount < minRuns
554
) {
555
continue;
556
}
557
break;
558
}
559
}
560
}
561
562
function containsBiblioData(auxData: string) {
563
return auxData.match(/^\\(bibdata|citation|bibstyle)\{/m);
564
}
565
566
function auxFile(stem: string, ext: string) {
567
return `${stem}.${ext}`;
568
}
569
570
function cleanup(workingDir: string, stem: string) {
571
const auxFiles = [
572
"log",
573
"idx",
574
"aux",
575
"bcf",
576
"blg",
577
"bbl",
578
"fls",
579
"out",
580
"lof",
581
"lot",
582
"toc",
583
"nav",
584
"snm",
585
"vrb",
586
"ilg",
587
"ind",
588
"xwm",
589
"brf",
590
"run.xml",
591
].map((aux) => join(workingDir, auxFile(stem, aux)));
592
593
// Also cleanup any missfont.log file
594
auxFiles.push(join(workingDir, kMissingFontLog));
595
596
auxFiles.forEach((auxFile) => {
597
if (existsSync(auxFile)) {
598
safeRemoveSync(auxFile);
599
}
600
});
601
}
602
603