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

126 statements  

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# 

9 

10 

11import re 

12from enum import Enum 

13from typing import Optional 

14 

15 

16class UrlTypes(Enum): 

17 """ 

18 Enum to represent different types of URLS. 

19 """ 

20 

21 UNKNOWN = 0 

22 URL = 1 

23 INTERNAL = 2 

24 EXTERNAL = 3 

25 

26 

27class Url: 

28 """ 

29 A class to represent URLs in Excel. 

30 

31 """ 

32 

33 MAX_URL_LEN = 2080 

34 MAX_PARAMETER_LEN = 255 

35 

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 

46 

47 self._parse_url() 

48 

49 if len(self._link) > self.MAX_URL_LEN: 

50 raise ValueError("URL exceeds Excel's maximum length.") 

51 

52 if len(self._anchor) > self.MAX_URL_LEN: 

53 raise ValueError("Anchor segment or url exceeds Excel's maximum length.") 

54 

55 if len(self._tip) > self.MAX_PARAMETER_LEN: 

56 raise ValueError("Hyperlink tool tip exceeds Excel's maximum length.") 

57 

58 self._escape_strings() 

59 

60 def __repr__(self): 

61 """ 

62 Return a string representation of the Url instance. 

63 

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 ) 

78 

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. 

85 

86 Args: 

87 options (dict): A dictionary that may contain a 'url' key. 

88 

89 Returns: 

90 url: A Url object or None. 

91 

92 """ 

93 if not isinstance(options, dict): 

94 raise TypeError("The 'options' parameter must be a dictionary.") 

95 

96 url = options.get("url") 

97 

98 if isinstance(url, str): 

99 url = cls(options["url"]) 

100 if options.get("tip"): 

101 url._tip = options["tip"] 

102 

103 return url 

104 

105 @property 

106 def text(self) -> str: 

107 """Get the alternative, user-friendly, text for the URL.""" 

108 return self._text 

109 

110 @text.setter 

111 def text(self, value: str): 

112 """Set the alternative, user-friendly, text for the URL.""" 

113 self._text = value 

114 

115 @property 

116 def tip(self) -> str: 

117 """Get the screen tip for the URL.""" 

118 return self._tip 

119 

120 @tip.setter 

121 def tip(self, value: str): 

122 """Set the screen tip for the URL.""" 

123 self._tip = value 

124 

125 def _parse_url(self): 

126 """Parse the URL and determine its type.""" 

127 

128 # Handle mail address links. 

129 if self._link.startswith("mailto:"): 

130 self._link_type = UrlTypes.URL 

131 

132 if not self._text: 

133 self._text = self._link.replace("mailto:", "", 1) 

134 

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 

141 

142 if not self._text: 

143 self._text = self._anchor 

144 

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 

148 

149 # Handle backward compatibility with external: links. 

150 file_url = self._original_url.replace("external:", "file:///", 1) 

151 

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("/", "\\") 

156 

157 if self._is_relative_path(link_path): 

158 self._link = link_path 

159 else: 

160 self._link = "file:///" + link_path 

161 

162 if not self._text: 

163 self._text = link_path 

164 

165 if "#" in self._link: 

166 self._link, self._anchor = self._link.split("#", 1) 

167 

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 

175 

176 else: 

177 self._relationship_link = self._link 

178 

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) 

182 

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 

187 

188 if not self._text: 

189 self._text = self._link 

190 

191 if "#" in self._link: 

192 self._link, self._anchor = self._link.split("#", 1) 

193 

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 

200 

201 else: 

202 raise ValueError(f"Unknown URL type: {self._original_url}") 

203 

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. 

208 

209 """ 

210 self._is_object_link = True 

211 self._link = self._original_url 

212 self._parse_url() 

213 self._escape_strings() 

214 

215 def _escape_strings(self): 

216 """Escape special characters in the URL strings.""" 

217 

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) 

221 

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") 

225 

226 def _target(self) -> str: 

227 """Get the target for relationship IDs.""" 

228 return self._relationship_link 

229 

230 def _target_mode(self) -> str: 

231 """Get the target mode for relationship IDs.""" 

232 if self._link_type == UrlTypes.INTERNAL: 

233 return "" 

234 

235 return "External" 

236 

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 

242 

243 if url[0].isalpha() and url[1] == ":": 

244 return False 

245 

246 return True 

247 

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 

254 

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 )