Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/xlsxwriter/url.py: 25%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1###############################################################################
2#
3# Url - A class to represent URLs in Excel.
4#
5# SPDX-License-Identifier: BSD-2-Clause
6#
7# Copyright (c) 2013-2025, John McNamara, jmcnamara@cpan.org
8#
11import re
12from enum import Enum
13from typing import Optional
16class UrlTypes(Enum):
17 """
18 Enum to represent different types of URLS.
19 """
21 UNKNOWN = 0
22 URL = 1
23 INTERNAL = 2
24 EXTERNAL = 3
27class Url:
28 """
29 A class to represent URLs in Excel.
31 """
33 MAX_URL_LEN = 2080
34 MAX_PARAMETER_LEN = 255
36 def __init__(self, link: str):
37 self._link_type: UrlTypes = UrlTypes.UNKNOWN
38 self._original_url: str = link
39 self._link: str = link
40 self._relationship_link: str = link
41 self._text: str = ""
42 self._tip: str = ""
43 self._anchor: str = ""
44 self._is_object_link: bool = False
45 self._rel_index: int = 0
47 self._parse_url()
49 if len(self._link) > self.MAX_URL_LEN:
50 raise ValueError("URL exceeds Excel's maximum length.")
52 if len(self._anchor) > self.MAX_URL_LEN:
53 raise ValueError("Anchor segment or url exceeds Excel's maximum length.")
55 if len(self._tip) > self.MAX_PARAMETER_LEN:
56 raise ValueError("Hyperlink tool tip exceeds Excel's maximum length.")
58 self._escape_strings()
60 def __repr__(self):
61 """
62 Return a string representation of the Url instance.
64 """
65 return (
66 "\n"
67 f"Url:\n"
68 f" _link_type = {self._link_type.name}\n"
69 f" _original_url = {self._original_url}\n"
70 f" _link = {self._link}\n"
71 f" _relationship_link = {self._relationship_link}\n"
72 f" _text = {self._text}\n"
73 f" _tip = {self._tip}\n"
74 f" _anchor = {self._anchor}\n"
75 f" _is_object_link = {self._is_object_link}\n"
76 f" _rel_index = {self._rel_index}\n"
77 )
79 @classmethod
80 def from_options(cls, options: dict) -> Optional["Url"]:
81 """
82 For backward compatibility, convert the 'url' key and 'tip' keys in an
83 options dictionary to a Url object, or return the Url object if already
84 an instance.
86 Args:
87 options (dict): A dictionary that may contain a 'url' key.
89 Returns:
90 url: A Url object or None.
92 """
93 if not isinstance(options, dict):
94 raise TypeError("The 'options' parameter must be a dictionary.")
96 url = options.get("url")
98 if isinstance(url, str):
99 url = cls(options["url"])
100 if options.get("tip"):
101 url._tip = options["tip"]
103 return url
105 @property
106 def text(self) -> str:
107 """Get the alternative, user-friendly, text for the URL."""
108 return self._text
110 @text.setter
111 def text(self, value: str):
112 """Set the alternative, user-friendly, text for the URL."""
113 self._text = value
115 @property
116 def tip(self) -> str:
117 """Get the screen tip for the URL."""
118 return self._tip
120 @tip.setter
121 def tip(self, value: str):
122 """Set the screen tip for the URL."""
123 self._tip = value
125 def _parse_url(self):
126 """Parse the URL and determine its type."""
128 # Handle mail address links.
129 if self._link.startswith("mailto:"):
130 self._link_type = UrlTypes.URL
132 if not self._text:
133 self._text = self._link.replace("mailto:", "", 1)
135 # Handle links to cells within the workbook.
136 elif self._link.startswith("internal:"):
137 self._link_type = UrlTypes.INTERNAL
138 self._relationship_link = self._link.replace("internal:", "#", 1)
139 self._link = self._link.replace("internal:", "", 1)
140 self._anchor = self._link
142 if not self._text:
143 self._text = self._anchor
145 # Handle links to other files or cells in other Excel files.
146 elif self._link.startswith("file://") or self._link.startswith("external:"):
147 self._link_type = UrlTypes.EXTERNAL
149 # Handle backward compatibility with external: links.
150 file_url = self._original_url.replace("external:", "file:///", 1)
152 link_path = file_url
153 link_path = link_path.replace("file:///", "", 1)
154 link_path = link_path.replace("file://", "", 1)
155 link_path = link_path.replace("/", "\\")
157 if self._is_relative_path(link_path):
158 self._link = link_path
159 else:
160 self._link = "file:///" + link_path
162 if not self._text:
163 self._text = link_path
165 if "#" in self._link:
166 self._link, self._anchor = self._link.split("#", 1)
168 # Set up the relationship link. This doesn't usually contain the
169 # anchor unless it is a link from an object like an image.
170 if self._is_object_link:
171 if self._is_relative_path(link_path):
172 self._relationship_link = self._link.replace("\\", "/")
173 else:
174 self._relationship_link = file_url
176 else:
177 self._relationship_link = self._link
179 # Convert a .\dir\file.xlsx link to dir\file.xlsx.
180 if self._relationship_link.startswith(".\\"):
181 self._relationship_link = self._relationship_link.replace(".\\", "", 1)
183 # Handle standard Excel links like http://, https://, ftp://, ftps://
184 # but also allow custom "foo://bar" URLs.
185 elif "://" in self._link:
186 self._link_type = UrlTypes.URL
188 if not self._text:
189 self._text = self._link
191 if "#" in self._link:
192 self._link, self._anchor = self._link.split("#", 1)
194 # Set up the relationship link. This doesn't usually contain the
195 # anchor unless it is a link from an object like an image.
196 if self._is_object_link:
197 self._relationship_link = self._original_url
198 else:
199 self._relationship_link = self._link
201 else:
202 raise ValueError(f"Unknown URL type: {self._original_url}")
204 def _set_object_link(self):
205 """
206 Set the _is_object_link flag and re-parse the URL since the relationship
207 link is different for object links.
209 """
210 self._is_object_link = True
211 self._link = self._original_url
212 self._parse_url()
213 self._escape_strings()
215 def _escape_strings(self):
216 """Escape special characters in the URL strings."""
218 if self._link_type != UrlTypes.INTERNAL:
219 self._link = self._escape_url(self._link)
220 self._relationship_link = self._escape_url(self._relationship_link)
222 # Excel additionally escapes # to %23 in file paths.
223 if self._link_type == UrlTypes.EXTERNAL:
224 self._relationship_link = self._relationship_link.replace("#", "%23")
226 def _target(self) -> str:
227 """Get the target for relationship IDs."""
228 return self._relationship_link
230 def _target_mode(self) -> str:
231 """Get the target mode for relationship IDs."""
232 if self._link_type == UrlTypes.INTERNAL:
233 return ""
235 return "External"
237 @staticmethod
238 def _is_relative_path(url: str) -> bool:
239 """Check if a URL is a relative path."""
240 if url.startswith(r"\\"):
241 return False
243 if url[0].isalpha() and url[1] == ":":
244 return False
246 return True
248 @staticmethod
249 def _escape_url(url: str) -> str:
250 """Escape special characters in a URL."""
251 # Don't escape URL if it looks already escaped.
252 if re.search("%[0-9a-fA-F]{2}", url):
253 return url
255 # Can't use url.quote() here because it doesn't match Excel.
256 return (
257 url.replace("%", "%25")
258 .replace('"', "%22")
259 .replace(" ", "%20")
260 .replace("<", "%3c")
261 .replace(">", "%3e")
262 .replace("[", "%5b")
263 .replace("]", "%5d")
264 .replace("^", "%5e")
265 .replace("`", "%60")
266 .replace("{", "%7b")
267 .replace("}", "%7d")
268 )