unicode_encode_strings

Provides an example for how one could realize a string encoding pipeline for plugin resources.

unicode_encode_strings.py

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