1"""Export to PDF via latex"""
2
3# Copyright (c) IPython Development Team.
4# Distributed under the terms of the Modified BSD License.
5from __future__ import annotations
6
7import os
8import shutil
9import subprocess
10import sys
11from tempfile import TemporaryDirectory
12
13from traitlets import Bool, Instance, Integer, List, Unicode, default
14
15from nbconvert.utils import _contextlib_chdir
16
17from .latex import LatexExporter
18
19
20class LatexFailed(IOError):
21 """Exception for failed latex run
22
23 Captured latex output is in error.output.
24 """
25
26 def __init__(self, output):
27 """Initialize the error."""
28 self.output = output
29
30 def __unicode__(self):
31 """Unicode representation."""
32 return "PDF creating failed, captured latex output:\n%s" % self.output
33
34 def __str__(self):
35 """String representation."""
36 return self.__unicode__()
37
38
39def prepend_to_env_search_path(varname, value, envdict):
40 """Add value to the environment variable varname in envdict
41
42 e.g. prepend_to_env_search_path('BIBINPUTS', '/home/sally/foo', os.environ)
43 """
44 if not value:
45 return # Nothing to add
46
47 envdict[varname] = value + os.pathsep + envdict.get(varname, "")
48
49
50class PDFExporter(LatexExporter):
51 """Writer designed to write to PDF files.
52
53 This inherits from `LatexExporter`. It creates a LaTeX file in
54 a temporary directory using the template machinery, and then runs LaTeX
55 to create a pdf.
56 """
57
58 export_from_notebook = "PDF via LaTeX"
59
60 latex_count = Integer(3, help="How many times latex will be called.").tag(config=True)
61
62 latex_command = List(
63 ["xelatex", "{filename}", "-quiet"], help="Shell command used to compile latex."
64 ).tag(config=True)
65
66 bib_command = List(["bibtex", "{filename}"], help="Shell command used to run bibtex.").tag(
67 config=True
68 )
69
70 verbose = Bool(False, help="Whether to display the output of latex commands.").tag(config=True)
71
72 texinputs = Unicode(help="texinputs dir. A notebook's directory is added")
73 writer = Instance("nbconvert.writers.FilesWriter", args=(), kw={"build_directory": "."})
74
75 output_mimetype = "application/pdf"
76
77 _captured_output = List(Unicode())
78
79 @default("file_extension")
80 def _file_extension_default(self):
81 return ".pdf"
82
83 @default("template_extension")
84 def _template_extension_default(self):
85 return ".tex.j2"
86
87 def run_command(self, command_list, filename, count, log_function, raise_on_failure=None):
88 """Run command_list count times.
89
90 Parameters
91 ----------
92 command_list : list
93 A list of args to provide to Popen. Each element of this
94 list will be interpolated with the filename to convert.
95 filename : unicode
96 The name of the file to convert.
97 count : int
98 How many times to run the command.
99 raise_on_failure: Exception class (default None)
100 If provided, will raise the given exception for if an instead of
101 returning False on command failure.
102
103 Returns
104 -------
105 success : bool
106 A boolean indicating if the command was successful (True)
107 or failed (False).
108 """
109 command = [c.format(filename=filename) for c in command_list]
110
111 # This will throw a clearer error if the command is not found
112 cmd = shutil.which(command_list[0])
113 if cmd is None:
114 link = "https://nbconvert.readthedocs.io/en/latest/install.html#installing-tex"
115 msg = (
116 f"{command_list[0]} not found on PATH, if you have not installed "
117 f"{command_list[0]} you may need to do so. Find further instructions "
118 f"at {link}."
119 )
120 raise OSError(msg)
121
122 times = "time" if count == 1 else "times"
123 self.log.info("Running %s %i %s: %s", command_list[0], count, times, command)
124
125 shell = sys.platform == "win32"
126 if shell:
127 command = subprocess.list2cmdline(command) # type:ignore[assignment]
128 env = os.environ.copy()
129 prepend_to_env_search_path("TEXINPUTS", self.texinputs, env)
130 prepend_to_env_search_path("BIBINPUTS", self.texinputs, env)
131 prepend_to_env_search_path("BSTINPUTS", self.texinputs, env)
132
133 with open(os.devnull, "rb") as null:
134 stdout = subprocess.PIPE if not self.verbose else None
135 for _ in range(count):
136 p = subprocess.Popen(
137 command,
138 stdout=stdout,
139 stderr=subprocess.STDOUT,
140 stdin=null,
141 shell=shell, # noqa: S603
142 env=env,
143 )
144 out, _ = p.communicate()
145 if p.returncode:
146 if self.verbose: # noqa: SIM108
147 # verbose means I didn't capture stdout with PIPE,
148 # so it's already been displayed and `out` is None.
149 out_str = ""
150 else:
151 out_str = out.decode("utf-8", "replace")
152 log_function(command, out)
153 self._captured_output.append(out_str)
154 if raise_on_failure:
155 msg = f'Failed to run "{command}" command:\n{out_str}'
156 raise raise_on_failure(msg)
157 return False # failure
158 return True # success
159
160 def run_latex(self, filename, raise_on_failure=LatexFailed):
161 """Run xelatex self.latex_count times."""
162
163 def log_error(command, out):
164 self.log.critical("%s failed: %s\n%s", command[0], command, out)
165
166 return self.run_command(
167 self.latex_command, filename, self.latex_count, log_error, raise_on_failure
168 )
169
170 def run_bib(self, filename, raise_on_failure=False):
171 """Run bibtex one time."""
172 filename = os.path.splitext(filename)[0]
173
174 def log_error(command, out):
175 self.log.warning(
176 "%s had problems, most likely because there were no citations", command[0]
177 )
178 self.log.debug("%s output: %s\n%s", command[0], command, out)
179
180 return self.run_command(self.bib_command, filename, 1, log_error, raise_on_failure)
181
182 def from_notebook_node(self, nb, resources=None, **kw):
183 """Convert from notebook node."""
184 latex, resources = super().from_notebook_node(nb, resources=resources, **kw)
185 # set texinputs directory, so that local files will be found
186 if resources and resources.get("metadata", {}).get("path"):
187 self.texinputs = os.path.abspath(resources["metadata"]["path"])
188 else:
189 self.texinputs = os.getcwd()
190
191 self._captured_outputs = []
192 with TemporaryDirectory() as td, _contextlib_chdir.chdir(td):
193 notebook_name = "notebook"
194 resources["output_extension"] = ".tex"
195 tex_file = self.writer.write(latex, resources, notebook_name=notebook_name)
196 self.log.info("Building PDF")
197 self.run_latex(tex_file)
198 if self.run_bib(tex_file):
199 self.run_latex(tex_file)
200
201 pdf_file = notebook_name + ".pdf"
202 if not os.path.isfile(pdf_file):
203 raise LatexFailed("\n".join(self._captured_output))
204 self.log.info("PDF successfully created")
205 with open(pdf_file, "rb") as f:
206 pdf_data = f.read()
207
208 # convert output extension to pdf
209 # the writer above required it to be tex
210 resources["output_extension"] = ".pdf"
211 # clear figure outputs and attachments, extracted by latex export,
212 # so we don't claim to be a multi-file export.
213 resources.pop("outputs", None)
214 resources.pop("attachments", None)
215
216 return pdf_data, resources