Add locale-aware encoding checks and character fallbacks to coerce cleaned UTF-8 text into the preferred system encoding when needed. This transliterates unsupported characters to readable equivalents so Windows charmap writes do not fail on non-UTF-8 code pages.fix: transliterate text for non-UTF-8 system encodings Add locale-aware encoding checks and character fallbacks to coerce cleaned UTF-8 text into the preferred system encoding when needed. This transliterates unsupported characters to readable equivalents so Windows charmap writes do not fail on non-UTF-8 code pages.
181 lines
4.9 KiB
Python
181 lines
4.9 KiB
Python
# utf8_processor.py
|
||
import locale
|
||
import unicodedata
|
||
from typing import Tuple
|
||
|
||
# Common typography/invisible-character cleanup while preserving valid Unicode
|
||
# letters such as á, é, í, ó, ú, ç, etc.
|
||
_CHAR_REPLACEMENTS = str.maketrans(
|
||
{
|
||
ord("…"): "...",
|
||
ord("–"): "-",
|
||
ord("—"): "-",
|
||
ord("−"): "-",
|
||
ord("‐"): "-",
|
||
ord("‑"): "-",
|
||
ord("“"): '"',
|
||
ord("”"): '"',
|
||
ord("„"): '"',
|
||
ord("«"): '"',
|
||
ord("»"): '"',
|
||
ord("‘"): "'",
|
||
ord("’"): "'",
|
||
ord("‚"): "'",
|
||
ord("´"): "'",
|
||
ord("`"): "'",
|
||
ord("•"): "-",
|
||
ord("·"): "-",
|
||
ord("→"): "->",
|
||
ord("←"): "<-",
|
||
ord("↔"): "<->",
|
||
ord("⇒"): "=>",
|
||
ord("⇐"): "<=",
|
||
ord("⇔"): "<=>",
|
||
ord("\u00A0"): " ",
|
||
ord("\u202F"): " ",
|
||
ord("\u2007"): " ",
|
||
ord("\u2009"): " ",
|
||
ord("\u200B"): "",
|
||
ord("\u200C"): "",
|
||
ord("\u200D"): "",
|
||
ord("\u2060"): "",
|
||
ord("\uFEFF"): "",
|
||
ord("\u00AD"): "",
|
||
}
|
||
)
|
||
|
||
_ALLOWED_WHITESPACE = {"\n", "\t"}
|
||
|
||
_ENCODING_FALLBACKS = {
|
||
"ß": "ss",
|
||
"ẞ": "SS",
|
||
"æ": "ae",
|
||
"Æ": "AE",
|
||
"œ": "oe",
|
||
"Œ": "OE",
|
||
"ø": "o",
|
||
"Ø": "O",
|
||
"ð": "d",
|
||
"Ð": "D",
|
||
"þ": "th",
|
||
"Þ": "TH",
|
||
"ł": "l",
|
||
"Ł": "L",
|
||
"đ": "d",
|
||
"Đ": "D",
|
||
"ı": "i",
|
||
"IJ": "IJ",
|
||
"ij": "ij",
|
||
}
|
||
|
||
|
||
def _can_encode(text: str, encoding: str) -> bool:
|
||
try:
|
||
text.encode(encoding)
|
||
return True
|
||
except UnicodeEncodeError:
|
||
return False
|
||
|
||
|
||
def _transliterate_for_encoding(char: str, encoding: str) -> str:
|
||
direct_fallback = _ENCODING_FALLBACKS.get(char)
|
||
if direct_fallback and _can_encode(direct_fallback, encoding):
|
||
return direct_fallback
|
||
|
||
# Decompose accented characters (e.g. ă -> a + combining breve), then
|
||
# drop the combining marks so Windows non-UTF-8 code pages can still
|
||
# encode the nearest readable equivalent.
|
||
decomposed = unicodedata.normalize("NFKD", char)
|
||
stripped = "".join(part for part in decomposed if not unicodedata.combining(part))
|
||
|
||
if stripped and _can_encode(stripped, encoding):
|
||
return stripped
|
||
|
||
return ""
|
||
|
||
|
||
def _coerce_to_preferred_encoding(text: str) -> str:
|
||
preferred_encoding = locale.getpreferredencoding(False) or "utf-8"
|
||
|
||
if preferred_encoding.lower().replace("-", "") == "utf8":
|
||
return text
|
||
|
||
if _can_encode(text, preferred_encoding):
|
||
return text
|
||
|
||
safe_chars = []
|
||
for char in text:
|
||
if char in _ALLOWED_WHITESPACE:
|
||
safe_chars.append(char)
|
||
continue
|
||
|
||
if _can_encode(char, preferred_encoding):
|
||
safe_chars.append(char)
|
||
continue
|
||
|
||
safe_chars.append(_transliterate_for_encoding(char, preferred_encoding))
|
||
|
||
return "".join(safe_chars)
|
||
|
||
|
||
def _clean_utf8_text(text: str) -> str:
|
||
normalized = unicodedata.normalize("NFC", text)
|
||
normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
|
||
normalized = normalized.translate(_CHAR_REPLACEMENTS)
|
||
|
||
# Drop any invalid surrogate data while keeping valid UTF-8/Unicode text.
|
||
utf8_safe = normalized.encode("utf-8", errors="ignore").decode("utf-8")
|
||
|
||
cleaned_chars = []
|
||
for char in utf8_safe:
|
||
if char in _ALLOWED_WHITESPACE:
|
||
cleaned_chars.append(char)
|
||
continue
|
||
|
||
# Remove control/format/private-use/surrogate chars, keep visible Unicode.
|
||
if unicodedata.category(char).startswith("C"):
|
||
continue
|
||
|
||
cleaned_chars.append(char)
|
||
|
||
cleaned_text = "".join(cleaned_chars)
|
||
return _coerce_to_preferred_encoding(cleaned_text)
|
||
|
||
|
||
class UTF8Processor:
|
||
"""
|
||
Cleans text into a UTF-8-safe string while preserving valid Unicode letters.
|
||
Replaces common smart punctuation with simpler equivalents and removes
|
||
invisible/control characters. Also normalizes common arrow symbols to
|
||
ASCII equivalents and converts common non-breaking/thin spaces to regular
|
||
spaces. On systems whose default text encoding is not UTF-8 (common on
|
||
Windows), unsupported characters are transliterated to the closest
|
||
encodable equivalent so downstream charmap writes do not fail.
|
||
"""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(cls):
|
||
return {
|
||
"required": {
|
||
"text": ("STRING", {"multiline": True, "default": ""}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("STRING",)
|
||
RETURN_NAMES = ("clean_text",)
|
||
FUNCTION = "process"
|
||
CATEGORY = "Text/Utils"
|
||
OUTPUT_NODE = False
|
||
|
||
def process(self, text: str) -> Tuple[str]:
|
||
if text is None:
|
||
text = ""
|
||
if not isinstance(text, str):
|
||
text = str(text)
|
||
|
||
return (_clean_utf8_text(text),)
|
||
|
||
@classmethod
|
||
def IS_CHANGED(cls, **kwargs) -> float:
|
||
text = kwargs.get("text", "") or ""
|
||
return float(len(text)) |