mod: двухпроходный конвейер типографа (теперь проблеы перед предлогами и кавычками не ломаются из-за html-тегов)
This commit is contained in:
@@ -674,4 +674,7 @@ ABBR_COMMON_PREPOSITION = [
|
||||
'тов.', 'г-н.', 'г-жа.', 'им.',
|
||||
'д. о. с.', 'д. о. н.', 'д. м. н.', 'к. т. д.', 'к. т. п.',
|
||||
'АО', 'ООО', 'ЗАО', 'ПАО', 'НКО', 'ОАО', 'ФГУП', 'НИИ', 'ПБОЮЛ', 'ИП',
|
||||
]
|
||||
]
|
||||
|
||||
# === КОНСТАНТЫ ДЛЯ HTML-ТЕГОВ, ВНУТРИ КОТОРЫХ НЕ НАДО ТИПОГРАФИРОВАТЬ ===
|
||||
PROTECTED_HTML_TAGS = ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']
|
@@ -14,6 +14,7 @@ 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
|
||||
|
||||
|
||||
# --- Настройки логирования ---
|
||||
@@ -107,13 +108,9 @@ class Typographer:
|
||||
processed_text = decode_to_unicode(text)
|
||||
# processed_text = text # ВРЕМЕННО: используем текст как есть
|
||||
|
||||
# Шаг 2: Применяем правила к чистому Unicode-тексту
|
||||
# Шаг 2: Применяем правила к чистому Unicode-тексту (только правила на уровне ноды)
|
||||
if self.symbols is not None:
|
||||
processed_text = self.symbols.process(processed_text)
|
||||
if self.quotes is not None:
|
||||
processed_text = self.quotes.process(processed_text)
|
||||
if self.unbreakables is not None:
|
||||
processed_text = self.unbreakables.process(processed_text)
|
||||
if self.layout is not None:
|
||||
processed_text = self.layout.process(processed_text)
|
||||
if self.hyphenation is not None:
|
||||
@@ -138,8 +135,8 @@ class Typographer:
|
||||
|
||||
processed_node_text = self._process_text_node(child.string)
|
||||
child.replace_with((processed_node_text))
|
||||
elif child.name not in ['style', 'script', 'pre', 'code', 'kbd', 'samp', 'math']:
|
||||
# Если это "безопасный" тег, рекурсивно заходим в него
|
||||
elif child.name not in PROTECTED_HTML_TAGS:
|
||||
# Если это "обычный" html-тег, рекурсивно заходим в него
|
||||
self._walk_tree(child)
|
||||
|
||||
def process(self, text: str) -> str:
|
||||
@@ -151,18 +148,66 @@ class Typographer:
|
||||
return ""
|
||||
# Если включена обработка HTML и BeautifulSoup доступен
|
||||
if self.process_html:
|
||||
# Мы передаем 'html.parser', он быстрый и встроенный.
|
||||
soup = BeautifulSoup(markup=text, features='html.parser')
|
||||
# Запускаем рекурсивный обход дерева, начиная с корневого элемента
|
||||
self._walk_tree(soup)
|
||||
# Получаем измененный HTML. BeautifulSoup по умолчанию выводит без тегов <html><body>
|
||||
# если их не было в исходной строке.
|
||||
processed_html = str(soup)
|
||||
# --- ЭТАП 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)))
|
||||
|
||||
# Финальный шаг: BeautifulSoup по умолчанию экранирует амперсанды (& -> &).
|
||||
# Но наш кодек encode_from_unicode() тоже это делает. Так что мы получаем двойное экранирование.
|
||||
# Чтобы избежать этого, мы просто заменяем & обратно на &.
|
||||
# --- ЭТАП 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-режим выключен
|
||||
return self._process_text_node(text)
|
||||
# Если 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)
|
||||
|
||||
|
@@ -116,6 +116,8 @@ LAYOUT_TEST_CASES = [
|
||||
# Составные и математические единицы
|
||||
('ru', "Площадь 120 кв. м.", f"Площадь 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м."),
|
||||
('ru', "Площадь 130 кв.м.", f"Площадь 130{CHAR_NBSP}кв.{CHAR_THIN_SP}м."),
|
||||
('ru', "Площадь 130 м²", f"Площадь 130{CHAR_NBSP}м²"),
|
||||
('ru', "Площадь 130м²", f"Площадь 130м²"),
|
||||
('ru', f"Площадь 140 {CHAR_NBSP} кв.{CHAR_NBSP}м.", f"Площадь 140{CHAR_NBSP}кв.{CHAR_THIN_SP}м."),
|
||||
('ru', "Площадь 150 тыс. кв. км.", f"Площадь 150{CHAR_NBSP}тыс.{CHAR_THIN_SP}кв.{CHAR_THIN_SP}км."),
|
||||
('ru', "Скорость 90 км/ч", f"Скорость 90{CHAR_NBSP}км/ч"),
|
||||
@@ -123,11 +125,17 @@ LAYOUT_TEST_CASES = [
|
||||
('ru', "В 500 г. н. э.", f"В 500{CHAR_NBSP}г.{CHAR_THIN_SP}н.{CHAR_THIN_SP}э."),
|
||||
('ru', "Пластинка 45 мин. об.", f"Пластинка 45{CHAR_NBSP}мин.{CHAR_THIN_SP}об."),
|
||||
('ru', "Пластинка 45 об. мин.", f"Пластинка 45{CHAR_NBSP}об.{CHAR_THIN_SP}мин."),
|
||||
('ru', "За окном 15°C", f"За окном 15°C"),
|
||||
('ru', "За окном 15 °C", f"За окном 15{CHAR_NBSP}°C"),
|
||||
('ru', "HiFi 20 Гц - 20 кГц", f"HiFi 20{CHAR_NBSP}Гц - 20{CHAR_NBSP}кГц"),
|
||||
# Случаи когда единица измерения вплотную к числу и не должны меняться
|
||||
('ru', "Площадь 130м²", f"Площадь 130м²"),
|
||||
('ru', "За окном 15°C", f"За окном 15°C"),
|
||||
('ru', "Скорость 90км/ч", f"Скорость 90км/ч"),
|
||||
('ru', "Зачислено на счёт $5тыс.", f"Зачислено на счёт $5тыс."),
|
||||
('ru', "Зачислено на счёт $5000", f"Зачислено на счёт $5000"),
|
||||
|
||||
# Сложные единицы (склеиваются тонкой шпацией, привязываются к числу неразрывным пробелом)
|
||||
('ru', "Зачислено на счёт 10 млн.руб.", f"Зачислено на счёт 10{CHAR_NBSP}млн.{CHAR_THIN_SP}руб."),
|
||||
('ru', "Дом 120 кв.м. / Участок 6 сот.", f"Дом 120{CHAR_NBSP}кв.{CHAR_THIN_SP}м. / Участок 6{CHAR_NBSP}сот."),
|
||||
# ('ru', "Гробик кладут в ямку 2 кв. м.", f"Гробик кладут в ямку 2 кв. м."),
|
||||
('ru', "500 до н. э.", f"500 до н.{CHAR_THIN_SP}э."),
|
||||
|
@@ -3,7 +3,7 @@
|
||||
|
||||
import pytest
|
||||
from etpgrf import Typographer
|
||||
from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP
|
||||
from etpgrf.config import CHAR_NBSP, CHAR_THIN_SP, CHAR_NDASH, CHAR_MDASH
|
||||
|
||||
TYPOGRAPHER_HTML_TEST_CASES = [
|
||||
# --- Базовая обработка без HTML ---
|
||||
@@ -32,37 +32,74 @@ TYPOGRAPHER_HTML_TEST_CASES = [
|
||||
('mnemonic', '<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>',
|
||||
'<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>'),
|
||||
('mixed', '<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>',
|
||||
'<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>'),
|
||||
'<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>'),
|
||||
('unicode', '<p>Союз и <b>слово</b> и еще один союз а <span>текст</span>.</p>',
|
||||
f'<p>Союз и{CHAR_NBSP}<b>слово</b> и{CHAR_NBSP}еще один союз а{CHAR_NBSP}<span>текст</span>.</p>'),
|
||||
f'<p>Союз и{CHAR_NBSP}<b>слово</b> и{CHAR_NBSP}еще один союз а{CHAR_NBSP}<span>текст</span>.</p>'),
|
||||
|
||||
# --- Проверка тегов <style>, <script>, <pre>, <code>, <kbd<, <samp> и <math> ---
|
||||
('mixed', '<p>Текст "до".</p><pre> - 10</pre><code>"тоже не трогать"</code>',
|
||||
'<p>Текст «до».</p><pre> - 10</pre><code>"тоже не трогать"</code>'),
|
||||
('mixed', '<p>Текст "до".</p><style>body { font-family: "Arial"; }</style>',
|
||||
'<p>Текст «до».</p><style>body { font-family: "Arial"; }</style>'),
|
||||
('mixed', '<p>Текст "до".</p><script>var text = "не трогать";</script>',
|
||||
'<p>Текст «до».</p><script>var text = "не трогать";</script>'),
|
||||
('mixed', '<p>Текст "до".</p><kbd>Ctrl + C</kbd>',
|
||||
'<p>Текст «до».</p><kbd>Ctrl + C</kbd>'),
|
||||
('mixed', '<p>Текст "до".</p><samp>Sample "text"</samp>',
|
||||
'<p>Текст «до».</p><samp>Sample "text"</samp>'),
|
||||
('mixed', '<p>Текст "до".</p><math><mi>x</mi><mo>=</mo><mn>5</mn></math>',
|
||||
'<p>Текст «до».</p><math><mi>x</mi><mo>=</mo><mn>5</mn></math>'),
|
||||
|
||||
# --- Проверка тегов с атрибутами ---
|
||||
('mixed', '<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст "снаружи"</a>',
|
||||
'<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст «снаружи»</a>'),
|
||||
('mixed', '<a href="/a-b" title=\'Текст в кавычках \"внутри\" атрибута\'>Текст "снаружи"</a>',
|
||||
'<a href="/a-b" title=\'Текст в кавычках \"внутри\" атрибута\'>Текст «снаружи»</a>'),
|
||||
('mixed', '<a href="/a-b" title="Текст в кавычках «внутри» атрибута">Текст "снаружи"</a>',
|
||||
'<a href="/a-b" title="Текст в кавычках «внутри» атрибута">Текст «снаружи»</a>'),
|
||||
('mnemonic', '<a href="/a-b" title="Текст в кавычках «внутри» атрибута">Текст "снаружи"</a>',
|
||||
'<a href="/a-b" title="Текст в кавычках «внутри» атрибута">Текст «снаружи»</a>'),
|
||||
|
||||
# --- Комплексный интеграционный тест ---
|
||||
('mnemonic', '<p>Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."</p>',
|
||||
'<p>Он сказал: «В 1941–1945 гг. – было 100 тыс. руб.'
|
||||
' и т. д.»</p>'),
|
||||
('mixed', '<p>Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."</p>',
|
||||
'<p>Он сказал: «В 1941–1945 гг. – было 100 тыс. руб.'
|
||||
' и т. д.»</p>'),
|
||||
('unicode', '<p>Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."</p>',
|
||||
f'<p>Он{CHAR_NBSP}сказал: «В{CHAR_NBSP}1941{CHAR_NDASH}1945{CHAR_NBSP}гг.{CHAR_NBSP}{CHAR_NDASH} было'
|
||||
f' 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб. и{CHAR_NBSP}т.{CHAR_THIN_SP}д.»</p>'),
|
||||
# --- Теги внутри кавычек ---
|
||||
('mnemonic', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
'<p>«<u>Почему</u>», «<u>зачем</u>» и «<u>кому это выгодно</u>'
|
||||
'» – вопросы требующие ответа.</p>'),
|
||||
('mixed', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
'<p>«<u>Почему</u>», «<u>зачем</u>» и «<u>кому это выгодно</u>» – вопросы требующие ответа.</p>'),
|
||||
('unicode', '<p>"<u>Почему</u>", "<u>зачем</u>" и "<u>кому это выгодно</u>" -- вопросы требующие ответа.</p>',
|
||||
f'<p>«<u>Почему</u>», «<u>зачем</u>» и{CHAR_NBSP}«<u>кому это выгодно</u>»{CHAR_NBSP}{CHAR_NDASH} вопросы требующие ответа.</p>'),
|
||||
|
||||
# --- Проверка пустого текста и узлов с пробелами ---
|
||||
('mnemonic', '<p> </p><div>\n\t</div><p>Слово</p>', '<p> </p><div>\n</div><p>Слово</p>'),
|
||||
('mixed', '<p> </p><div>\n\t</div><p>Слово</p>', '<p> </p><div>\n</div><p>Слово</p>'),
|
||||
('unicode', '<p> </p><div>\n\t</div><p>Слово</p>', '<p> </p><div>\n</div><p>Слово</p>'),
|
||||
|
||||
# --- Самозакрывающиеся теги и теги с атрибутами ---
|
||||
# ВАЖНО: порядок атрибутов в типографированном тексте может быть произвольным
|
||||
('mnemonic', '<p>Текст с картинкой <img src="image.jpg" alt="image" /> и текстом.</p>',
|
||||
'<p>Текст с картинкой <img alt="image" src="image.jpg"/> и текстом.</p>'),
|
||||
('mnemonic', '<p>Текст с <code><br></code><br>А это новая строка.</p>',
|
||||
'<p>Текст с <code><br></code><br/>А это новая строка.</p>'),
|
||||
('mixed', '<p>Текст с картинкой <img src="image.jpg" alt="image" /> и текстом.</p>',
|
||||
'<p>Текст с картинкой <img alt="image" src="image.jpg"/> и текстом.</p>'),
|
||||
('mixed', '<p>Текст с <code><br></code><br>А это новая строка.</p>',
|
||||
'<p>Текст с <code><br></code><br/>А это новая строка.</p>'),
|
||||
('unicode', '<p>Текст с картинкой <img src="image.jpg" alt="image" /> и текстом.</p>',
|
||||
f'<p>Текст с{CHAR_NBSP}картинкой <img alt="image" src="image.jpg"/> и{CHAR_NBSP}текстом.</p>'),
|
||||
('unicode', '<p>Текст с <code><br></code><br>А это новая строка.</p>',
|
||||
f'<p>Текст с{CHAR_NBSP}<code><br></code><br/>А{CHAR_NBSP}это новая строка.</p>'),
|
||||
|
||||
|
||||
|
||||
|
||||
# # --- Проверка "небезопасных" тегов ---
|
||||
# (
|
||||
# 'Небезопасные теги не должны обрабатываться.',
|
||||
# '<p>Текст "до".</p><script>var text = "не трогать";</script><pre> - 10</pre><code>"тоже не трогать"</code>',
|
||||
# '<p>Текст «до».</p><script>var text = "не трогать";</script><pre> - 10</pre><code>"тоже не трогать"</code>'
|
||||
# ),
|
||||
# # --- Проверка атрибутов ---
|
||||
# (
|
||||
# 'Атрибуты тегов не должны обрабатываться.',
|
||||
# '<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст "снаружи"</a>',
|
||||
# '<a href="/a-b" title="Текст в кавычках \'внутри\' атрибута">Текст «снаружи»</a>'
|
||||
# ),
|
||||
# # --- Комплексный интеграционный тест ---
|
||||
# (
|
||||
# 'Все правила вместе в HTML.',
|
||||
# '<p>Он сказал: "В 1941-1945 гг. -- было 100 тыс. руб. и т. д."</p>',
|
||||
# f'<p>Он сказал: «В 1941–1945{CHAR_NBSP}гг.{CHAR_NBSP}— было 100{CHAR_NBSP}тыс.{CHAR_THIN_SP}руб. и{CHAR_NBSP}т.{CHAR_THIN_SP}д.»</p>'
|
||||
# ),
|
||||
# # --- Проверка пустого текста и узлов с пробелами ---
|
||||
# (
|
||||
# 'Пустые и пробельные узлы.',
|
||||
# '<p> </p><div>\n\t</div><p>Слово</p>',
|
||||
# '<p> </p><div>\n\t</div><p>Слово</p>'
|
||||
# ),
|
||||
]
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user