svg2pdf.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. """Module containing a preprocessor that converts outputs in the notebook from
  2. one format to another.
  3. """
  4. # Copyright (c) Jupyter Development Team.
  5. # Distributed under the terms of the Modified BSD License.
  6. import base64
  7. import os
  8. import subprocess
  9. import sys
  10. import warnings
  11. from pathlib import Path
  12. from shutil import which
  13. from tempfile import TemporaryDirectory
  14. from traitlets import List, Unicode, Union, default
  15. from nbconvert.utils.io import FormatSafeDict
  16. from .convertfigures import ConvertFiguresPreprocessor
  17. # inkscape path for darwin (macOS)
  18. INKSCAPE_APP = "/Applications/Inkscape.app/Contents/Resources/bin/inkscape"
  19. # Recent versions of Inkscape (v1.0) moved the executable from
  20. # Resources/bin/inkscape to MacOS/inkscape
  21. INKSCAPE_APP_v1 = "/Applications/Inkscape.app/Contents/MacOS/inkscape"
  22. if sys.platform == "win32":
  23. try:
  24. import winreg
  25. except ImportError:
  26. import _winreg as winreg
  27. class SVG2PDFPreprocessor(ConvertFiguresPreprocessor):
  28. """
  29. Converts all of the outputs in a notebook from SVG to PDF.
  30. """
  31. @default("from_format")
  32. def _from_format_default(self):
  33. return "image/svg+xml"
  34. @default("to_format")
  35. def _to_format_default(self):
  36. return "application/pdf"
  37. inkscape_version = Unicode(
  38. help="""The version of inkscape being used.
  39. This affects how the conversion command is run.
  40. """
  41. ).tag(config=True)
  42. @default("inkscape_version")
  43. def _inkscape_version_default(self):
  44. p = subprocess.Popen( # noqa:S603
  45. [self.inkscape, "--version"],
  46. stdout=subprocess.PIPE,
  47. stderr=subprocess.PIPE,
  48. )
  49. output, _ = p.communicate()
  50. if p.returncode != 0:
  51. msg = "Unable to find inkscape executable --version"
  52. raise RuntimeError(msg)
  53. return output.decode("utf-8").split(" ")[1]
  54. # FIXME: Deprecate passing a string here
  55. command = Union(
  56. [Unicode(), List()],
  57. help="""
  58. The command to use for converting SVG to PDF
  59. This traitlet is a template, which will be formatted with the keys
  60. to_filename and from_filename.
  61. The conversion call must read the SVG from {from_filename},
  62. and write a PDF to {to_filename}.
  63. It could be a List (recommended) or a String. If string, it will
  64. be passed to a shell for execution.
  65. """,
  66. ).tag(config=True)
  67. @default("command")
  68. def _command_default(self):
  69. major_version = self.inkscape_version.split(".")[0]
  70. command = [self.inkscape]
  71. if int(major_version) < 1:
  72. # --without-gui is only needed for inkscape 0.x
  73. command.append("--without-gui")
  74. # --export-pdf is old name for --export-filename
  75. command.append("--export-pdf={to_filename}")
  76. else:
  77. command.append("--export-filename={to_filename}")
  78. command.append("{from_filename}")
  79. return command
  80. inkscape = Unicode(help="The path to Inkscape, if necessary").tag(config=True)
  81. @default("inkscape")
  82. def _inkscape_default(self):
  83. # Windows: Secure registry lookup FIRST (CVE-2025-53000 fix)
  84. if sys.platform == "win32":
  85. wr_handle = winreg.ConnectRegistry(None, winreg.HKEY_LOCAL_MACHINE)
  86. try:
  87. rkey = winreg.OpenKey(wr_handle, r"SOFTWARE\Classes\inkscape.svg\DefaultIcon")
  88. inkscape_full = winreg.QueryValueEx(rkey, "")[0].split(",")[0] # Fix: remove ",0"
  89. if os.path.isfile(inkscape_full):
  90. return inkscape_full
  91. except (FileNotFoundError, OSError, IndexError):
  92. pass # Safe fallback
  93. # Block CWD in PATH search (CVE-2025-53000)
  94. os.environ["NODEFAULTCURRENTDIRECTORYINEXEPATH"] = "1"
  95. inkscape_path = which("inkscape")
  96. # Extra safety for Python < 3.12 on Windows:
  97. # If which() resolved to a path in CWD even though CWD is not on PATH,
  98. # warn and treat as "not found".
  99. if sys.platform == "win32" and inkscape_path and sys.version_info < (3, 12):
  100. try:
  101. cwd = Path.cwd().resolve()
  102. in_cwd = Path(inkscape_path).resolve().parent == cwd
  103. cwd_on_path = cwd in {
  104. Path(p).resolve() for p in os.environ.get("PATH", os.defpath).split(os.pathsep)
  105. }
  106. if in_cwd and not cwd_on_path:
  107. warnings.warn(
  108. "shutil.which('inkscape') resolved to an executable in the current "
  109. "working directory even though CWD is not on PATH. Ignoring this "
  110. "result for security reasons (CVE-2025-53000).",
  111. RuntimeWarning,
  112. stacklevel=2,
  113. )
  114. inkscape_path = None
  115. except Exception:
  116. # If detection fails for any reason, prefer safety: ignore CWD result
  117. inkscape_path = None
  118. if inkscape_path is not None:
  119. return inkscape_path
  120. # macOS: EXACT original order preserved
  121. if sys.platform == "darwin":
  122. if os.path.isfile(INKSCAPE_APP_v1):
  123. return INKSCAPE_APP_v1
  124. # Order is important. If INKSCAPE_APP exists, prefer it over
  125. # the executable in the MacOS directory.
  126. if os.path.isfile(INKSCAPE_APP):
  127. return INKSCAPE_APP
  128. msg = "Inkscape executable not found in safe paths"
  129. raise FileNotFoundError(msg)
  130. def convert_figure(self, data_format, data):
  131. """
  132. Convert a single SVG figure to PDF. Returns converted data.
  133. """
  134. # Work in a temporary directory
  135. with TemporaryDirectory() as tmpdir:
  136. # Write fig to temp file
  137. input_filename = os.path.join(tmpdir, "figure.svg")
  138. # SVG data is unicode text
  139. with open(input_filename, "w", encoding="utf8") as f:
  140. f.write(data)
  141. # Call conversion application
  142. output_filename = os.path.join(tmpdir, "figure.pdf")
  143. template_vars = {"from_filename": input_filename, "to_filename": output_filename}
  144. if isinstance(self.command, list):
  145. full_cmd = [s.format_map(FormatSafeDict(**template_vars)) for s in self.command]
  146. else:
  147. # For backwards compatibility with specifying strings
  148. # Okay-ish, since the string is trusted
  149. full_cmd = self.command.format(**template_vars)
  150. subprocess.call(full_cmd, shell=isinstance(full_cmd, str)) # noqa: S603
  151. # Read output from drive
  152. # return value expects a filename
  153. if os.path.isfile(output_filename):
  154. with open(output_filename, "rb") as f:
  155. # PDF is a nb supported binary, data type, so base64 encode.
  156. return base64.encodebytes(f.read()).decode("utf-8")
  157. else:
  158. msg = "Inkscape svg to pdf conversion failed"
  159. raise TypeError(msg)