Path: blob/main/src/command/render/latexmk/pdf.ts
6434 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,23findPdfAccessibilityWarnings,24kMissingFontLog,25needsRecompilation,26} from "./parse-error.ts";27import { error, info, warning } from "../../../deno_ral/log.ts";28import { logProgress } from "../../../core/log.ts";29import { isWindows } from "../../../deno_ral/platform.ts";3031export async function generatePdf(mkOptions: LatexmkOptions): Promise<string> {32if (!mkOptions.quiet) {33logProgress("\nRendering PDF");34logProgress(35`running ${mkOptions.engine.pdfEngine} - 1`,36);37}3839// Get the working directory and file name stem40const [cwd, stem] = dirAndStem(mkOptions.input);41const workingDir = mkOptions.outputDir ? join(cwd, mkOptions.outputDir) : cwd;4243// Ensure that working directory exists44if (!existsSync(workingDir)) {45Deno.mkdirSync(workingDir);46} else {47// Clean the working directory of any leftover artifacts48cleanup(workingDir, stem);49}5051// Determine whether we support automatic updating (TexLive is available)52const allowUpdate = await hasTexLive();53mkOptions.autoInstall = mkOptions.autoInstall && allowUpdate;5455// Create the TexLive context for this compilation56const texLive = await texLiveContext(mkOptions.tinyTex !== false);5758// The package manager used to find and install packages59const pkgMgr = packageManager(mkOptions, texLive);6061// Render the PDF, detecting whether any packages need to be installed62const response = await initialCompileLatex(63mkOptions.input,64mkOptions.engine,65pkgMgr,66texLive,67mkOptions.outputDir,68mkOptions.texInputDirs,69mkOptions.quiet,70);71const initialCompileNeedsRerun = needsRecompilation(response.log);7273const indexIntermediateFile = indexIntermediate(workingDir, stem);74let indexCreated = false;75if (indexIntermediateFile) {76// When building large and complex indexes, it77// may be required to run the PDF engine again prior to building78// the index (or page numbers may be incorrect).79// See: https://github.com/rstudio/bookdown/issues/127480info(" Re-compiling document for index");81await runPdfEngine(82mkOptions.input,83mkOptions.engine,84texLive,85mkOptions.outputDir,86mkOptions.texInputDirs,87pkgMgr,88mkOptions.quiet,89);9091// Generate the index information, if needed92indexCreated = await makeIndexIntermediates(93indexIntermediateFile,94pkgMgr,95texLive,96mkOptions.engine.indexEngine,97mkOptions.engine.indexEngineOpts,98mkOptions.quiet,99);100}101102// Generate the bibliography intermediaries103const bibliographyCreated = await makeBibliographyIntermediates(104mkOptions.input,105mkOptions.engine.bibEngine || "citeproc",106pkgMgr,107texLive,108mkOptions.outputDir,109mkOptions.texInputDirs,110mkOptions.quiet,111);112113// Recompile the Latex if required114// we have already run the engine one time (hence subtracting one run from min and max)115const minRuns = (mkOptions.minRuns || 1) - 1;116const maxRuns = (mkOptions.maxRuns || 10) - 1;117if (118(indexCreated || bibliographyCreated || minRuns ||119initialCompileNeedsRerun) && maxRuns > 0120) {121await recompileLatexUntilComplete(122mkOptions.input,123mkOptions.engine,124pkgMgr,125mkOptions.minRuns || 1,126maxRuns,127texLive,128mkOptions.outputDir,129mkOptions.texInputDirs,130mkOptions.quiet,131);132}133134// cleanup if requested135if (mkOptions.clean) {136cleanup(workingDir, stem);137}138139if (!mkOptions.quiet) {140info("");141}142143return mkOptions.outputDir144? join(mkOptions.outputDir, stem + ".pdf")145: join(cwd, stem + ".pdf");146}147148// The first pass compilation of the latex with the ability to discover149// missing packages (and subsequently retrying the compilation)150async function initialCompileLatex(151input: string,152engine: PdfEngine,153pkgMgr: PackageManager,154texLive: TexLiveContext,155outputDir?: string,156texInputDirs?: string[],157quiet?: boolean,158) {159let packagesUpdated = false;160while (true) {161// Run the pdf engine162const response = await runPdfEngine(163input,164engine,165texLive,166outputDir,167texInputDirs,168pkgMgr,169quiet,170);171172// Check whether it suceeded. We'll consider it a failure if there is an error status or output is missing despite a success status173// (PNAS Template may eat errors when missing packages exists)174// See: https://github.com/rstudio/tinytex/blob/6c0078f2c3c1319a48b71b61753f09c3ec079c0a/R/latex.R#L216175const success = response.result.code === 0 &&176(!response.output || existsSync(response.output));177178if (success) {179// See whether there are warnings about hyphenation180// See (https://github.com/rstudio/tinytex/commit/0f2007426f730a6ed9d45369233c1349a69ddd29)181const logText = Deno.readTextFileSync(response.log);182const missingHyphenationFile = findMissingHyphenationFiles(logText);183if (missingHyphenationFile) {184// try to install it, unless auto install is opted out185if (pkgMgr.autoInstall) {186logProgress("Installing missing hyphenation file...");187if (await pkgMgr.installPackages([missingHyphenationFile])) {188// We installed hyphenation files, retry189continue;190} else {191logProgress("Installing missing hyphenation file failed.");192}193}194// Let's just through a warning, but it may not be fatal for the compilation195// and we can end normally196warning(197`Possibly missing hyphenation file: '${missingHyphenationFile}'. See more in logfile (by setting 'latex-clean: false').\n`,198);199}200201// Check for accessibility warnings (e.g., missing alt text, language with PDF/UA)202const accessibilityWarnings = findPdfAccessibilityWarnings(logText);203if (accessibilityWarnings.missingAltText.length > 0) {204const fileList = accessibilityWarnings.missingAltText.join(", ");205warning(206`PDF accessibility: Missing alt text for image(s): ${fileList}. Add alt text using  syntax for PDF/UA compliance.\n`,207);208}209if (accessibilityWarnings.missingLanguage) {210warning(211`PDF accessibility: Document language not set. Add 'lang: en' (or appropriate language) to document metadata for PDF/UA compliance.\n`,212);213}214if (accessibilityWarnings.otherWarnings.length > 0) {215for (const warn of accessibilityWarnings.otherWarnings) {216warning(`PDF accessibility: ${warn}\n`);217}218}219} else if (pkgMgr.autoInstall) {220// try autoinstalling221// First be sure all packages are up to date222if (!packagesUpdated) {223if (!quiet) {224logProgress("updating tlmgr");225}226await pkgMgr.updatePackages(false, true);227info("");228229if (!quiet) {230logProgress("updating existing packages");231}232await pkgMgr.updatePackages(true, false);233packagesUpdated = true;234}235236// Try to find and install packages237const packagesInstalled = await findAndInstallPackages(238pkgMgr,239response.log,240response.result.stderr,241quiet,242);243244if (packagesInstalled) {245// try the intial compile again246continue;247} else {248// We failed to install packages (but there are missing packages), give up249displayError(250"missing packages (automatic installation failed)",251response.log,252response.result,253);254return Promise.reject();255}256} else {257// Failed, but no auto-installation, just display the error258displayError(259"missing packages (automatic installed disabled)",260response.log,261response.result,262);263return Promise.reject();264}265266// If we get here, we aren't installing packages (or we've already installed them)267return Promise.resolve(response);268}269}270271function displayError(title: string, log: string, result: ProcessResult) {272if (existsSync(log)) {273// There is a log file, so read that and try to find the error274const logText = Deno.readTextFileSync(log);275writeError(276title,277findLatexError(logText, result.stderr),278log,279);280} else {281// There is no log file, just display an unknown error282writeError(title);283}284}285286function indexIntermediate(dir: string, stem: string) {287const indexFile = join(dir, `${stem}.idx`);288if (existsSync(indexFile)) {289return indexFile;290} else {291return undefined;292}293}294295async function makeIndexIntermediates(296indexFile: string,297pkgMgr: PackageManager,298texLive: TexLiveContext,299engine?: string,300args?: string[],301quiet?: boolean,302) {303// If there is an idx file, we need to run makeindex to create the index data304if (indexFile) {305if (!quiet) {306logProgress("making index");307}308309// Make the index310try {311const response = await runIndexEngine(312indexFile,313texLive,314engine,315args,316pkgMgr,317quiet,318);319320// Indexing Failed321const indexLogExists = existsSync(response.log);322if (response.result.code !== 0) {323writeError(324`result code ${response.result.code}`,325"",326response.log,327);328return Promise.reject();329} else if (indexLogExists) {330// The command succeeded, but there is an indexing error in the lgo331const logText = Deno.readTextFileSync(response.log);332const error = findIndexError(logText);333if (error) {334writeError(335`error generating index`,336error,337response.log,338);339return Promise.reject();340}341}342return true;343} catch {344writeError(345`error generating index`,346);347return Promise.reject();348}349} else {350return false;351}352}353354async function makeBibliographyIntermediates(355input: string,356engine: string,357pkgMgr: PackageManager,358texLive: TexLiveContext,359outputDir?: string,360texInputDirs?: string[],361quiet?: boolean,362) {363// Generate bibliography (including potentially installing missing packages)364// By default, we'll use citeproc which requires no additional processing,365// but if the user would like to use natbib or biblatex, we do need additional366// processing (including explicitly calling the processing tool)367const bibCommand = engine === "natbib" ? "bibtex" : "biber";368369const [cwd, stem] = dirAndStem(input);370371while (true) {372// If biber, look for a bcf file, otherwise look for aux file373const auxBibFile = bibCommand === "biber" ? `${stem}.bcf` : `${stem}.aux`;374const auxBibPath = outputDir ? join(outputDir, auxBibFile) : auxBibFile;375const auxBibFullPath = join(cwd, auxBibPath);376377if (existsSync(auxBibFullPath)) {378const auxFileData = Deno.readTextFileSync(auxBibFullPath);379380const requiresProcessing = bibCommand === "biber"381? true382: containsBiblioData(auxFileData);383384if (requiresProcessing) {385if (!quiet) {386logProgress("generating bibliography");387}388389// If we're on windows and auto-install isn't enabled,390// fix up the aux file391//392if (isWindows) {393if (bibCommand !== "biber" && !hasTexLive()) {394// See https://github.com/rstudio/tinytex/blob/b2d1bae772f3f979e77fca9fb5efda05855b39d2/R/latex.R#L284395// Strips the '.bib' from any match and returns the string without the bib extension396// Replace any '.bib' in bibdata in windows auxData397const fixedAuxFileData = auxFileData.replaceAll(398/(^\\bibdata{.+)\.bib(.*})$/gm,399(400_substr: string,401prefix: string,402postfix: string,403) => {404return prefix + postfix;405},406);407408// Rewrite the corrected file409Deno.writeTextFileSync(auxBibFullPath, fixedAuxFileData);410}411}412413// If natbib, only use bibtex, otherwise, could use biber or bibtex414const response = await runBibEngine(415bibCommand,416auxBibPath,417cwd,418texLive,419pkgMgr,420texInputDirs,421quiet,422);423424if (response.result.code !== 0 && pkgMgr.autoInstall) {425// Biblio generation failed, see whether we should install anything to try to resolve426// Find the missing packages427const log = join(dirname(auxBibFullPath), `${stem}.blg`);428429if (existsSync(log)) {430const logOutput = Deno.readTextFileSync(log);431const match = logOutput.match(/.* open style file ([^ ]+).*/);432433if (match) {434const file = match[1];435if (436await findAndInstallPackages(437pkgMgr,438file,439response.result.stderr,440quiet,441)442) {443continue;444} else {445// TODO: read error out of blg file446// TODO: writeError that doesn't require logText?447writeError(`error generating bibliography`, "", log);448return Promise.reject();449}450}451}452}453return true;454}455}456457return false;458}459}460461async function findAndInstallPackages(462pkgMgr: PackageManager,463logFile: string,464stderr?: string,465_quiet?: boolean,466) {467if (existsSync(logFile)) {468// Read the log file itself469const logText = Deno.readTextFileSync(logFile);470471const searchTerms = findMissingFontsAndPackages(logText, dirname(logFile));472if (searchTerms.length > 0) {473const packages = await pkgMgr.searchPackages(searchTerms);474if (packages.length > 0) {475const packagesInstalled = await pkgMgr.installPackages(476packages,477);478if (packagesInstalled) {479// Try again480return true;481} else {482writeError(483"package installation error",484findLatexError(logText, stderr),485logFile,486);487return Promise.reject();488}489} else {490writeError(491"no matching packages",492findLatexError(logText, stderr),493logFile,494);495return Promise.reject();496}497} else {498writeError("error", findLatexError(logText, stderr), logFile);499return Promise.reject();500}501}502return false;503}504505function writeError(primary: string, secondary?: string, logFile?: string) {506error(507`\ncompilation failed- ${primary}`,508);509510if (secondary) {511info(secondary);512}513514if (logFile) {515info(`see ${logFile} for more information.`);516}517}518519async function recompileLatexUntilComplete(520input: string,521engine: PdfEngine,522pkgMgr: PackageManager,523minRuns: number,524maxRuns: number,525texLive: TexLiveContext,526outputDir?: string,527texInputDirs?: string[],528quiet?: boolean,529) {530// Run the engine until the bibliography is fully resolved531let runCount = 0;532533// convert to zero based minimum534minRuns = minRuns - 1;535while (true) {536// If we've exceeded maximum runs break537if (runCount >= maxRuns) {538if (!quiet) {539warning(540`maximum number of runs (${maxRuns}) reached`,541);542}543break;544}545546if (!quiet) {547logProgress(548`running ${engine.pdfEngine} - ${runCount + 2}`,549);550}551552const result = await runPdfEngine(553input,554engine,555texLive,556outputDir,557texInputDirs,558pkgMgr,559quiet,560);561562if (!result.result.success) {563// Failed564displayError("Error compiling latex", result.log, result.result);565return Promise.reject();566} else {567runCount = runCount + 1;568// If we haven't reached the minimum or the bibliography still needs to be rerun569// go again.570if (571existsSync(result.log) && needsRecompilation(result.log) ||572runCount < minRuns573) {574continue;575}576break;577}578}579}580581function containsBiblioData(auxData: string) {582return auxData.match(/^\\(bibdata|citation|bibstyle)\{/m);583}584585function auxFile(stem: string, ext: string) {586return `${stem}.${ext}`;587}588589function cleanup(workingDir: string, stem: string) {590const auxFiles = [591"log",592"idx",593"aux",594"bcf",595"blg",596"bbl",597"fls",598"out",599"lof",600"lot",601"toc",602"nav",603"snm",604"vrb",605"ilg",606"ind",607"xwm",608"brf",609"run.xml",610].map((aux) => join(workingDir, auxFile(stem, aux)));611612// Also cleanup any missfont.log file613auxFiles.push(join(workingDir, kMissingFontLog));614615auxFiles.forEach((auxFile) => {616if (existsSync(auxFile)) {617safeRemoveSync(auxFile);618}619});620}621622623