mod: add etpgrf typograph
This commit is contained in:
@@ -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(' ', ' ')
|
||||
result = result.replace('«', '«')
|
||||
result = result.replace('»', '»')
|
||||
result = result.replace('…', '…')
|
||||
result = result.replace('<nobr>', '')
|
||||
result = result.replace('</nobr>', '')
|
||||
result = result.replace('—', '—')
|
||||
result = result.replace('№', '№')
|
||||
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+|> ", "> ", s, flags=re.IGNORECASE)
|
||||
s = re.sub(r"\n|\r|<p[^>]*>\s*</p>|<p> </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+|> ", "> ", s, flags=re.IGNORECASE)
|
||||
# s = re.sub(r"\n|\r|<p[^>]*>\s*</p>|<p> </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
|
||||
|
||||
@@ -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> "
|
||||
"«приклеивает» союзы, поддерживает неразрывные конструкции, "
|
||||
verbose_name="Типограф etpgrf",
|
||||
help_text="Обработать через <a href=\"https://typograph.cube2.ru/\""
|
||||
" target=\"_blank\">Типограф ETPRGF</a><br />"
|
||||
"<small><b>СТАБИЛЬНЫЙ И СОВРЕМЕННЫЙ ТИПОГРАФ, РЕКОМЕНДУЕМ</b> "
|
||||
"«приклеивает» союзы и предлоги, поддерживает неразрывные конструкции, "
|
||||
"замена тире, кавычек и дефисов, расстановка «мягких переносов» "
|
||||
"в словах длиннее 12 символов, убирает «вдовы» «сироты» (кроме "
|
||||
"в словах длиннее 14 символов, убирает «вдовы» «сироты» (кроме "
|
||||
"заголовков), расставляет абзацы (кроме заголовков), расшифровывает "
|
||||
"аббревиатуры (те, что знает и кроме заголовков), висячая "
|
||||
"пунктуация (только в заголовках) и т.п.</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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>«Привет <b>мир</b>» ­<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>«Привет мир»</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>Привет мир</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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user