xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ufoLib/filenames.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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