# -*- coding: utf-8 -*- """DokoGen — Склонение (pymorphy3 + правила для ФИО/организаций)""" from functools import lru_cache from typing import Dict import re try: import pymorphy3 morph = pymorphy3.MorphAnalyzer() MORPH_AVAILABLE = True except ImportError: morph = None MORPH_AVAILABLE = False CASES = ["nomn", "gent", "datv", "accs", "ablt", "loct"] CASE_MAP = { "nominative": "nomn", "им": "nomn", "genitive": "gent", "род": "gent", "dative": "datv", "дат": "datv", "accusative": "accs", "вин": "accs", "instrumental": "ablt", "твор": "ablt", "prepositional": "loct", "пр": "loct", } CASE_NAMES = { "nomn": "Именительный (Кто? Что?)", "gent": "Родительный (Кого? Чего?)", "datv": "Дательный (Кому? Чему?)", "accs": "Винительный (Кого? Что?)", "ablt": "Творительный (Кем? Чем?)", "loct": "Предложный (О ком? О чём?)", } CASE_SHORT = { "nomn": "им.п.", "gent": "род.п.", "datv": "дат.п.", "accs": "вин.п.", "ablt": "твор.п.", "loct": "пр.п.", } def _normalize_case(case: str) -> str: return CASE_MAP.get(case, "nomn" if case not in CASES else case) def inflect_name(name: str) -> Dict[str, str]: """Склонение названия организации по всем падежам.""" return {gr: company_name_decline(name, gr) for gr in CASES} @lru_cache(maxsize=512) def company_name_decline(name: str, case: str) -> str: """Склонение названия организации.""" if not name: return "" case_gr = _normalize_case(case) if case_gr == "nomn": return name m = re.match(r'^(.+?)\s*([\u00ab\"].*[\u00bb\"])$', name.strip()) if m: prefix = m.group(1).strip() quoted = m.group(2) if prefix: return decline(prefix, case_gr, decline_all=True) + " " + quoted return name return decline(name, case_gr, decline_all=True) @lru_cache(maxsize=512) def decline(text: str, case: str, decline_all: bool = False) -> str: """Склонение строки (ФИО, фразы) по падежам.""" if not text: return "" case_gr = _normalize_case(case) if case_gr == "nomn": return text clean_text, quoted = _extract_quoted(text) tokens = re.split(r"(\s+)", clean_text) content_tokens = [t for t in tokens if not t.isspace() and t.strip()] only_first = False if not decline_all and len(content_tokens) >= 2: for ct in content_tokens[1:]: if ct[0].islower(): only_first = True break result_parts = [] content_idx = 0 for token in tokens: if token.isspace() or not token.strip(): result_parts.append(token) continue stripped = token.strip() if only_first and content_idx > 0: result_parts.append(token) content_idx += 1 continue if _is_quoted(stripped): result_parts.append(token) content_idx += 1 continue if stripped.isupper() and len(stripped) > 1: result_parts.append(token) content_idx += 1 continue inflected = _inflect_word(stripped, case_gr) if stripped[0].isupper(): inflected = inflected[0].upper() + inflected[1:] result_parts.append(inflected) content_idx += 1 return _restore_quoted("".join(result_parts), quoted) def _inflect_word(word: str, case_gr: str) -> str: """Склонение одного слова через pymorphy3 с fallback на ручные правила.""" if not word or len(word) < 2 or case_gr == "nomn": return word if not MORPH_AVAILABLE or morph is None: return _rules_decline(word, case_gr) or word try: parsed = morph.parse(word) if not parsed: return _rules_decline(word, case_gr) or word best = parsed[0] inflected = best.inflect({case_gr}) if inflected: return inflected.word return _rules_decline(word, case_gr) or word except Exception: return _rules_decline(word, case_gr) or word def _rules_decline(word: str, case_gr: str) -> str: """Ручное склонение фамилий.""" w = word.lower() # Мужские фамилии на -ов/-ев/-ёв if w.endswith(('ов', 'ев', 'ёв')) and not w.endswith(('ова', 'ева', 'ёва')): return { 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', 'ablt': word + 'ым', 'loct': word + 'е' }.get(case_gr, word) # Мужские фамилии на -ин if w.endswith('ин') and not w.endswith('ина'): return { 'gent': word + 'а', 'datv': word + 'у', 'accs': word + 'а', 'ablt': word + 'ым', 'loct': word + 'е' }.get(case_gr, word) # Женские фамилии на -ова/-ева/-ёва if w.endswith(('ова', 'ева', 'ёва')): stem = word[:-1] return { 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', 'ablt': stem + 'ой', 'loct': stem + 'ой' }.get(case_gr, word) # Женские фамилии на -ина if w.endswith('ина'): stem = word[:-1] return { 'gent': stem + 'ой', 'datv': stem + 'ой', 'accs': stem + 'у', 'ablt': stem + 'ой', 'loct': stem + 'ой' }.get(case_gr, word) return word def _extract_quoted(text: str): """Извлекает подстроки в кавычках, заменяет плейсхолдерами.""" if not text: return text, {} pattern = re.compile(r'(\u00ab[^\u00bb]*\u00bb|"[^"]*")') placeholders = {} counter = [0] def _replace(m): ph = f'__Q_{counter[0]}__' placeholders[ph] = m.group(0) counter[0] += 1 return ph return pattern.sub(_replace, text), placeholders def _restore_quoted(text: str, placeholders: dict) -> str: for ph, original in placeholders.items(): text = text.replace(ph, original) return text def _is_quoted(word: str) -> bool: w = word.strip() if not w: return False return (w.startswith("\u00ab") and w.endswith("\u00bb")) or \ (w.startswith('"') and w.endswith('"')) # ==================== ФОРМАТИРОВАНИЕ ФИО ==================== def _initial(word: str) -> str: """Инициал из слова: если уже с точкой — вернуть как есть, иначе первая буква + точка. «В.Н.» → «В.Н.», «Владимир» → «В.»""" w = word.strip() if not w: return '' if w.endswith('.'): return w return w[0] + '.' def get_short_fio(fio: str) -> str: """Фамилия И.О.""" if not fio: return "" parts = fio.split() if len(parts) >= 2: initials = _initial(parts[1]) if len(parts) >= 3: initials += _initial(parts[2]) return parts[0] + " " + initials return fio def get_initials(fio: str) -> str: """И.О. Фамилия""" if not fio: return "" parts = fio.split() if len(parts) >= 2: initials = _initial(parts[1]) if len(parts) >= 3: initials += _initial(parts[2]) return initials + " " + parts[0] return fio