1""" 2User name to file name conversion. 3This was taken from the UFO 3 spec. 4""" 5 6# Restrictions are taken mostly from 7# https://docs.microsoft.com/en-gb/windows/win32/fileio/naming-a-file#naming-conventions. 8# 9# 1. Integer value zero, sometimes referred to as the ASCII NUL character. 10# 2. Characters whose integer representations are in the range 1 to 31, 11# inclusive. 12# 3. Various characters that (mostly) Windows and POSIX-y filesystems don't 13# allow, plus "(" and ")", as per the specification. 14illegalCharacters = { 15 "\x00", 16 "\x01", 17 "\x02", 18 "\x03", 19 "\x04", 20 "\x05", 21 "\x06", 22 "\x07", 23 "\x08", 24 "\t", 25 "\n", 26 "\x0b", 27 "\x0c", 28 "\r", 29 "\x0e", 30 "\x0f", 31 "\x10", 32 "\x11", 33 "\x12", 34 "\x13", 35 "\x14", 36 "\x15", 37 "\x16", 38 "\x17", 39 "\x18", 40 "\x19", 41 "\x1a", 42 "\x1b", 43 "\x1c", 44 "\x1d", 45 "\x1e", 46 "\x1f", 47 '"', 48 "*", 49 "+", 50 "/", 51 ":", 52 "<", 53 ">", 54 "?", 55 "[", 56 "\\", 57 "]", 58 "(", 59 ")", 60 "|", 61 "\x7f", 62} 63reservedFileNames = { 64 "aux", 65 "clock$", 66 "com1", 67 "com2", 68 "com3", 69 "com4", 70 "com5", 71 "com6", 72 "com7", 73 "com8", 74 "com9", 75 "con", 76 "lpt1", 77 "lpt2", 78 "lpt3", 79 "lpt4", 80 "lpt5", 81 "lpt6", 82 "lpt7", 83 "lpt8", 84 "lpt9", 85 "nul", 86 "prn", 87} 88maxFileNameLength = 255 89 90 91class NameTranslationError(Exception): 92 pass 93 94 95def userNameToFileName(userName: str, existing=(), prefix="", suffix=""): 96 """ 97 `existing` should be a set-like object. 98 99 >>> userNameToFileName("a") == "a" 100 True 101 >>> userNameToFileName("A") == "A_" 102 True 103 >>> userNameToFileName("AE") == "A_E_" 104 True 105 >>> userNameToFileName("Ae") == "A_e" 106 True 107 >>> userNameToFileName("ae") == "ae" 108 True 109 >>> userNameToFileName("aE") == "aE_" 110 True 111 >>> userNameToFileName("a.alt") == "a.alt" 112 True 113 >>> userNameToFileName("A.alt") == "A_.alt" 114 True 115 >>> userNameToFileName("A.Alt") == "A_.A_lt" 116 True 117 >>> userNameToFileName("A.aLt") == "A_.aL_t" 118 True 119 >>> userNameToFileName(u"A.alT") == "A_.alT_" 120 True 121 >>> userNameToFileName("T_H") == "T__H_" 122 True 123 >>> userNameToFileName("T_h") == "T__h" 124 True 125 >>> userNameToFileName("t_h") == "t_h" 126 True 127 >>> userNameToFileName("F_F_I") == "F__F__I_" 128 True 129 >>> userNameToFileName("f_f_i") == "f_f_i" 130 True 131 >>> userNameToFileName("Aacute_V.swash") == "A_acute_V_.swash" 132 True 133 >>> userNameToFileName(".notdef") == "_notdef" 134 True 135 >>> userNameToFileName("con") == "_con" 136 True 137 >>> userNameToFileName("CON") == "C_O_N_" 138 True 139 >>> userNameToFileName("con.alt") == "_con.alt" 140 True 141 >>> userNameToFileName("alt.con") == "alt._con" 142 True 143 """ 144 # the incoming name must be a string 145 if not isinstance(userName, str): 146 raise ValueError("The value for userName must be a string.") 147 # establish the prefix and suffix lengths 148 prefixLength = len(prefix) 149 suffixLength = len(suffix) 150 # replace an initial period with an _ 151 # if no prefix is to be added 152 if not prefix and userName[0] == ".": 153 userName = "_" + userName[1:] 154 # filter the user name 155 filteredUserName = [] 156 for character in userName: 157 # replace illegal characters with _ 158 if character in illegalCharacters: 159 character = "_" 160 # add _ to all non-lower characters 161 elif character != character.lower(): 162 character += "_" 163 filteredUserName.append(character) 164 userName = "".join(filteredUserName) 165 # clip to 255 166 sliceLength = maxFileNameLength - prefixLength - suffixLength 167 userName = userName[:sliceLength] 168 # test for illegal files names 169 parts = [] 170 for part in userName.split("."): 171 if part.lower() in reservedFileNames: 172 part = "_" + part 173 parts.append(part) 174 userName = ".".join(parts) 175 # test for clash 176 fullName = prefix + userName + suffix 177 if fullName.lower() in existing: 178 fullName = handleClash1(userName, existing, prefix, suffix) 179 # finished 180 return fullName 181 182 183def handleClash1(userName, existing=[], prefix="", suffix=""): 184 """ 185 existing should be a case-insensitive list 186 of all existing file names. 187 188 >>> prefix = ("0" * 5) + "." 189 >>> suffix = "." + ("0" * 10) 190 >>> existing = ["a" * 5] 191 192 >>> e = list(existing) 193 >>> handleClash1(userName="A" * 5, existing=e, 194 ... prefix=prefix, suffix=suffix) == ( 195 ... '00000.AAAAA000000000000001.0000000000') 196 True 197 198 >>> e = list(existing) 199 >>> e.append(prefix + "aaaaa" + "1".zfill(15) + suffix) 200 >>> handleClash1(userName="A" * 5, existing=e, 201 ... prefix=prefix, suffix=suffix) == ( 202 ... '00000.AAAAA000000000000002.0000000000') 203 True 204 205 >>> e = list(existing) 206 >>> e.append(prefix + "AAAAA" + "2".zfill(15) + suffix) 207 >>> handleClash1(userName="A" * 5, existing=e, 208 ... prefix=prefix, suffix=suffix) == ( 209 ... '00000.AAAAA000000000000001.0000000000') 210 True 211 """ 212 # if the prefix length + user name length + suffix length + 15 is at 213 # or past the maximum length, silce 15 characters off of the user name 214 prefixLength = len(prefix) 215 suffixLength = len(suffix) 216 if prefixLength + len(userName) + suffixLength + 15 > maxFileNameLength: 217 l = prefixLength + len(userName) + suffixLength + 15 218 sliceLength = maxFileNameLength - l 219 userName = userName[:sliceLength] 220 finalName = None 221 # try to add numbers to create a unique name 222 counter = 1 223 while finalName is None: 224 name = userName + str(counter).zfill(15) 225 fullName = prefix + name + suffix 226 if fullName.lower() not in existing: 227 finalName = fullName 228 break 229 else: 230 counter += 1 231 if counter >= 999999999999999: 232 break 233 # if there is a clash, go to the next fallback 234 if finalName is None: 235 finalName = handleClash2(existing, prefix, suffix) 236 # finished 237 return finalName 238 239 240def handleClash2(existing=[], prefix="", suffix=""): 241 """ 242 existing should be a case-insensitive list 243 of all existing file names. 244 245 >>> prefix = ("0" * 5) + "." 246 >>> suffix = "." + ("0" * 10) 247 >>> existing = [prefix + str(i) + suffix for i in range(100)] 248 249 >>> e = list(existing) 250 >>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == ( 251 ... '00000.100.0000000000') 252 True 253 254 >>> e = list(existing) 255 >>> e.remove(prefix + "1" + suffix) 256 >>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == ( 257 ... '00000.1.0000000000') 258 True 259 260 >>> e = list(existing) 261 >>> e.remove(prefix + "2" + suffix) 262 >>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == ( 263 ... '00000.2.0000000000') 264 True 265 """ 266 # calculate the longest possible string 267 maxLength = maxFileNameLength - len(prefix) - len(suffix) 268 maxValue = int("9" * maxLength) 269 # try to find a number 270 finalName = None 271 counter = 1 272 while finalName is None: 273 fullName = prefix + str(counter) + suffix 274 if fullName.lower() not in existing: 275 finalName = fullName 276 break 277 else: 278 counter += 1 279 if counter >= maxValue: 280 break 281 # raise an error if nothing has been found 282 if finalName is None: 283 raise NameTranslationError("No unique name could be found.") 284 # finished 285 return finalName 286 287 288if __name__ == "__main__": 289 import doctest 290 291 doctest.testmod() 292