Path: blob/main/src/command/render/latexmk/pdf.ts
3587 views
/*1* pdf.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { dirname, join } from "../../../deno_ral/path.ts";7import { existsSync, safeRemoveSync } from "../../../deno_ral/fs.ts";89import { PdfEngine } from "../../../config/types.ts";10import { LatexmkOptions } from "./types.ts";1112import { dirAndStem } from "../../../core/path.ts";13import { ProcessResult } from "../../../core/process-types.ts";1415import { hasTexLive, TexLiveContext, texLiveContext } from "./texlive.ts";16import { runBibEngine, runIndexEngine, runPdfEngine } from "./latex.ts";17import { PackageManager, packageManager } from "./pkgmgr.ts";18import {19findIndexError,20findLatexError,21findMissingFontsAndPackages,22findMissingHyphenationFiles,23kMissingFontLog,24needsRecompilation,25} from "./parse-error.ts";26import { error, info, warning } from "../../../deno_ral/log.ts";27import { logProgress } from "../../../core/log.ts";28import { isWindows } from "../../../deno_ral/platform.ts";2930export async function generatePdf(mkOptions: LatexmkOptions): Promise<string> {31if (!mkOptions.quiet) {32logProgress("\nRendering PDF");33logProgress(34`running ${mkOptions.engine.pdfEngine} - 1`,35);36}3738// Get the working directory and file name stem39const [cwd, stem] = dirAndStem(mkOptions.input);40const workingDir = mkOptions.outputDir ? join(cwd, mkOptions.outputDir) : cwd;4142// Ensure that working directory exists43if (!existsSync(workingDir)) {44Deno.mkdirSync(workingDir);45} else {46// Clean the working directory of any leftover artifacts47cleanup(workingDir, stem);48}4950// Determine whether we support automatic updating (TexLive is available)51const allowUpdate = await hasTexLive();52mkOptions.autoInstall = mkOptions.autoInstall && allowUpdate;5354// Create the TexLive context for this compilation55const texLive = await texLiveContext(mkOptions.tinyTex !== false);5657// The package manager used to find and install packages58const pkgMgr = packageManager(mkOptions, texLive);5960// Render the PDF, detecting whether any packages need to be installed61const response = await initialCompileLatex(62mkOptions.input,63mkOptions.engine,64pkgMgr,65texLive,66mkOptions.outputDir,67mkOptions.texInputDirs,68mkOptions.quiet,69);70const initialCompileNeedsRerun = needsRecompilation(response.log);7172const indexIntermediateFile = indexIntermediate(workingDir, stem);73let indexCreated = false;74if (indexIntermediateFile) {75// When building large and complex indexes, it76// may be required to run the PDF engine again prior to building77// the index (or page numbers may be incorrect).78// See: https://github.com/rstudio/bookdown/issues/127479info(" Re-compiling document for index");80await runPdfEngine(81mkOptions.input,82mkOptions.engine,83texLive,84mkOptions.outputDir,85mkOptions.texInputDirs,86pkgMgr,87mkOptions.quiet,88);8990// Generate the index information, if needed91indexCreated = await makeIndexIntermediates(92indexIntermediateFile,93pkgMgr,94texLive,95mkOptions.engine.indexEngine,96mkOptions.engine.indexEngineOpts,97mkOptions.quiet,98);99}100101// Generate the bibliography intermediaries102const bibliographyCreated = await makeBibliographyIntermediates(103mkOptions.input,104mkOptions.engine.bibEngine || "citeproc",105pkgMgr,106texLive,107mkOptions.outputDir,108mkOptions.texInputDirs,109mkOptions.quiet,110);111112// Recompile the Latex if required113// we have already run the engine one time (hence subtracting one run from min and max)114const minRuns = (mkOptions.minRuns || 1) - 1;115const maxRuns = (mkOptions.maxRuns || 10) - 1;116if (117(indexCreated || bibliographyCreated || minRuns ||118initialCompileNeedsRerun) && maxRuns > 0119) {120await recompileLatexUntilComplete(121mkOptions.input,122mkOptions.engine,123pkgMgr,124mkOptions.minRuns || 1,125maxRuns,126texLive,127mkOptions.outputDir,128mkOptions.texInputDirs,129mkOptions.quiet,130);131}132133// cleanup if requested134if (mkOptions.clean) {135cleanup(workingDir, stem);136}137138if (!mkOptions.quiet) {139info("");140}141142return mkOptions.outputDir143? join(mkOptions.outputDir, stem + ".pdf")144: join(cwd, stem + ".pdf");145}146147// The first pass compilation of the latex with the ability to discover148// missing packages (and subsequently retrying the compilation)149async function initialCompileLatex(150input: string,151engine: PdfEngine,152pkgMgr: PackageManager,153texLive: TexLiveContext,154outputDir?: string,155texInputDirs?: string[],156quiet?: boolean,157) {158let packagesUpdated = false;159while (true) {160// Run the pdf engine161const response = await runPdfEngine(162input,163engine,164texLive,165outputDir,166texInputDirs,167pkgMgr,168quiet,169);170171// Check whether it suceeded. We'll consider it a failure if there is an error status or output is missing despite a success status172// (PNAS Template may eat errors when missing packages exists)173// See: https://github.com/rstudio/tinytex/blob/6c0078f2c3c1319a48b71b61753f09c3ec079c0a/R/latex.R#L216174const success = response.result.code === 0 &&175(!response.output || existsSync(response.output));176177if (success) {178// See whether there are warnings about hyphenation179// See (https://github.com/rstudio/tinytex/commit/0f2007426f730a6ed9d45369233c1349a69ddd29)180const logText = Deno.readTextFileSync(response.log);181const missingHyphenationFile = findMissingHyphenationFiles(logText);182if (missingHyphenationFile) {183// try to install it, unless auto install is opted out184if (pkgMgr.autoInstall) {185logProgress("Installing missing hyphenation file...");186if (await pkgMgr.installPackages([missingHyphenationFile])) {187// We installed hyphenation files, retry188continue;189} else {190logProgress("Installing missing hyphenation file failed.");191}192}193// Let's just through a warning, but it may not be fatal for the compilation194// and we can end normally195warning(196`Possibly missing hyphenation file: '${missingHyphenationFile}'. See more in logfile (by setting 'latex-clean: false').\n`,197);198}199} else if (pkgMgr.autoInstall) {200// try autoinstalling201// First be sure all packages are up to date202if (!packagesUpdated) {203if (!quiet) {204logProgress("updating tlmgr");205}206await pkgMgr.updatePackages(false, true);207info("");208209if (!quiet) {210logProgress("updating existing packages");211}212await pkgMgr.updatePackages(true, false);213packagesUpdated = true;214}215216// Try to find and install packages217const packagesInstalled = await findAndInstallPackages(218pkgMgr,219response.log,220response.result.stderr,221quiet,222);223224if (packagesInstalled) {225// try the intial compile again226continue;227} else {228// We failed to install packages (but there are missing packages), give up229displayError(230"missing packages (automatic installation failed)",231response.log,232response.result,233);234return Promise.reject();235}236} else {237// Failed, but no auto-installation, just display the error238displayError(239"missing packages (automatic installed disabled)",240response.log,241response.result,242);243return Promise.reject();244}245246// If we get here, we aren't installing packages (or we've already installed them)247return Promise.resolve(response);248}249}250251function displayError(title: string, log: string, result: ProcessResult) {252if (existsSync(log)) {253// There is a log file, so read that and try to find the error254const logText = Deno.readTextFileSync(log);255writeError(256title,257findLatexError(logText, result.stderr),258log,259);260} else {261// There is no log file, just display an unknown error262writeError(title);263}264}265266function indexIntermediate(dir: string, stem: string) {267const indexFile = join(dir, `${stem}.idx`);268if (existsSync(indexFile)) {269return indexFile;270} else {271return undefined;272}273}274275async function makeIndexIntermediates(276indexFile: string,277pkgMgr: PackageManager,278texLive: TexLiveContext,279engine?: string,280args?: string[],281quiet?: boolean,282) {283// If there is an idx file, we need to run makeindex to create the index data284if (indexFile) {285if (!quiet) {286logProgress("making index");287}288289// Make the index290try {291const response = await runIndexEngine(292indexFile,293texLive,294engine,295args,296pkgMgr,297quiet,298);299300// Indexing Failed301const indexLogExists = existsSync(response.log);302if (response.result.code !== 0) {303writeError(304`result code ${response.result.code}`,305"",306response.log,307);308return Promise.reject();309} else if (indexLogExists) {310// The command succeeded, but there is an indexing error in the lgo311const logText = Deno.readTextFileSync(response.log);312const error = findIndexError(logText);313if (error) {314writeError(315`error generating index`,316error,317response.log,318);319return Promise.reject();320}321}322return true;323} catch {324writeError(325`error generating index`,326);327return Promise.reject();328}329} else {330return false;331}332}333334async function makeBibliographyIntermediates(335input: string,336engine: string,337pkgMgr: PackageManager,338texLive: TexLiveContext,339outputDir?: string,340texInputDirs?: string[],341quiet?: boolean,342) {343// Generate bibliography (including potentially installing missing packages)344// By default, we'll use citeproc which requires no additional processing,345// but if the user would like to use natbib or biblatex, we do need additional346// processing (including explicitly calling the processing tool)347const bibCommand = engine === "natbib" ? "bibtex" : "biber";348349const [cwd, stem] = dirAndStem(input);350351while (true) {352// If biber, look for a bcf file, otherwise look for aux file353const auxBibFile = bibCommand === "biber" ? `${stem}.bcf` : `${stem}.aux`;354const auxBibPath = outputDir ? join(outputDir, auxBibFile) : auxBibFile;355const auxBibFullPath = join(cwd, auxBibPath);356357if (existsSync(auxBibFullPath)) {358const auxFileData = Deno.readTextFileSync(auxBibFullPath);359360const requiresProcessing = bibCommand === "biber"361? true362: containsBiblioData(auxFileData);363364if (requiresProcessing) {365if (!quiet) {366logProgress("generating bibliography");367}368369// If we're on windows and auto-install isn't enabled,370// fix up the aux file371//372if (isWindows) {373if (bibCommand !== "biber" && !hasTexLive()) {374// See https://github.com/rstudio/tinytex/blob/b2d1bae772f3f979e77fca9fb5efda05855b39d2/R/latex.R#L284375// Strips the '.bib' from any match and returns the string without the bib extension376// Replace any '.bib' in bibdata in windows auxData377const fixedAuxFileData = auxFileData.replaceAll(378/(^\\bibdata{.+)\.bib(.*})$/gm,379(380_substr: string,381prefix: string,382postfix: string,383) => {384return prefix + postfix;385},386);387388// Rewrite the corrected file389Deno.writeTextFileSync(auxBibFullPath, fixedAuxFileData);390}391}392393// If natbib, only use bibtex, otherwise, could use biber or bibtex394const response = await runBibEngine(395bibCommand,396auxBibPath,397cwd,398texLive,399pkgMgr,400texInputDirs,401quiet,402);403404if (response.result.code !== 0 && pkgMgr.autoInstall) {405// Biblio generation failed, see whether we should install anything to try to resolve406// Find the missing packages407const log = join(dirname(auxBibFullPath), `${stem}.blg`);408409if (existsSync(log)) {410const logOutput = Deno.readTextFileSync(log);411const match = logOutput.match(/.* open style file ([^ ]+).*/);412413if (match) {414const file = match[1];415if (416await findAndInstallPackages(417pkgMgr,418file,419response.result.stderr,420quiet,421)422) {423continue;424} else {425// TODO: read error out of blg file426// TODO: writeError that doesn't require logText?427writeError(`error generating bibliography`, "", log);428return Promise.reject();429}430}431}432}433return true;434}435}436437return false;438}439}440441async function findAndInstallPackages(442pkgMgr: PackageManager,443logFile: string,444stderr?: string,445_quiet?: boolean,446) {447if (existsSync(logFile)) {448// Read the log file itself449const logText = Deno.readTextFileSync(logFile);450451const searchTerms = findMissingFontsAndPackages(logText, dirname(logFile));452if (searchTerms.length > 0) {453const packages = await pkgMgr.searchPackages(searchTerms);454if (packages.length > 0) {455const packagesInstalled = await pkgMgr.installPackages(456packages,457);458if (packagesInstalled) {459// Try again460return true;461} else {462writeError(463"package installation error",464findLatexError(logText, stderr),465logFile,466);467return Promise.reject();468}469} else {470writeError(471"no matching packages",472findLatexError(logText, stderr),473logFile,474);475return Promise.reject();476}477} else {478writeError("error", findLatexError(logText, stderr), logFile);479return Promise.reject();480}481}482return false;483}484485function writeError(primary: string, secondary?: string, logFile?: string) {486error(487`\ncompilation failed- ${primary}`,488);489490if (secondary) {491info(secondary);492}493494if (logFile) {495info(`see ${logFile} for more information.`);496}497}498499async function recompileLatexUntilComplete(500input: string,501engine: PdfEngine,502pkgMgr: PackageManager,503minRuns: number,504maxRuns: number,505texLive: TexLiveContext,506outputDir?: string,507texInputDirs?: string[],508quiet?: boolean,509) {510// Run the engine until the bibliography is fully resolved511let runCount = 0;512513// convert to zero based minimum514minRuns = minRuns - 1;515while (true) {516// If we've exceeded maximum runs break517if (runCount >= maxRuns) {518if (!quiet) {519warning(520`maximum number of runs (${maxRuns}) reached`,521);522}523break;524}525526if (!quiet) {527logProgress(528`running ${engine.pdfEngine} - ${runCount + 2}`,529);530}531532const result = await runPdfEngine(533input,534engine,535texLive,536outputDir,537texInputDirs,538pkgMgr,539quiet,540);541542if (!result.result.success) {543// Failed544displayError("Error compiling latex", result.log, result.result);545return Promise.reject();546} else {547runCount = runCount + 1;548// If we haven't reached the minimum or the bibliography still needs to be rerun549// go again.550if (551existsSync(result.log) && needsRecompilation(result.log) ||552runCount < minRuns553) {554continue;555}556break;557}558}559}560561function containsBiblioData(auxData: string) {562return auxData.match(/^\\(bibdata|citation|bibstyle)\{/m);563}564565function auxFile(stem: string, ext: string) {566return `${stem}.${ext}`;567}568569function cleanup(workingDir: string, stem: string) {570const auxFiles = [571"log",572"idx",573"aux",574"bcf",575"blg",576"bbl",577"fls",578"out",579"lof",580"lot",581"toc",582"nav",583"snm",584"vrb",585"ilg",586"ind",587"xwm",588"brf",589"run.xml",590].map((aux) => join(workingDir, auxFile(stem, aux)));591592// Also cleanup any missfont.log file593auxFiles.push(join(workingDir, kMissingFontLog));594595auxFiles.forEach((auxFile) => {596if (existsSync(auxFile)) {597safeRemoveSync(auxFile);598}599});600}601602603