mod: add etpgrf typograph

This commit is contained in:
2026-04-10 16:14:57 +03:00
parent 360af67ed3
commit 50067b9bd2
7 changed files with 363 additions and 129 deletions

View File

@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
from cadpoint.settings import *
from bs4 import BeautifulSoup
from html import unescape
import pytils
import re
@@ -12,42 +14,56 @@ def check_cookies(request) -> bool:
def safe_html_special_symbols(s: str) -> str:
""" Очистка строки от HTML-разметки типографа
"""Преобразует HTML-фрагмент в чистый текст.
:param s: строка которую надо очистить
:return: str:
Удаляет все HTML-теги и декодирует HTML-сущности в Unicode.
:param s: строка, которую надо очистить
:return: str: чистый текст без HTML-разметки
"""
# очистка строки от некоторых спец-символов HTML
result = s.replace('­', '­')
result = result.replace('<span class="laquo">', '')
result = result.replace('<span style="margin-right:0.44em;">', '')
result = result.replace('<span style="margin-left:-0.44em;">', '')
result = result.replace('<span class="raquo">', '')
result = result.replace('<span class="point">', '')
result = result.replace('<span class="thinsp">', ' ')
result = result.replace('<span class="ensp">', '')
result = result.replace('</span>', '')
result = result.replace('&nbsp;', ' ')
result = result.replace('&laquo;', '«')
result = result.replace('&raquo;', '»')
result = result.replace('&hellip;', '')
result = result.replace('<nobr>', '')
result = result.replace('</nobr>', '')
result = result.replace('&mdash;', '')
result = result.replace('&#8470;', '')
result = result.replace('<br />', ' ')
result = result.replace('<br>', ' ')
return result
if not s:
return ""
soup = BeautifulSoup(s, "html.parser")
# Скрипты и стили в чистый текст не нужны — выкидываем их целиком.
for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
tag.decompose()
result = soup.get_text()
result = unescape(result).replace("\xa0", " ")
# Убираем мягкие переносы и другие невидимые символы, которые не нужны
# ни для slug, ни для человекочитаемого текста.
result = result.translate({
ord("\xad"): None, # символ мягкого переноса
ord("\u200b"): None, # символ нулевой ширины (zero-width space)
ord("\u200c"): None, # символ нулевой ширины (zero-width non-joiner)
ord("\u200d"): None, # символ Zero Width Joiner (ZWJ)
ord("\u2060"): None, # символ Word Joiner (WJ)
ord("\ufeff"): None, # символ Zero Width No-Break Space (BOM)
})
return " ".join(result.split())
def post_processing_html(s: str) -> str:
s = re.sub(r"\s+", " ", s, flags=re.IGNORECASE)
s = re.sub(r">\s+|>&nbsp;", "> ", s, flags=re.IGNORECASE)
s = re.sub(r"\n|\r|<p[^>]*>\s*</p>|<p>&nbsp;</p>", "", s, flags=re.IGNORECASE)
s = re.sub(r"</p>\s*<br[^>]*>", "</p>", s, flags=re.IGNORECASE)
s = re.sub(r"<br[^>]*>\s*<p>|<p[^>]*>\s*<p[^>]*>", "<p>", s, flags=re.IGNORECASE)
s = re.sub(r"</p>\s*</p>", "</p>", s, flags=re.IGNORECASE)
s = re.sub(r"<br[^>]*>\s*<br[^>]*>", "<br />", s, flags=re.IGNORECASE)
s = re.sub(r"<p><blockquote>", "<blockquote>", s, flags=re.IGNORECASE)
s = re.sub(r"</blockquote></p>", "</blockquote>", s, flags=re.IGNORECASE)
return s
def clean_text_to_slug(s: str, default: str = "content") -> str:
"""Готовит чистый slug из HTML/Unicode текста."""
slug = pytils.translit.slugify(safe_html_special_symbols(s).lower())
slug = re.sub(r"-+", "-", slug).strip("-")
return slug or default
# Удалить: HTML-постобработка была нужна только для старого типографа Муравьёва.
# После перехода на `etpgrf` можно будет убрать и этот закомментированный блок,
# и сам импорт `re`, если он больше нигде не понадобится.
#
# def post_processing_html(s: str) -> str:
# s = re.sub(r"\s+", " ", s, flags=re.IGNORECASE)
# s = re.sub(r">\s+|>&nbsp;", "> ", s, flags=re.IGNORECASE)
# s = re.sub(r"\n|\r|<p[^>]*>\s*</p>|<p>&nbsp;</p>", "", s, flags=re.IGNORECASE)
# s = re.sub(r"</p>\s*<br[^>]*>", "</p>", s, flags=re.IGNORECASE)
# s = re.sub(r"<br[^>]*>\s*<p>|<p[^>]*>\s*<p[^>]*>", "<p>", s, flags=re.IGNORECASE)
# s = re.sub(r"</p>\s*</p>", "</p>", s, flags=re.IGNORECASE)
# s = re.sub(r"<br[^>]*>\s*<br[^>]*>", "<br />", s, flags=re.IGNORECASE)
# s = re.sub(r"<p><blockquote>", "<blockquote>", s, flags=re.IGNORECASE)
# s = re.sub(r"</blockquote></p>", "</blockquote>", s, flags=re.IGNORECASE)
# return s

View File

@@ -1,15 +1,49 @@
# -*- coding: utf-8 -*-
import datetime
import logging
from django.db import models
from django.utils.timezone import now
from etpgrf import Hyphenator, Typographer
from filer.fields.image import FilerFileField
from taggit.managers import TaggableManager
from taggit.models import Tag, TaggedItem
from web.add_function import safe_html_special_symbols, post_processing_html
import urllib3
from web.add_function import clean_text_to_slug, safe_html_special_symbols
import pytils
import random
import datetime
logger = logging.getLogger(__name__)
# Типограф настраиваем один раз на модуль: в save() он только обрабатывает строку,
# а не пересоздаётся на каждый объект контента.
_TYPOGRAPHER_LANGS = 'ru+en'
_TYPOGRAPHER_MAX_UNHYPHENATED_LEN = 14
def _build_typographer(hanging_punctuation=None) -> Typographer:
"""Собирает `etpgrf` с едиными настройками для заголовка и текста."""
return Typographer(
langs=_TYPOGRAPHER_LANGS,
process_html=True,
hyphenation=Hyphenator(
langs=_TYPOGRAPHER_LANGS,
max_unhyphenated_len=_TYPOGRAPHER_MAX_UNHYPHENATED_LEN,
),
hanging_punctuation=hanging_punctuation,
)
_TYPOGRAPHER_HEAD = _build_typographer(hanging_punctuation='left')
_TYPOGRAPHER_TEXT = _build_typographer()
def _typograph_text(text: str, typographer: Typographer) -> str:
"""Применяет `etpgrf` к HTML-фрагменту и не валит save при сбое библиотеки."""
if not text:
return text
try:
return typographer.process(text)
except Exception:
logger.exception("etpgrf не смог обработать текст, сохраняем исходный вариант")
return text
# класс для транслитерации русскоязычных slug
@@ -113,13 +147,13 @@ class TbContent(models.Model):
)
bTypograf = models.BooleanField(
default=False,
verbose_name="Типограф Стандарт",
help_text="Обработать через <a href=\"https://www.typograf.ru\""
" target=\"_blank\">Типограф 2.0</a><br />"
"<small><b>НОРМАЛЬНЫЙ ТИПОГРАФ, ХОРОШИЙ HTML, РЕКОМЕНДУЕМ</b> "
"&laquo;приклеивает&raquo; союзы, поддерживает неразрывные конструкции, "
verbose_name="Типограф etpgrf",
help_text="Обработать через <a href=\"https://typograph.cube2.ru/\""
" target=\"_blank\">Типограф ETPRGF</a><br />"
"<small><b>СТАБИЛЬНЫЙ И СОВРЕМЕННЫЙ ТИПОГРАФ, РЕКОМЕНДУЕМ</b> "
"&laquo;приклеивает&raquo; союзы и предлоги, поддерживает неразрывные конструкции, "
"замена тире, кавычек и дефисов, расстановка &laquo;мягких переносов&raquo; "
"в словах длиннее 12 символов, убирает &laquo;вдовы&raquo; &laquo;сироты&raquo; (кроме "
"в словах длиннее 14 символов, убирает &laquo;вдовы&raquo; &laquo;сироты&raquo; (кроме "
"заголовков), расставляет абзацы (кроме заголовков), расшифровывает "
"аббревиатуры (те, что знает и кроме заголовков), висячая "
"пунктуация (только в заголовках) и т.п.</small>"
@@ -158,87 +192,24 @@ class TbContent(models.Model):
return u"%03d: %s" % (self.id, result[:50] + "" if len(result) > 50 else result)
def save(self, *args, **kwargs):
# переопределяем метод save() чтобы "проверуть" тексты через типографы...
# Переопределяем save(), чтобы автоматически типографировать контент перед сохранением.
if self.szContentSlug is None or self.szContentSlug == "" or " " in self.szContentSlug:
# print("ку-ку", self.szContentHead)
result_slug = pytils.translit.slugify(
safe_html_special_symbols(self.szContentHead)).lower()
while TbContent.objects.filter(szContentSlug=result_slug).count() != 0:
result_slug = "%s-%x" % (result_slug[0: -3], int(random.uniform(0, 255)))
base_slug = clean_text_to_slug(self.szContentHead)
result_slug = base_slug
suffix = 1
while TbContent.objects.filter(szContentSlug=result_slug).exists():
result_slug = f"{base_slug}-{suffix}"
suffix += 1
self.szContentSlug = result_slug
if self.bTypograf:
# Используем типограф Eugene Spearance (https://www.typograf.ru) через API
# Настройки стиля типографики см. тут: https://www.typograf.ru/webservice/about/
try:
http = urllib3.PoolManager()
resp = http.request("POST", "https://www.typograf.ru/webservice/",
fields={"text": self.szContentHead.encode('cp1251'),
'xml': '<?xml version="1.0" encoding="windows-1251" ?>'
'<preferences>'
' <!-- Абзацы НЕ СТАВИМ-->'
' <paragraph insert="0" />'
' <!-- Переводы строк НЕ СТАВИМ -->'
' <newline insert="0" />'
' <!-- Неразрывные конструкции ДА -->'
' <hanging-punct insert="1" />'
' <!-- Переносы слов длиннее 12 знаков -->'
' <hyphen insert="1" length="12" />'
'</preferences>'.encode('cp1251')})
result = resp.data.decode('cp1251')
if len(result) <= 512:
self.szContentHead = result
resp = http.request("POST", "https://www.typograf.ru/webservice/",
fields={"text": self.szContentIntro.encode('cp1251'),
'xml': '<?xml version="1.0" encoding="windows-1251" ?>'
'<preferences>'
' <!-- Висячая пунктуация УДАЛЯЕТСЯ -->'
' <hanging-punct insert="1" />'
' <!-- Висячие слова УДАЛЯЕМ -->'
' <hanging-line delete="1" />'
' <!-- Переносы слов длиннее 12 знаков -->'
' <hyphen insert="1" length="12" />'
' <!-- Параметры ссылок -->'
' <link target="_blank" />'
'</preferences>'.encode('cp1251')})
self.szContentIntro = resp.data.decode('cp1251')
resp = http.request("POST", "https://www.typograf.ru/webservice/",
fields={"text": self.szContentBody.encode('cp1251'),
'xml': '<?xml version="1.0" encoding="windows-1251" ?>'
'<preferences>'
' <!-- Висячая пунктуация УДАЛЯЕТСЯ -->'
' <hanging-punct insert="1" />'
' <!-- Висячие слова УДАЛЯЕМ -->'
' <hanging-line delete="1" />'
' <!-- Переносы слов длиннее 10 знаков -->'
' <hyphen insert="1" length="12" />'
' <!-- Параметры ссылок -->'
' <link target="_blank" />'
'</preferences>'.encode('cp1251')})
self.szContentBody = resp.data.decode('cp1251')
except:
# если API типографа не доступен, то подключаем локальный типограф Муравьева
import web.EMT as EMT
emt_header = EMT.EMTypograph()
emt_header.setup({'Text.paragraphs': 'off'})
emt_header.set_text(self.szContentHead)
self.szContentHead = emt_header.apply()
emt_intro = EMT.EMTypograph()
# print("==================================== self.szContentBody\n", self.szContentIntro)
# print("-----------------")
emt_intro.set_text(self.szContentIntro)
# emt_intro.set_tag_layout(layout=EMT.LAYOUT_CLASS)
self.szContentIntro = emt_intro.apply()
self.szContentIntro = post_processing_html(self.szContentIntro)
# print(self.szContentIntro)
emt_body = EMT.EMTypograph()
# print("==================================== self.szContentBody")
# print(self.szContentBody)
# print("-----------------")
emt_body.set_text(self.szContentBody)
# emt_body.set_tag_layout(layout=EMT.LAYOUT_CLASS)
self.szContentBody = emt_body.apply()
self.szContentBody = post_processing_html(self.szContentBody)
# print(self.szContentBody)
# `etpgrf` уже умеет HTML-режим и висячую пунктуацию, поэтому здесь
# не нужен старый локальный fallback.
# Для заголовка включаем левую висячую пунктуацию, а для анонса и
# тела текста оставляем обычную обработку без hanging punctuation.
self.szContentHead = _typograph_text(self.szContentHead, _TYPOGRAPHER_HEAD)
self.szContentIntro = _typograph_text(self.szContentIntro, _TYPOGRAPHER_TEXT)
self.szContentBody = _typograph_text(self.szContentBody, _TYPOGRAPHER_TEXT)
self.bTypograf = False
if self.dtContentCreate is None:
self.dtContentCreate = datetime.datetime.now()

View File

@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
from django import template
from web.add_function import safe_html_special_symbols
import pytils
from web.add_function import clean_text_to_slug, safe_html_special_symbols
register = template.Library()
@@ -16,9 +15,9 @@ def slug_ru(value: str, arg: int) -> str:
"""
try:
arg = int(arg)
return pytils.translit.slugify(str(value).lower())[:int(arg)]
return clean_text_to_slug(str(value))[:int(arg)]
except ValueError:
return pytils.translit.slugify(str(value).lower())
return clean_text_to_slug(str(value))
@register.filter

View File

@@ -1,9 +1,13 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from taggit.models import Tag
from web.add_function import clean_text_to_slug, safe_html_special_symbols
from web.legacy_links import build_canonical_url, replace_legacy_links
from web.models import TbContent
class LegacyLinksTests(SimpleTestCase):
@@ -35,6 +39,23 @@ class LegacyLinksTests(SimpleTestCase):
self.assertEqual(len(matches), 1)
class SafeHtmlSpecialSymbolsTests(SimpleTestCase):
def test_strips_html_tags_and_decodes_entities(self):
text = '<p>&laquo;Привет&nbsp;<b>мир</b>&raquo; &shy;<script>alert(1)</script><style>p{}</style></p>'
self.assertEqual(safe_html_special_symbols(text), '«Привет мир»')
def test_clean_text_to_slug_normalizes_non_latin_symbols(self):
self.assertEqual(clean_text_to_slug('αβγ ΔΩ'), 'content')
self.assertEqual(clean_text_to_slug('₽ € $ ₴ ₿'), 'content')
def test_tbcontent_str_uses_clean_text(self):
item = TbContent(id=7, szContentHead='<b>&laquo;Привет&nbsp;мир&raquo;</b>')
self.assertEqual(str(item), '007: «Привет мир»')
class TagAutocompleteTests(TestCase):
def setUp(self):
user_model = get_user_model()
@@ -85,3 +106,40 @@ class TagAutocompleteTests(TestCase):
self.assertEqual(payload['pagination']['more'], False)
class TypographTests(TestCase):
def test_save_generates_slug_from_clean_text(self):
item = TbContent(szContentHead='<b>Привет&nbsp;мир</b>')
item.save()
self.assertEqual(item.szContentSlug, 'privet-mir')
def test_save_normalizes_non_latin_slug_to_default(self):
item = TbContent(szContentHead='αβγ ΔΩ')
item.save()
self.assertEqual(item.szContentSlug, 'content')
def test_save_uses_etpgrf_and_clears_flag(self):
item = TbContent(
szContentHead='«Привет»',
szContentIntro='<p>Абзац</p>',
szContentBody='<p>Тело</p>',
bTypograf=True,
)
with patch('web.models._TYPOGRAPHER_HEAD.process') as head_process_mock, \
patch('web.models._TYPOGRAPHER_TEXT.process') as text_process_mock:
head_process_mock.side_effect = lambda text: f'HEAD[{text}]'
text_process_mock.side_effect = lambda text: f'TEXT[{text}]'
item.save()
self.assertEqual(head_process_mock.call_count, 1)
self.assertEqual(text_process_mock.call_count, 2)
self.assertEqual(item.szContentHead, 'HEAD[«Привет»]')
self.assertEqual(item.szContentIntro, 'TEXT[<p>Абзац</p>]')
self.assertEqual(item.szContentBody, 'TEXT[<p>Тело</p>]')
self.assertFalse(item.bTypograf)