Чистый генератор документов 152-ФЗ. Только нужные переменные, без мусора от 187-ФЗ/117-ФСТЭК. Модели, склонение, генератор DOCX, tkinter GUI, импорт из Excel.
223 lines
7.1 KiB
Python
223 lines
7.1 KiB
Python
# -*- 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 get_short_fio(fio: str) -> str:
|
||
"""Фамилия И.О."""
|
||
if not fio:
|
||
return ""
|
||
parts = fio.split()
|
||
if len(parts) >= 2:
|
||
initials = parts[1][0] + "."
|
||
if len(parts) >= 3:
|
||
initials += parts[2][0] + "."
|
||
return parts[0] + " " + initials
|
||
return fio
|
||
|
||
|
||
def get_initials(fio: str) -> str:
|
||
"""И.О. Фамилия"""
|
||
if not fio:
|
||
return ""
|
||
parts = fio.split()
|
||
if len(parts) >= 2:
|
||
initials = parts[1][0] + "."
|
||
if len(parts) >= 3:
|
||
initials += parts[2][0] + "."
|
||
return initials + " " + parts[0]
|
||
return fio
|