pdf.py 7.7 KB

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