نرمال‌سازی متن فارسی

کد پایتون برای پاک‌سازی متن فارسی: یکسان‌سازی «ی» و «ک»، اصلاح نیم‌فاصله و رقم‌ها و حذف اعراب، همراه با آزمون‌های کوتاه.

کد · ۱۳ آبان ۱۴۰۵

python
import re
from typing import Literal, TypedDict


class NormalizeOptions(TypedDict, total=False):
    digits: Literal["persian", "arabic", "latin", "keep"]
    remove_diacritics: bool
    normalize_spacing: bool
    normalize_zwnj: bool


_DIGIT_SETS = {
    "persian": "۰۱۲۳۴۵۶۷۸۹",
    "arabic": "٠١٢٣٤٥٦٧٨٩",
    "latin": "0123456789",
}
_DIACRITICS = re.compile(r"[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06ed]")


def normalize_persian(text: str, options: NormalizeOptions | None = None) -> str:
    """متن فارسی را با گزینه‌های قابل تنظیم یکدست کند.

    نویسه‌های عربی «ي» و «ك» همیشه به شکل فارسی تبدیل می‌شوند.
    گزینهٔ digits مقصد رقم‌ها را مشخص می‌کند: persian، arabic، latin یا
    keep؛ مقدار پیش‌فرض persian است. گزینه‌های remove_diacritics،
    normalize_spacing و normalize_zwnj همگی به‌طور پیش‌فرض فعال‌اند.
    اصلاح نیم‌فاصله فقط برای الگوهای رایج و با یک قاعدهٔ ساده انجام
    می‌شود و جایگزین تحلیل زبانی کامل نیست.
    """
    opts = options or {}
    digit_target = opts.get("digits", "persian")
    if digit_target not in (*_DIGIT_SETS, "keep"):
        raise ValueError("مقدار گزینهٔ digits معتبر نیست")

    result = text.translate(str.maketrans({"ي": "ی", "ك": "ک"}))
    if digit_target != "keep":
        target = _DIGIT_SETS[digit_target]
        for source in _DIGIT_SETS.values():
            result = result.translate(str.maketrans(source, target))

    if opts.get("remove_diacritics", True):
        result = _DIACRITICS.sub("", result)

    if opts.get("normalize_spacing", True):
        # فاصله‌های افقی یکدست می‌شوند؛ مرز خط‌ها حفظ می‌شود.
        result = re.sub(r"[^\S\r\n]+", " ", result)
        result = re.sub(r" *\n *", "\n", result)
        result = re.sub(r" *\u200c *", "\u200c", result)
        result = result.strip()

    if opts.get("normalize_zwnj", True):
        # برای پرهیز از تغییر واژهٔ مستقل «می»، فقط فعل‌های متداول هدف‌اند.
        verbs = r"(?:شود|شوند|شد|شدم|شدیم|شدی|شدید|کنم|کنی|کند|کنیم|کنید|کنند|کرد|کردم|کردیم|رود|روم|روی|رویم|روید|روند|آید|آیند|تواند|توانند|خواهم|خواهد|دانم|داند|دانند|بینم|بیند|بینند|گوید|گویند)"
        result = re.sub(r"(?<![\w\u200c])(ن?می)[ \t]+(" + verbs + r")(?![\w\u200c])", r"\1\u200c\2".replace(r"\u200c", "\u200c"), result)
        # «ها» به واژهٔ پیشین می‌چسبد؛ ادامه‌های رایج جمع نیز پشتیبانی می‌شوند.
        result = re.sub(r"([\u0621-\u063a\u0641-\u064a\u0671-\u06d3])[ \t]+(ها(?:ی(?:م|ت|ش|مان|تان|شان)?|یم|یت|یش|مان|تان|شان)?)(?![\w\u200c])", r"\1\u200c\2".replace(r"\u200c", "\u200c"), result)
    return result


if __name__ == "__main__":
    assert normalize_persian("  كتاب ها  123 ٤٥٦  ") == "کتاب‌ها ۱۲۳ ۴۵۶"
    assert normalize_persian("او می شود و آنها نمی کنند") == "او می‌شود و آنها نمی‌کنند"
    assert normalize_persian("خانه های زیبا") == "خانه‌های زیبا"
    assert normalize_persian("عَلِی", {"remove_diacritics": False}) == "عَلِی"
    assert normalize_persian("عَلِی") == "علی"
    assert normalize_persian("۱۲٣4", {"digits": "latin"}) == "1234"
    assert normalize_persian("۱۲3", {"digits": "arabic"}) == "١٢٣"
    assert normalize_persian("۱۲3", {"digits": "keep"}) == "۱۲3"
    assert normalize_persian("می شود", {"normalize_zwnj": False}) == "می شود"
    assert normalize_persian("الف  ب", {"normalize_spacing": False}) == "الف  ب"
    assert normalize_persian("می\u200c شود\n  کتاب ها") == "می‌شود\nکتاب‌ها"
    assert normalize_persian(normalize_persian("كتاب ها می شود")) == "کتاب‌ها می‌شود"
    print("همهٔ آزمون‌ها با موفقیت گذشتند.")