Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/xlsxwriter/url.py: 26%
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-2026, John McNamara, jmcnamara@cpan.org
8#
11import re
12from enum import Enum
13from typing import Any, Dict, Optional
15from xlsxwriter.utility import _utf16_len
18class UrlTypes(Enum):
19 """
20 Enum to represent different types of URLS.
21 """
23 UNKNOWN = 0
24 URL = 1
25 INTERNAL = 2
26 EXTERNAL = 3
29class Url:
30 """
31 A class to represent URLs in Excel.
33 """
35 MAX_URL_LEN = 2080
36 MAX_PARAMETER_LEN = 255
38 def __init__(self, link: str) -> None:
39 self._link_type: UrlTypes = UrlTypes.UNKNOWN
40 self._original_url: str = link
41 self._link: str = link
42 self._relationship_link: str = link
43 self._text: str = ""
44 self._tip: str = ""
45 self._anchor: str = ""
46 self._is_object_link: bool = False
47 self._rel_index: int = 0
49 self._parse_url()
51 if _utf16_len(self._link) > self.MAX_URL_LEN:
52 raise ValueError("URL exceeds Excel's maximum length.")
54 if _utf16_len(self._anchor) > self.MAX_URL_LEN:
55 raise ValueError("Anchor segment or url exceeds Excel's maximum length.")
57 self._escape_strings()
59 def __repr__(self) -> str:
60 """
61 Return a string representation of the Url instance.
63 """
64 return (
65 "\n"
66 f"Url:\n"
67 f" _link_type = {self._link_type.name}\n"
68 f" _original_url = {self._original_url}\n"
69 f" _link = {self._link}\n"
70 f" _relationship_link = {self._relationship_link}\n"
71 f" _text = {self._text}\n"
72 f" _tip = {self._tip}\n"
73 f" _anchor = {self._anchor}\n"
74 f" _is_object_link = {self._is_object_link}\n"
75 f" _rel_index = {self._rel_index}\n"
76 )
78 @classmethod
79 def from_options(cls, options: Dict[str, Any]) -> Optional["Url"]:
80 """
81 For backward compatibility, convert the 'url' key and 'tip' keys in an
82 options dictionary to a Url object, or return the Url object if already
83 an instance.
85 Args:
86 options (dict): A dictionary that may contain a 'url' key.
88 Returns:
89 url: A Url object or None.
91 """
92 if not isinstance(options, dict):
93 raise TypeError("The 'options' parameter must be a dictionary.")
95 url = options.get("url")
97 if isinstance(url, str):
98 url = cls(options["url"])
99 if options.get("tip"):
100 url.tip = options["tip"]
102 return url
104 @property
105 def text(self) -> str:
106 """Get the alternative, user-friendly, text for the URL."""
107 return self._text
109 @text.setter
110 def text(self, value: str) -> None:
111 """Set the alternative, user-friendly, text for the URL."""
112 self._text = value
114 @property
115 def tip(self) -> str:
116 """Get the screen tip for the URL."""
117 return self._tip
119 @tip.setter
120 def tip(self, value: str) -> None:
121 """Set the screen tip for the URL."""
122 if _utf16_len(value) > self.MAX_PARAMETER_LEN:
123 raise ValueError("Hyperlink tool tip exceeds Excel's maximum length.")
124 self._tip = value
126 def _parse_url(self) -> None:
127 """Parse the URL and determine its type."""
129 # Handle mail address links.
130 if self._link.startswith("mailto:"):
131 self._link_type = UrlTypes.URL
133 if not self._text:
134 self._text = self._link.replace("mailto:", "", 1)
136 # Handle links to cells within the workbook.
137 elif self._link.startswith("internal:"):
138 self._link_type = UrlTypes.INTERNAL
139 self._relationship_link = self._link.replace("internal:", "#", 1)
140 self._link = self._link.replace("internal:", "", 1)
141 self._anchor = self._link
143 if not self._text:
144 self._text = self._anchor
146 # Handle links to other files or cells in other Excel files.
147 elif self._link.startswith("file://") or self._link.startswith("external:"):
148 self._link_type = UrlTypes.EXTERNAL
150 # Handle backward compatibility with external: links.
151 file_url = self._original_url.replace("external:", "file:///", 1)
153 link_path = file_url
154 link_path = link_path.replace("file:///", "", 1)
155 link_path = link_path.replace("file://", "", 1)
156 link_path = link_path.replace("/", "\\")
158 if self._is_relative_path(link_path):
159 self._link = link_path
160 else:
161 self._link = "file:///" + link_path
163 if not self._text:
164 self._text = link_path
166 if "#" in self._link:
167 self._link, self._anchor = self._link.split("#", 1)
169 # Set up the relationship link. This doesn't usually contain the
170 # anchor unless it is a link from an object like an image.
171 if self._is_object_link:
172 if self._is_relative_path(link_path):
173 self._relationship_link = self._link.replace("\\", "/")
174 else:
175 self._relationship_link = file_url
177 else:
178 self._relationship_link = self._link
180 # Convert a .\dir\file.xlsx link to dir\file.xlsx.
181 if self._relationship_link.startswith(".\\"):
182 self._relationship_link = self._relationship_link.replace(".\\", "", 1)
184 # Handle standard Excel links like http://, https://, ftp://, ftps://
185 # but also allow custom "foo://bar" URLs.
186 elif "://" in self._link:
187 self._link_type = UrlTypes.URL
189 if not self._text:
190 self._text = self._link
192 if "#" in self._link:
193 self._link, self._anchor = self._link.split("#", 1)
195 # Set up the relationship link. This doesn't usually contain the
196 # anchor unless it is a link from an object like an image.
197 if self._is_object_link:
198 self._relationship_link = self._original_url
199 else:
200 self._relationship_link = self._link
202 else:
203 raise ValueError(f"Unknown URL type: {self._original_url}")
205 def _set_object_link(self) -> None:
206 """
207 Set the _is_object_link flag and re-parse the URL since the relationship
208 link is different for object links.
210 """
211 self._is_object_link = True
212 self._link = self._original_url
213 self._parse_url()
214 self._escape_strings()
216 def _escape_strings(self) -> None:
217 """Escape special characters in the URL strings."""
219 if self._link_type != UrlTypes.INTERNAL:
220 self._link = self._escape_url(self._link)
221 self._relationship_link = self._escape_url(self._relationship_link)
223 # Excel additionally escapes # to %23 in file paths.
224 if self._link_type == UrlTypes.EXTERNAL:
225 self._relationship_link = self._relationship_link.replace("#", "%23")
227 def _target(self) -> str:
228 """Get the target for relationship IDs."""
229 return self._relationship_link
231 def _target_mode(self) -> str:
232 """Get the target mode for relationship IDs."""
233 if self._link_type == UrlTypes.INTERNAL:
234 return ""
236 return "External"
238 @staticmethod
239 def _is_relative_path(url: str) -> bool:
240 """Check if a URL is a relative path."""
241 if url.startswith(r"\\"):
242 return False
244 if url[0].isalpha() and url[1] == ":": # noqa: SIM103
245 return False
247 return True
249 @staticmethod
250 def _escape_url(url: str) -> str:
251 """Escape special characters in a URL."""
252 # Don't escape URL if it looks already escaped.
253 if re.search("%[0-9a-fA-F]{2}", url):
254 return url
256 # Can't use url.quote() here because it doesn't match Excel.
257 return (
258 url.replace("%", "%25")
259 .replace('"', "%22")
260 .replace(" ", "%20")
261 .replace("<", "%3c")
262 .replace(">", "%3e")
263 .replace("[", "%5b")
264 .replace("]", "%5d")
265 .replace("^", "%5e")
266 .replace("`", "%60")
267 .replace("{", "%7b")
268 .replace("}", "%7d")
269 )