typus — локальный типограф на python

           ,'``.._   ,'``.
          :,--._:)\,:,._,.:       All Glory to
          :`--,''   :`...';\      the HYPNO TOAD!
           `,'       `---'  `.
           /                 :
          /                   \
        ,'                     :\.___,-.
       `...,---'``````-..._    |:       \
         (                 )   ;:    )   \  _,-.
          `.              (   //          `'    \
           :               `.//  )      )     , ;
         ,-|`.            _,'/       )    ) ,' ,'
        (  :`.`-..____..=:.-':     .     _,' ,'
         `,'\ ``--....-)='    `._,  \  ,') _ '``._
      _.-/ _ `.       (_)      /     )' ; / \ \`-.'
     `--(   `-:`.     `' ___..'  _,-'   |/   `.)
         `-. `.`.``-----``--,  .'
           |/`.\`'        ,','); SSt
               `         (/  (/

    Найдено в интернетах.


    Всем привет!
    Хочу поделиться своей небольшой разработкой: типографом, который можно использовать локально.


    Дисклеймер


    Проект находится в стадии разработки и нуждается в тщательном тестировании.


    Возможности


    • замена кавычек на «„“» и “‘’” (в английской версии). Число уровней не ограничено — типограф просто чередует четные/нечетные — где-какие можно настроить
    • расстановка дюймов, апострофов: 4′, 20″
    • комплексные символы: многоточие, копирайты, трейдмарки, стрелочки и т. д.: (c) становится ``, причем, даже если написано кириллицей
    • замена дефисов на длинное тире в текстах и числовых диапазонах
    • замена дефисов на короткое тире в номерах телефонов
    • расстановка минусов и знаков умножения
    • связывание чисел с последующими словами неразрывным дефисом, например 40 попугаев
    • связывание союзов и любых слов из 1-2 символов с последующими словами
    • отделение единиц измерения от чисел (возможно, выпилю в скором будущем, очень велик шанс ложно-положительного результата)
    • неразрывные пробелы в сокращениях: т.д. станет т. д.; А. С. Пушкин — здесь обычный пробел станет разрывным
    • замена р и руб (с точкой в конце и без) на символ рубля — возможно выпилю, поскольку удалит точку если найдет совпадение в конце предложения
    • замена дробей 1/2, 1/3 и т.д. на существующие символы юникода
    • удаление лишних пробелов и переносов строк, тримминг вначале и вконце
    • расстановка неразрывных пробелов в куче случаев
    • не влияет на html теги и игнорирует содержимое (head|iframe|pre|code|script|style)
    • можно передать строки, которые типограф будет игнорировать

    Пример


    from typus import ru_typus
    
    ru_typus('00" "11 \'22\' 11"? "11 \'22 "33 33?"\' 11" 00 "11 \'22\' 11" 0"')
    '00″ «11 „22“ 11»? «11 „22 «33 33?»“ 11» 00 «11 „22“ 11» 0″'

    Число — уровень вложенности. Если бы первая кавычка стояла до нулей, был бы еще один уровень, а так вышли дюймы.


    Как устроен


    class BaseTypus(EnRuExpressions, TypusCore):
        processors = (EscapePhrases, EscapeHtml, TypoQuotes, Expressions)
    
    class RuTypus(RuQuotes, BaseTypus):
        pass
    
    ru_typus = RuTypus()

    Typus состоит из "процессоров" и "выражений".


    Выражения


    Это пары (regex, replace), которые передаются в re.sub(regex, replace) и выполняются последовательно (см. чуть ниже). Почти весь типограф — это "выражения". Они записываются как методы с приставкой expr_, функция должна вернуть вложенный список, т.е. одно "выражение" может вернуть череду "выражений":


    class MyTypus(Typus):
        expressions = Typus.expressions + 'http://bar'
    
        def expr_http://bar(self):
            expr = (
                (r'\d', '@'),  # заменяет числа на @
            )
            return expr

    Третий, необязательный, аргумент — флаги передаваемые в re.compile, по-умолчанию, это re.I | re.U | re.M | re.S.
    Кстати, replace может быть функцией, см. re.sub.


    Чтобы определить последовательность используется атрибут типографа — expressions, который хранит список названий выражений. Можно выключить лишнее:


    from typus import RuTypus
    
    exclude_expressions = ('ruble', 'math')
    
    class MyTypus(RuTypus):
        expressions = (e for e in RuTypus.expressions
                       if e not in exclude_expressions)

    expressions может быть генератором, но если сделать последовательностью, можно делать так:


    def expr_http://bar(self):
        if 'some' in self.expressions:
            return baz
        return egg

    В коробке идет лишь один микс выражений — EnRuExpressions, но он делает почти всю работу.


    Для работы выражений используется процессор Expressions.


    Процессоры


    Иногда простыми регулярками не отделаться, приходится городить убер-функцию. Процессор это класс-функция-декоратор, который инициируется во время создания типографа, а затем вызывается при обработке текста. Ему (инстансу процессора) передается сам инстанс типографа, чтобы процессор мог получить доступ к его конфигурации.


    При использовании нескольких процессоров, они декорируют друг-друга по порядку. Например, так:


    удалить html теги
        дать ход следующему процессору, если сошлись звезды
            что-то там поделать с текстом
        обработать и вернуть наверх
    вернуть теги

    С Typus поставляются несколько процессоров: EscapePhrases, EscapeHtml, TypoQuotes, Expressions.


    EscapePhrases


    Бывают случаи, когда определенный кусок текста обрабатывать нельзя, либо вы заранее знаете, что типограф на этом месте запнется, в таком случае можно поступить так:


    typus('"http://bar 2""', escape_phrases=['2"'])
    '«http://bar 2"»'

    Без этого типограф встретит закрывающую кавычку: «http://bar 2»". Еще пример:


    typus('Типограф заменяет (c) на (c)', escape_phrases=['заменяет (c)'])
    'Типограф заменяет (c) на '

    Аргумент escape_phrases можно вынести отдельным полем в ваше CRUD приложение (ака "админку"), где контент менеджер сможет перечислить фразы через разделитель, а вы передадите их типографу.


    Чтобы разделить текст можно можно воспользоваться утилитой:


    from typus.utils import splinter
    
    split = splinter(',')
    split('a, b,c ') == ['a', 'b', 'c']
    split('a, b\,c') == ['a', 'b,c']

    splinter понимает экранированные разделители и вызывает str.strip() для каждой фразы.


    EscapeHtml


    Выразет html-теги до типографа и возвращает их после. Без него <img src="http://bar"> превратится в <img src=«http://bar»>.


    TypoQuotes


    Проставляет кавычки. Ожидает, что в типографе будут перечислены атрибуты loq, roq, leq, req. Пример:


    from typus import BaseTypus
    from typus.chars import LAQUO, RAQUO, DLQUO, LDQUO
    
    class MyTypus(BaseTypus):
        # Лева нечетная, правая нечетная, левая четная, правая четная
        loq, roq, leq, req = LAQUO, RAQUO, DLQUO, LDQUO

    Есть готовые миксины EnQuotes и RuQuotes в модуле typus.mixins.


    Expressions


    Обеспечивает работу выражений. Во время инициализации типографа все регулярки компилируются и хранятся в инстансе процессора.


    Про отладку


    Если типографу передать debug=True, то он заменит все неразрывные пробелы на символ подчеркивания, это может быть полезным для отладки:


    ru_typus('(c) me', debug=True)
    '_me'

    Демо


    Важно: демо крутится на очень простой виртуалке и предназначено для демонстрации возможностей.


    Я ничего никуда не сохраню (честно), исходный код сайта вы найдете у меня на гитхабе.


    Установка и использование


    pip install -e git://github.com/byashimov/typus.git#egg=typus

    Далее:


    from typus import en_typus, ru_typus
    
    en_typus('"Beautiful is better than ugly." (c) Tim Peters.', debug=True)
    '“Beautiful is_better than ugly.” _Tim Peters.'  # _ for nbsp
    
    ru_typus('"Красивое лучше, чем уродливое." (с) Тим Петерс.')
    '«Красивое лучше, чем уродливое.»  Тим Петерс.'  # cyrillic 'с' in '(с)'

    Документация


    Эту статью можно считать таковой, пока я не сделаю корявый перевод на английский.


    Совместимость


    Name                  Stmts   Miss  Cover
    -----------------------------------------
    typus/__init__.py         8      0   100%
    typus/chars.py           18      0   100%
    typus/core.py            24      0   100%
    typus/mixins.py          77      0   100%
    typus/processors.py      99      0   100%
    typus/utils.py           30      0   100%
    -----------------------------------------
    TOTAL                   256      0   100%
    ________________ summary ________________
      py25: commands succeeded
      py26: commands succeeded
      py27: commands succeeded
      py33: commands succeeded
      py34: commands succeeded
      py35: commands succeeded
      congratulations :)

    Travis-CI, которым я пользуюсь, не поддерживает 2.5, а вручную я проверять не всегда тружусь, так что если вы еще им пользуетесь (соболезную), запустите тесты после установки.


    Страница проекта.


    Планы и какие-то идеи


    • Я не планирую вносить в типограф подчеркивание ссылок или расстановку html-тегов. Этим должен занимать процессор текстов (markdown, retext и т. д.). К тому же, у всех свои кейсы.
    • Я также не хотел бы, чтобы типограф исправлял ошибки в тексте, даже если это ничего не стоит.
    • Почти все типографы конвертируют небезопасные символы, такие как &, в html-сущности. На данный момент мне не ясно зачем это делать: браузеры, поисковики и парсеры справляются играючи с таким текстом, а гонять cpu просто так и делать код нечитаемым мне совсем не хочется. Буду рад конкретному примеру.
    • Вероятно, ru_typus справится с украинскими и белорусскими текстами (а возможно и с другими), если так, я внесу это в описание проекта.

    Вроде все.


    P. S. Какой-то ад с подсветкой инлайн кода на хабре.

    Поделиться публикацией

    Похожие публикации

    Комментарии 26

      0

      Хабр зачем-то заменяет foo на http://foo, я уже отписался в саппорт.

        0

        Парсер слопал половину спец символов в тексте. Браво.

        –1
        Tested on py 2.5, 2.6, 2.7, 3.3, 3.4, 3.5 and probably others

        Сейчас в мире Python напротив стараются поскорее отрезать все эти 2.5, 2.6, 2.7, 3.3. Это должно приблизить завершение великого перехода на 3.x. И в самом деле, зачем тянуть всё это в новом проекте и испытывать боль? Вы действительно думаете, что кто-то использует 2.5 и сожалеете, что Travis-CI его не поддерживает?

          +3

          Чтобы получить совместимость с 2.5-2.6 мне пришлось проставить индексы, которые я и так ставлю, в стрингах для format и в одним месте заменить dict comprehension на вызов dict, который подменяется из пакета future. Т. е. времени ушло 2 минуты. Может кому пригодится в легаси проекте.
          Веб-сайт, кстати, работает на тройке, хотя может и на двойке — я не специально, видимо привычка писать совместимые приложения очень сильная.

          0
          Посмотрите в сторону seZam.
            0

            Поделитесь ссылкой, пожалуйста, гугл много чего выдает.

              0
              sZam создан для других вещей — Adobe InDesign/InCopy.
              0
              Также, есть типограф Муравьева (http://mdash.ru) но он изначально написан на php и как-то ну очень по зверски переведен в python. Но патч из примерно 10 строк позволяет запускать его в продакшене и почти не бояться проблем с кодировкой.
                0
                #!/usr/bin/env python2.7
                
                import timeit
                
                from EMT import EMTypograph
                from typus import ru_typus
                
                emt = EMTypograph()
                text = '"test" (c)'
                
                def test_emt():
                    emt.set_text(text)
                    return emt.apply()
                
                def test_typus():
                    return ru_typus(text)
                
                print timeit.timeit(test_emt, number=1000)
                print timeit.timeit(test_typus, number=1000)

                $ python test.py
                62.5571029186
                0.090714931488

                Скриптик набросал чтобы избежать оверхеды с инстанцированием типографа Муравьева.

                0
                Ещё есть такой типограф github.com/samdark/Typograph

                А вообще у меня уже долгое время есть мечта создать опен-сорс проект с различными локальными спецификациями и исключениями для различных языков (я дизайнер и делаю журналы, программирование изучаю как хобби)
                  0
                  Посмотрите мой типограф.

                  https://github.com/Harut/chakert

                  В имеющихся меня не устраивало, что, во-первых правила замены оформлены в виде регулярок, что лично для меня категорически неудобно (у вас, судя по всему, тоже :( ). Особенно, разбираться в чужих регулярках.

                  Во-вторых, это танцы с бубном вокруг HTML. Он либо эскейпится, а затем раскрывается, либо удаляется, а потом возвращается на место. В первом случае мы получаем, что даже пустой тег, вставленный в неудачном месте, может повлиять на результат, а второй вариант мне не нравится непредсказуемостью, не вызывает доверия.

                  У меня исходный текст разбивается на токены с использованием регулярок, а затем перебираются все токены и у каждого вызывается метод замены: получает на вход итератор предыдущих и следующих токенов и умеет заменять себя или соседей на любой другой токен.

                  При этом токены остаются связанными со своими тегами, и при сборке обратно в текст всё само встает на свои места.

                  Русский + английский. Py2.7, py3.4 (нужно, кстати, освежить...)

                  Буду рад фидбеку, особенно в виде тест-кейсов :)
                    0
                    PS. В продакшне чуть больше года на нескольких проектах, через типограф в принудительном режиме пропущено пару сотен тысяч текстов новостного формата в виде фильтрованного HTML, и каждый день пропускается порядка нескольких десятков. Пока нареканий со стороны редакторов не было.
                      0

                      Интересно, как это я проморгал ваш проект. По честному гуглил.
                      Из тестов тут почти все. Всякие "сложные случаи" уже разложены отдельно и я с ходу их не назову, ну может это или вот еще.


                      Пока я вижу незначительные расхождения в использовании правил, скорее субъективных.


                      • тайпус по большей части полагается на грамотно написанный текст и некоторые ваши тесты не пройдет. Например, с незакрытой кавычкой или с тире вначале строки без следующего пробела.
                      • еще у меня есть ощущение, что английские кавычки расставляются у вас все же неправильно.
                      • апосторофы оба не ставят. Я, кстати, тупо забыл об этом. Вот, что значит нет реальных кейсов.
                      • html тайпус никак не модифицирует, у вас заметил теги в нижнем регистре

                      Если будете сверять тесты, имейте ввиду, что мои проверяются как по отдельности для каждого правила, так и вкупе с остальными.
                      У вас есть демо погонять?

                        0

                        Пожалуй, еще добавлю, что тайпус понимает миксованный EnRu текст. Разница между ru_typus и en_typus только в кавычках.

                          0

                          Ну вот, добавил апостроф, теперь красиво:


                          'She yelled, "I\'m going to kill you!"'
                          'She yelled, “I’m going to kill you!”'
                            0

                            Хороший тест:


                            'For ones, maybe someone will call me 'sir' without adding, 'you're making a scene.''
                            “For ones, maybe someone will call me ‘sir’ without adding, ‘you’re making a scene.’”
                            0
                            еще у меня есть ощущение, что английские кавычки расставляются у вас все же неправильно.

                            Это мы какую-то книжку нашли, в которой были описаны правила. В ридми есть ссылка на нее, правда, на другой фрагмент. Найти сейчас ее наверно будет непросто. Я завтра перепроверю по вашей ссылке.

                            html тайпус никак не модифицирует, у вас заметил теги в нижнем регистре

                            Это lxml виноват :)

                            Пожалуй, еще добавлю, что тайпус понимает миксованный EnRu текст.

                            chakert опирается на атрибут lang, если он есть. Тут тоже вопрос кейсов, у нас смешанных текстов нет.

                            Веб-демо нет… pip install chakert lxml

                            Тесты посмотрю, спасибо.
                              0
                              Посмотрел по-диагонально test_example. Некоторые вещи, например, замена (с) на © я у себя по-умолчанию включать точно не буду, потому что здесь не исключены ложные срабатывания, а через типограф у нас тексты проходят все и всегда. Лучше оставить спецсимволы на совести редактора, чем ненароком заменить не то. Надо бы мне подумать, как кастомайзить список модификаций.

                              Еще бросается в глаза смешение работы в текстовом режиме и html. Например, сокращать пробелы до одного и переводы строк до двух максимум имеет смысл для plain text, в то же время, там же есть и теги. Конечно, вряд ли это может создать проблемы, и в некоторых случаях даже подходит, например, для редактора комментов хабра :) Но какой-то червячок внутри меня мне упорно твердит, что разделение методов работы с plain text и html в вебе — это хорошая практика.

                              Ну и здесь у меня разные издевательства над HTML посреди текста. Вполне вероятные кейсы для данных, поступающих из визивига. Это главная причина, почему я написал свой типограф, потому что сделать такое на регулярках довольно непросто. Неясно, поддерживается ли это у вас, по крайней мере, тестов не вижу…
                                0
                                А, вижу что-то похожее, но кажется, всего один случай.

                                Например, мой вот этот кейс у вас не проходит, судя по всему (если я всё правильно сделал в демо) — вместо одного неразрывного получается один обычный и один неразрывный.
                                  0

                                  А не расскажите как это у вас происходит, что теги не мешают? Я сходу не разобрался.

                                    0
                                    Как работает типограф HTML:

                                    1. Парсим с помощью lxml.html

                                    2. Проходимся по дереву: каждый абзац типографируется независимо от остальных, граница абзацев — начало и конец блочного тега. Содержимое Некоторых тегов игнорируются.

                                    3. Весь текст разбивается на токены в соотвествии с содержащейся в классе токена регуляркой. При этом каждый токен сохраняет ссылку на место в дереве lxml ElementTree, из которого он взят ([1], [2]).

                                    4. Затем мы проходимся по списку всех токенов и вызываем для каждого функцию преобразования. На входе — итераторы следующих и предыдущих токенов. Итератор возвращает токены-фрагменты текста вне зависимости от тегов, так что при применении правил теги игнорируются.

                                    5. Каждый токен содержит набор правил в виде произвольного кода на питоне, он может смотреть на предыдущих и следующих соседей, может менять, добавлять, удалять соседние токены или самого себя.

                                    6. Записываем изменения в ElementTree

                                    7. Выгружаем ElementTree обратно в HTML.
                                      0

                                      Интересная реализация, спасибо. Скажите, а такой кейс обработается?


                                      <div>"foo </div>
                                      <div>bar"</div>

                                      Чтобы убедиться, что я правильно понял.

                                        0
                                        Интересный кейс, не приходило в голову. несмотря на то, что используются блочные теги, стек кавычек ведётся глобальный для всего текста, поэтому кавычки заменяются. При переходе от одного блока к другому обнуляется итератор предыдущих и следующих элементов, может что-то еще.
                                          0
                                          Я не прав, если вы успели прочитать мой коммент, то забудьте :). Оно действительно заменяется, но не так, как я описал, а просто потому что кавычка привязана к слову слева или справа, блоки тут ни при чем.
                                            0
                                            self.assertHtml('<p>"начало "текста </p><p>конец" текста"</p>',
                                                            '<p>«начало „текста </p><p>конец“ текста»</p>')
                                            


                                            Такой кейс уже не отрабатывает, поскольку стек кавычек сбрасывается вместе со всем остальным контекстом. На выходе даёт

                                            '<p>«начало „текста </p><p>конец» текста»</p>'
                                            


                                            Должен ли он отрабатывать — вопрос сложный. С одной стороны вроде бы логично, но с другой, (а) непонятно, допустимы ли в нормах типографики вложенные цитаты на несколько абзацев, и (б) не хотелось бы, чтобы лишняя кавычка в длинном тексте сдвигала расстановку кавычек в непредсказуемом месте, а то и во всём остальном тексте. Пожалуй, минусы перевешивают, и оставлю-ка как есть, на совести редакторов :)
                                              0

                                              Да, это я и имел ввиду. Похоже, я понял идею и смогу частично реализовать ее в тайпусе, правда, на все тех же регулярках.
                                              А искать закрывающие и открывающие кавычки не совсем корректно, если вы захотите внести поддержку дюймов <div>Диагональ 22"</div>.
                                              Еще всякие редакторы делают так “тест” — т. е. ставят "неправильные" кавычки, не проверял ваш типограф на этот кейс.


                                              непонятно, допустимы ли в нормах типографики вложенные цитаты на несколько абзацев

                                              Цитата может быть в несколько абзацев, но да, случай крайне редкий.

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое