Files
2025-etpgrf/etpgrf/typograph.py

214 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# etpgrf/typograph.py
# Основной класс Typographer, который объединяет все модули правил и предоставляет единый интерфейс.
# Поддерживает обработку текста внутри HTML-тегов с помощью BeautifulSoup.
import logging
import html
try:
from bs4 import BeautifulSoup, NavigableString
except ImportError:
BeautifulSoup = None
from etpgrf.comutil import parse_and_validate_mode, parse_and_validate_langs
from etpgrf.hyphenation import Hyphenator
from etpgrf.unbreakables import Unbreakables
from etpgrf.quotes import QuotesProcessor
from etpgrf.layout import LayoutProcessor
from etpgrf.symbols import SymbolsProcessor
from etpgrf.codec import decode_to_unicode, encode_from_unicode
from etpgrf.config import PROTECTED_HTML_TAGS
# --- Настройки логирования ---
logger = logging.getLogger(__name__)
# --- Основной класс Typographer ---
class Typographer:
def __init__(self,
langs: str | list[str] | tuple[str, ...] | frozenset[str] | None = None,
mode: str | None = None,
process_html: bool = False, # Флаг обработки HTML-тегов
hyphenation: Hyphenator | bool | None = True, # Перенос слов и параметры расстановки переносов
unbreakables: Unbreakables | bool | None = True, # Правила для предотвращения разрыва коротких слов
quotes: QuotesProcessor | bool | None = True, # Правила для обработки кавычек
layout: LayoutProcessor | bool | None = True, # Правила для тире и спецсимволов
symbols: SymbolsProcessor | bool | None = True, # Правила для псевдографики
# ... другие модули правил ...
):
# A. --- Обработка и валидация параметра langs ---
self.langs: frozenset[str] = parse_and_validate_langs(langs)
# B. --- Обработка и валидация параметра mode ---
self.mode: str = parse_and_validate_mode(mode)
# C. --- Настройка режима обработки HTML ---
self.process_html = process_html
if self.process_html and BeautifulSoup is None:
logger.warning("Параметр 'process_html=True', но библиотека BeautifulSoup не установлена. "
"HTML не будет обработан. Установите ее: `pip install beautifulsoup4`")
self.process_html = False
# D. --- Конфигурация правил для псевдографики ---
self.symbols: SymbolsProcessor | None = None
if symbols is True or symbols is None:
self.symbols = SymbolsProcessor()
elif isinstance(symbols, SymbolsProcessor):
self.symbols = symbols
# E. --- Инициализация правила переноса ---
# Предпосылка: если вызвали типограф, значит, мы хотим обрабатывать текст и переносы тоже нужно расставлять.
# А для специальных случаев, когда переносы не нужны, пусть не ленятся и делают `hyphenation=False`.
self.hyphenation: Hyphenator | None = None
if hyphenation is True or hyphenation is None:
# C1. Создаем новый объект Hyphenator с заданными языками и режимом, а все остальное по умолчанию
self.hyphenation = Hyphenator(langs=self.langs)
elif isinstance(hyphenation, Hyphenator):
# C2. Если hyphenation - это объект Hyphenator, то просто сохраняем его (и используем его langs и mode)
self.hyphenation = hyphenation
# F. --- Конфигурация правил неразрывных слов ---
self.unbreakables: Unbreakables | None = None
if unbreakables is True or unbreakables is None:
# D1. Создаем новый объект Unbreakables с заданными языками и режимом, а все остальное по умолчанию
self.unbreakables = Unbreakables(langs=self.langs)
elif isinstance(unbreakables, Unbreakables):
# D2. Если unbreakables - это объект Unbreakables, то просто сохраняем его (и используем его langs и mode)
self.unbreakables = unbreakables
# G. --- Конфигурация правил обработки кавычек ---
self.quotes: QuotesProcessor | None = None
if quotes is True or quotes is None:
self.quotes = QuotesProcessor(langs=self.langs)
elif isinstance(quotes, QuotesProcessor):
self.quotes = quotes
# H. --- Конфигурация правил для тире и спецсимволов ---
self.layout: LayoutProcessor | None = None
if layout is True or layout is None:
self.layout = LayoutProcessor(langs=self.langs)
elif isinstance(layout, LayoutProcessor):
self.layout = layout
# I. --- Конфигурация других правил---
# Z. --- Логирование инициализации ---
logger.debug(f"Typographer `__init__`: langs: {self.langs}, mode: {self.mode}, "
f"hyphenation: {self.hyphenation is not None}, "
f"unbreakables: {self.unbreakables is not None}, "
f"quotes: {self.quotes is not None}, "
f"layout: {self.layout is not None}, "
f"symbols: {self.symbols is not None}, "
f"process_html: {self.process_html}")
def _process_text_node(self, text: str) -> str:
"""
Внутренний конвейер, который работает с чистым текстом.
"""
# Шаг 1: Декодируем весь входящий текст в канонический Unicode
# (здесь можно использовать html.unescape, но наш кодек тоже подойдет)
processed_text = decode_to_unicode(text)
# processed_text = text # ВРЕМЕННО: используем текст как есть
# Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды)
if self.symbols is not None:
processed_text = self.symbols.process(processed_text)
if self.layout is not None:
processed_text = self.layout.process(processed_text)
if self.hyphenation is not None:
processed_text = self.hyphenation.hyp_in_text(processed_text)
# ... вызовы других активных модулей правил ...
# Финальный шаг: кодируем результат в соответствии с выбранным режимом
return encode_from_unicode(processed_text, self.mode)
def _walk_tree(self, node):
"""
Рекурсивно обходит DOM-дерево, находя и обрабатывая все текстовые узлы.
"""
# Список "детей" узла, который мы будем изменять.
# Копируем в список, так как будем изменять его во время итерации.
for child in list(node.children):
if isinstance(child, NavigableString):
# Если это текстовый узел, обрабатываем его
# Пропускаем пустые или состоящие из пробелов узлы
if not child.string.strip():
continue
processed_node_text = self._process_text_node(child.string)
child.replace_with((processed_node_text))
elif child.name not in PROTECTED_HTML_TAGS:
# Если это "обычный" html-тег, рекурсивно заходим в него
self._walk_tree(child)
def process(self, text: str) -> str:
"""
Обрабатывает текст, применяя все активные правила типографики.
Поддерживает обработку текста внутри HTML-тегов.
"""
if not text:
return ""
# Если включена обработка HTML и BeautifulSoup доступен
if self.process_html:
# --- ЭТАП 1: Токенизация и "умная склейка" ---
try:
soup = BeautifulSoup(text, 'lxml')
except Exception:
soup = BeautifulSoup(text, 'html.parser')
# 1.1. Создаем "токен-стрим" из текстовых узлов, которые мы будем обрабатывать.
# soup.descendants возвращает все дочерние узлы (теги и текст) в порядке их следования.
text_nodes = [node for node in soup.descendants
if isinstance(node, NavigableString)
# and node.strip()
and node.parent.name not in PROTECTED_HTML_TAGS]
# 1.2. Создаем "супер-строку" и "карту длин"
super_string = ""
lengths_map = []
for node in text_nodes:
super_string += str(node)
lengths_map.append(len(str(node)))
# --- ЭТАП 2: Контекстная обработка (ПОКА ЧТО ПРОПУСКАЕМ) ---
processed_super_string = super_string
# Применяем правила, которым нужен полный контекст (вся супер-строка контекста, очищенная от html).
# Важно, чтобы эти правила не меняли длину строки!!!! Иначе карта длин слетит и восстановление не получится.
if self.quotes:
processed_super_string = self.quotes.process(processed_super_string)
if self.unbreakables:
processed_super_string = self.unbreakables.process(processed_super_string)
# --- ЭТАП 3: "Восстановление" ---
current_pos = 0
for i, node in enumerate(text_nodes):
length = lengths_map[i]
new_text_part = processed_super_string[current_pos : current_pos + length]
node.replace_with(new_text_part) # Заменяем содержимое узла на месте
current_pos += length
# --- ЭТАП 4: Локальная обработка (второй проход) ---
# Теперь, когда структура восстановлена, запускаем наш старый рекурсивный обход,
# который применит все остальные правила к каждому текстовому узлу.
self._walk_tree(soup)
# --- ЭТАП 5: Финальная сборка ---
processed_html = str(soup)
# BeautifulSoup по умолчанию экранирует амперсанды (& -> &), которые мы сгенерировали
# в _process_text_node. Возвращаем их обратно.
return processed_html.replace('&', '&')
else:
# Если HTML-режим выключен, используем полный конвейер для простого текста.
# Шаг 0: Нормализация
processed_text = decode_to_unicode(text)
# Шаг 1: Применяем все правила последовательно
if self.quotes:
processed_text = self.quotes.process(processed_text)
if self.unbreakables:
processed_text = self.unbreakables.process(processed_text)
if self.symbols:
processed_text = self.symbols.process(processed_text)
if self.layout:
processed_text = self.layout.process(processed_text)
if self.hyphenation:
processed_text = self.hyphenation.hyp_in_text(processed_text)
# Шаг 2: Финальное кодирование
return encode_from_unicode(processed_text, self.mode)