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