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