Repository navigation
Expand file tree
/
Copy pathunicode_encode_strings.py
More file actions
376 lines (311 loc) · 15.1 KB
/
Copy pathunicode_encode_strings.py
File metadata and controls
376 lines (311 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""Provides an example for how one could realize a string encoding pipeline for plugin resources.
Warning:
This is NOT a product in any sense, it is an EXAMPLE. Please USE IT AT YOUR OWN RISK. We cannot
guarantee that it will work for all use-cases or add features that you might need.
Note:
This script can be run with any Python interpreter that supports the standard library; you do
not need to run it in Cinema 4D or with c4dpy.
The idea is simple: users write the plugin resources in Unicode, e.g., use 'ä' or 'é' in their
string files, and this script encodes these Unicode characters to their escape sequences in ASCII.
The script can be used to encode, decode, restore, or clean up string files in a given directory. It
does not matter if these string files are used by dialogs, descriptions, or other parts of a
Cinema 4D plugin, as long as they end in ".str".
The script will create backups of the original files before encoding them, when asked to do so. It
will also create a log file in the same directory as the script.
The script can be run without any arguments; it will then use the 'res' directory next to the
script and ask the user for details, or it can be run with command line arguments. The four modes
are:
- 'e': This will encode all Unicode characters in the string files to their escape
sequences in ASCII, e.g., "ä" becomes "\u00e4".
- 'd': This will decode all escape sequences in the string files back to their original
Unicode characters.
- 'r': This will restore all string files from their backups, which are expected to be
in the same directory as the original files, with the ".bak" suffix.
- 'c': This will clean (delete) all backup files in the given directory, which are expected to
have the ".str.bak" suffix.
The command line arguments syntax is as follows:
usage: unicode_encode_strings.py [-h] [--path [PATH]] [--mode {e,d,r,c}] [--verbose]
optional arguments:
-h, --help show this help message and exit
--path [PATH] The path to the directory containing the files to process. If not provided,
defaults to the 'res' directory in the script's location.
--mode {e,d,r,c} Mode: 'e' for encode, 'd' for decode, 'r' for restore, 'c' for clean.
Default is 'e'.
--verbose Enable verbose output. Default is False (no verbose output).
Examples:
Encode all .str files in the default 'res' directory next to this script:
python unicode_encode_strings.py --mode e
Decode all .str files in a custom absolute directory:
python unicode_encode_strings.py --mode d --path /path/to/res
Decode all .str files in a custom directory relative to this script:
python unicode_encode_strings.py --mode d --path ./res
Restore .str files in the default 'res' directory from possibly existing backups:
python unicode_encode_strings.py --mode r
Clean (delete) all .str.bak files in a custom directory:
python unicode_encode_strings.py --mode c --path /path/to/res
Enable verbose output while encoding .str files in the default 'res' directory:
python unicode_encode_strings.py --mode e --verbose
Topics:
* plugins
"""
__author__ = "Ferdinand Hoppe"
__copyright__ = "Copyright (C) 2026 MAXON Computer GmbH"
__license__ = "Apache-2.0 License"
import argparse
import os
import re
import sys
import time
def EncodeStringFiles(path: str, createBackups: bool = True, verboseOutput: bool = True) -> None:
"""Escapes all unicode characters in string files in #path to their escape sequences in ASCII.
Args:
path (str): The path to the directory containing the files to encode.
createBackups (bool): If True, creates a backup of each file before encoding. A file is
only backed up once, I.e., existing backups will not be overwritten.
"""
# Walk through the directory and its subdirectories.
for root, _, files in os.walk(path):
for filename in files:
# Only process files with the .str extension.
if not filename.endswith(".str"):
continue
fPath: str = os.path.join(root, filename)
try:
os.chmod(fPath, 0o600) # Read/write for user only
except Exception as e:
print(f"Error setting permissions for {fPath}: {e}")
content: str = ""
with open(fPath, "r", encoding="utf-8") as f:
content = f.read()
if not content:
print(f"Warning: The file {fPath} is empty or could not be read.")
continue
# Python's `string.encode("unicode_escape")` will yield hexadecimal escape sequences
# (e.g. "\xXX"), but our C++ API expects unicode escape sequences (e.g. "\uXXXX"). I do
# not see a way how Python can do this out of the box, so we use this little function.
def EscapeUnicode(item: str) -> str:
return re.sub(r"([^\x00-\x7f])",
lambda m: "\\u{:04x}".format(ord(m.group(1))), item
)
# Escape all unicode characters in the content, e.g. "ä" becomes "\u00e4".
newContent: str = EscapeUnicode(content)
if newContent == content:
continue
bakPath: str = fPath + ".bak"
if createBackups and not os.path.exists(bakPath):
try:
with open(bakPath, "w", encoding="utf-8") as f:
f.write(content)
print(f"Created backup for {fPath} at {bakPath}.")
except Exception as e:
print(f"Error creating backup for {fPath}: {e}")
with open(fPath, "w", encoding="ascii") as f:
f.write(newContent)
if verboseOutput:
print(f"Encoded {fPath} to ASCII.")
print("-" * 120)
print("\nOriginal content:\n")
print(content)
print("\nEscaped content:\n")
print(newContent)
else:
print(f"Encoded {fPath} to ASCII.")
def DecodeStringFiles(path: str, verboseOutput: bool = True) -> None:
"""Unescapes all escape sequences in string files in #path back to their unicode characters.
This is the reverse operation of `EncodeStringFiles`. It will decode escape sequences like
"\u00e4" back to their original unicode characters like "ä".
Args:
path (str): The path to the directory containing the files to decode.
"""
for root, _, files in os.walk(path):
for filename in files:
if filename.endswith(".str"):
fPath: str = os.path.join(root, filename)
try:
with open(fPath, "r", encoding="utf-8") as f:
content = f.read()
# Decode the escape sequences back to unicode characters.
decodedContent: str = content.encode("utf-8").decode("unicode_escape")
if decodedContent == content:
continue
with open(fPath, "w", encoding="utf-8") as f:
f.write(decodedContent)
if verboseOutput:
print(f"Decoded {fPath} from ASCII.")
print("-" * 120)
print("\nOriginal content:\n")
print(content)
print("\nDecoded content:\n")
print(decodedContent)
else:
print(f"Decoded {fPath} from ASCII.")
except Exception as e:
print(f"Error decoding {fPath}: {e}")
def RestoreFilesFromBackups(path: str) -> None:
"""Restores all files from their backups in the given path.
Args:
path (str): The path to the directory containing the files to restore.
"""
for root, _, files in os.walk(path):
for filename in files:
if filename.endswith(".str.bak"):
fPath: str = os.path.join(root, filename)
originalPath: str = fPath[:-4] # Remove the ".bak" suffix
try:
with open(fPath, "r", encoding="utf-8") as f:
content = f.read()
with open(originalPath, "w", encoding="utf-8") as f:
f.write(content)
os.remove(fPath) # Remove the backup file after restoring
print(f"Restored {originalPath} from backup.")
except Exception as e:
print(f"Error restoring {originalPath} from backup: {e}")
def DeleteBackupFiles(path: str) -> None:
"""Deletes all backup files in the given path.
Args:
path (str): The path to the directory containing the backup files to delete.
"""
print(
f"Warning: This will delete all string backup files (*.str.bak) in the directory '{path}'.")
res: str = input("Do you want to continue (y/n): ").strip().lower()
if res != 'y':
print("Operation cancelled.")
return
for root, _, files in os.walk(path):
for filename in files:
if filename.endswith(".str.bak"):
fPath: str = os.path.join(root, filename)
try:
os.remove(fPath)
print(f"Deleted backup file: {fPath}")
except Exception as e:
print(f"Error deleting {fPath}: {e}")
def main() -> None:
"""Runs the script.
"""
# The three inputs for all actions.
resPath: str = ""
mode: str = ""
verboseMode: bool = False
# Handle logging
class LogHandler:
"""Realizes a simple dual channel (stdin/stdout + file) log handler, because doing this
with the logging module of Python is unnecessarily complicated.
"""
def __init__(self, stream: str):
if stream == "out":
self.stream = sys.stdout
sys.stdout = self
elif stream == "err":
self.stream = sys.stderr
sys.stderr = self
else:
raise ValueError("Stream must be 'out' or 'err'.")
name: str = os.path.splitext(os.path.basename(__file__))[0]
self.log: str = os.path.join(os.path.dirname(__file__), f"{name}.log")
with open(self.log, "a", encoding="utf-8") as logFile:
if stream == "out":
logFile.write("=" * 120 + "\n")
logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - "
"Opened new '{stream}' log handler.\n")
if stream == "err":
logFile.write("=" * 120 + "\n")
def write(self, message: str) -> None:
"""Write a message to the stream and log it.
"""
if message.strip():
self.stream.write(f"{message.strip()}\n")
try:
with open(self.log, "a", encoding="utf-8") as logFile:
logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} - {message.strip()}\n")
except Exception as e:
pass
def flush(self) -> None:
"""Flush the stream.
"""
if hasattr(self.stream, 'flush'):
self.stream.flush()
# Attach our log handler to both stdout and stderr.
handlers: list[LogHandler] = [
LogHandler("out"),
LogHandler("err")
]
# Attempt to parse the command line arguments, if there are any.
if len(sys.argv) > 1:
parser = argparse.ArgumentParser(
description="Encode, decode, or restore string files in a directory.")
parser.add_argument(
"--path", type=str, nargs='?', default="",
help=("The path to the directory containing the files to process. If not provided, "
"defaults to the 'res' directory in the script's location."))
parser.add_argument(
"--mode", type=str, choices=['e', 'd', 'r', 'c'], default='e',
help="Mode: 'e' for encode, 'd' for decode, 'r' for restore, 'c' for clean. Default is 'e'.")
parser.add_argument(
"--verbose", action='store_true', default=False,
help="Enable verbose output. Default is False (no verbose output).")
args = parser.parse_args()
resPath = args.path
if resPath == "":
resPath = os.path.join(os.path.dirname(__file__), "res")
args.path = resPath
mode = args.mode
verboseMode = args.verbose
print(f"Running with arguments: {args}")
print("-" * 120)
# Query the user instead.
else:
resPath = os.path.join(os.path.dirname(__file__), "res")
if not os.path.isabs(resPath):
resPath = os.path.abspath(resPath)
if not os.path.exists(resPath):
raise ValueError(f"The path '{resPath}' does not exist.")
print(f"Using resource path: {resPath}")
print("This script will encode or decode *.str files, restore therm from backups, or clean "
"(delete) all backup files in a 'res' directory.")
for i in range(3):
mode = input(
"Enter mode, 'e' for encode, 'd' for decode, 'r' for restore, 'c' for clean: ").strip().lower()
if mode not in ("e", "d", "r", "c"):
print("Invalid mode. Please enter 'e', 'd', 'r', or 'c'.")
else:
break
if mode in ("e", "d"):
for i in range(3):
verbose: str = input(
"Verbose output? (y/n): ").strip().lower()
if verbose not in ("y", "n"):
print("Invalid input. Please enter 'y' or 'n'.")
else:
verboseMode = verbose == 'y'
break
if mode not in ("e", "d", "r", "c"):
raise ValueError("Invalid mode. Please enter 'e', 'd', 'r', or 'c'.")
# Sort out all the path issues.
if not isinstance(resPath, str):
raise ValueError("The path must be a valid string.")
if not os.path.isabs(resPath):
cwd = os.getcwd()
os.chdir(os.path.dirname(__file__))
resPath = os.path.abspath(resPath)
os.chdir(cwd)
print(f"Using absolute path: {resPath}")
if not os.path.exists(resPath):
raise ValueError(f"The path '{resPath}' does not exist.")
if not os.path.isdir(resPath):
raise ValueError(f"The path '{resPath}' is not a directory.")
if not resPath.endswith("res"):
raise ValueError(
f"The path '{resPath}' does not end with 'res'. This script is intended for "
"encoding plugin resources, which should be in a directory ending with 'res'.")
# Run the appropriate function based on the mode.
if mode == 'e':
EncodeStringFiles(resPath, createBackups=True, verboseOutput=verboseMode)
elif mode == 'd':
DecodeStringFiles(resPath, verboseOutput=verboseMode)
elif mode == 'r':
RestoreFilesFromBackups(resPath)
elif mode == 'c':
DeleteBackupFiles(resPath)
if __name__ == "__main__":
main()