четверг, 21 августа 2014 г.

Еще один фильтр для pandoc

На этот раз будем решать следующую задачу. Требуется стилизовать отдельные (standalone) изображения (такие обычно помещаются внутрь HTML тэгов <div> … </div>) в документе, представляющем собой отдельный фрагмент HTML (то есть inlined HTML — без поддержки CSS). Я не взял эту задачу с потолка. Именно такие фрагменты я вставляю в свой блог каждый раз, когда пишу очередную статью. Непосредственно HTML код статьи генерируется из простого текстового документа с помощью pandoc и дополнительных фильтров (я писал о фильтрах, которые я использую здесь и здесь). Фильтры pandoc — замечательная вещь! Но у них есть небольшой недостаток. Если запускать фильтр с помощью опций pandoc -F или --filter, то в них нельзя будет передать их собственные опции, которые, как у каждой отдельной программы, могут быть предусмотрены — это несмотря на обратное утверждение в документации к классу ToJSONFilter. Обойти это можно разными способами: пропускать фильтр через пайпы без применения опций -F или --filter, передавать настройки с помощью переменных среды, в конце концов, создать несколько фильтров в которых захардкодить нужные настройки. Но все это совсем некрасиво. Наш фильтр должен уметь принимать разные стили изображений. При этом он не должен читать их как опции командной строки, поскольку pandoc их не сможет передать. К счастью, в pandoc есть одна хитрая лазейка на этот случай — метаданные документа. Они не отображаются в сгенерированных документах и при этом в них можно записать все что угодно. Фильтры pandoc имеют доступ к метаданным исходного документа! Вот исходный код фильтра (я назвал его imgStyleFromMeta), а ниже пояснения.
-- imgStyleFromMeta.hs

{-# OPTIONS_HADDOCK prune, ignore-exports #-}

import Text.Pandoc.JSON
import Text.Pandoc.Walk (walk)
import qualified Data.Map as M
import Data.String.Utils (replace)

-- | Applies image style found in the metadata of the document
--
-- Finds field /img_style/ in the document metadata and applies its value
-- to all standalone images found in the document. Field /img_style/ may be
-- declared inside YAML block on the top of the document like
--
-- > ---
-- > img_style : |
-- >  <div class="figure" style="clear: both; text-align: center;">
-- >  <a href="$SRC$" style="margin-left: 1em; margin-right: 1em;">
-- >  <img border="0" src="$SRC$" /></a></div>
-- > ...
--
-- Additionally placeholders /$SRC$/ and /$TITLE$/ in the /img_style/ will
-- be replaced by actual source and title specified in an image parameters.
--
imgStyleFromMeta :: Maybe Format -> Pandoc -> IO Pandoc
imgStyleFromMeta (Just (Format "html")) p@(Pandoc m bs) =
    return $ case (M.lookup "img_style" (unMeta m)) of
                 Nothing -> p
                 Just (MetaBlocks [b]) -> Pandoc m (walk (substImgParams b) bs)
                 Just _ -> p
imgStyleFromMeta _ p = return p

substImgParams :: Block -> Block -> Block
substImgParams b (Para [Image _ (src, title)]) =
    walk substImgParams' b
    where substImgParams' (RawInline f s) =
                RawInline f (foldr (\(a, b) -> replace a b) s
                                   [("$SRC$", src), ("$TITLE$", title)])
          substImgParams' b = b
substImgParams _ b = b

main :: IO ()
main = toJSONFilter imgStyleFromMeta
Как установить стиль изображения сказано в комментарии внутри приведенного кода. Функция imgStyleFromMeta пытается найти в метаданных исходного документа поле img_style и, в случае успеха, проходится (walk) по блокам документа bs, подставляя найденный результат. Но не все так просто: функция substImgParams также проходится по объектам, которые нужно вставить, заменяя в них плейсхолдеры $SRC$ и $TITLE$ на результаты совпадения (matches) src и title в Para [Image _ (src, title)]. Кстати, этот паттерн и определяет, к каким типам изображений из AST-представления исходного документа будут применены подстановки стиля. Фильтр paraToSpanBlock из этой статьи преобразует любые блоки, соответствующие паттерну Para contents (ему же соответствуют и блоки с отдельными изображениями) в Plain [Span ... contents] — а это уже не соответствует отдельным изображениям. Соответственно, если сначала пройтись по исходному документу фильтром paraToSpanBlock, а затем фильтром imgStyleFromMeta, то новые стили не будут применены. Исправить это просто: paraToSpanBlock не должен преобразовывать отдельные изображения в спаны — в конце концов они и не должны находиться внутри спанов. Вот исправленный код paraToSpanBlock.
-- paraToSpanBlock.hs
import Text.Pandoc.JSON

paraToSpanBlock :: Maybe Format -> Block -> IO Block
paraToSpanBlock (Just (Format "html")) b@(Para [Image _ _]) =
    return b
paraToSpanBlock (Just (Format "html")) (Para contents) =
    return $ Plain [Span ("", [], [("style", style)]) contents]
    where style = "display: block; margin-bottom: 16px;\
                 \ font-family: Arial, Helvetica, sans-serif;"
paraToSpanBlock _ b = return b

main :: IO ()
main = toJSONFilter paraToSpanBlock
Кстати, здесь тоже есть захардкоженный стиль, так что и этот фильтр можно переписать в стиле imgStyleFromMeta. Update. Запилил новый фильтр styleFromMeta, который умеет делать то же самое, что и imgStyleFromMeta (при этом с возможностью применять разные стили для разных изображений и форматов документа), и paraToSpanBlock. Кроме того, в новом фильтре доступны настройки стилей встроенных (inline) изображений и ссылок. Подробности на странице проекта.

6 комментариев:

  1. Здравствуйте!

    Нужен совет по Pandoc. Только познакомилась с этой программой и многое не до конца понятно.
    Подскажите пожалуйста, подходят ли фильтры для множественных изменений в документе.
    Возможно ли получать информацию о предшественнике и последователе элемента в JSON когда выполняется функция из фильтра?
    Хочу расширить Markdown нужными элементами, но пока не вижу возможности в фильтре проверки соседей элемента в JSON.
    Или для больших задач лучше написать отдельный скрипт изменяющий JSON?

    ОтветитьУдалить
    Ответы
    1. Здравствуйте. Да, фильтры pandoc в основном и нужны для проведения множественной замены в документе. По сути они производят типизированную подстановку, то есть изменяют определенный тип элементов (или заменяют его на другой) во всем документе. Например в этой статье: http://lin-techdet.blogspot.ru/2014/01/vim-publishhelper.html происходит замена всех элементов типа CodeBlock на элементы RawBlock. Правда, не всех ... Поскольку CodeBlock - это элемент со сложной иерархией, имеется возможность вытащить его подэлементы, в частности атрибуты языка и т.п. Я просто добавил новый атрибут hl и проверяю задан ли он в документе и если да, то какой, если он задан и равен vim, то производится замена на RawBlock с подсветкой из vim.

      В этой статье представлен фильтр, который также систематически подменяет ссылки на изображения и просто ссылки на основании наличия дополнительного элемента типа MathInline в иерархии типа ссылки (см. исходный код на https://github.com/lyokha/styleFromMeta). Мало того, здесь имеется возможность определять standalone изображения, то есть изображения, находящиеся в отдельном параграфе (это соответствует образцу b@(Para [Image (Style style : Alt (alt)) tgt]) в исходном коде фильтра). Это все примеры того, как вы можете определять "соседние" элементы в иерархии JSON.

      Я думаю, что для систематической типизированной замены элементов исходного дерева документа создание фильтра - самое лучшее и естественное решение.

      Кстати, есть возможность писать фильтры на python - см. https://github.com/jgm/pandoc/wiki/Pandoc-Filters

      Удалить
  2. Спасибо большое за ответ!

    Как я поняла из документации, фильтр принимает функцию и применяет ее последовательно к каждому элементу JSON дерева.
    Поэтому у меня и возникли вопросы к соседним элементам. Если функция принимает на вход Блок, то мы имеет доступ и к его внутренним Inline элементам.
    А есть ли возможность просмотреть какой Блок элемент следует за текущим? Например если функция работает с Para, посмотреть если следующий за ним блочный элемент BulletList и на этом основании делать изменения с Para? Возможно ли это, ведь функция не получает весь документ.

    Мне нужно написать большой фильтр работающий и с Block и Inline производящий различные замены.
    Является ли фильтр элегантным решением для такой задачи?

    ОтветитьУдалить
    Ответы
    1. Посмотрите на тип фильтра, описанного в данной статье. Он соответствует

      styleFromMeta :: Maybe Format -> Pandoc -> IO Pandoc

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

      Соответственно, во-первых, вам доступен весь документ, если вам это действительно нужно, а во-вторых, вы можете получить зависимости расположения элементов внутри дерева. Это уже дело техники. Pandoc предоставляет для этого служебную функцию walk, кроме того, вы можете использовать паттерн матчинг для деконструкции элементов дерева. Я не думаю, что вы можете напрямую сопоставлять соседние элементы, но если вы знаете их возможных предков в иерархии (не могу сказать навскидку, что может быть общим предком Para и BulletList - видимо, какие-то корневые узлы дерева), то вы сможете обнаружить все интересные для вас места в документе.

      Что касается элегантности, то я думаю, что использование фильтра и есть самое элегантное решение. Если вы умеете работать с haskell, то я вообще проблем не вижу, но можно писать фильтры и на python, как я уже говорил.

      Удалить
    2. Я тут пример для вас написал: http://lpaste.net/121331

      Смотрите комментарии в коде, вкратце, эта штука переносит содержимое Para перед элементом BulletList в этот самый лист как первый его элемент, работает только в руте документа, но можно и расширить вообще на все возможные случаи: просто просмотрите все элементы, которые содержат тип списка [Block] отсюда https://hackage.haskell.org/package/pandoc-types-1.12/docs/Text-Pandoc-Definition.html и вызовите тело приведенной функции для всех возможных сигнатур (это потому что последовательная пара Para, BulletList может быть только элементом [Block].

      Удалить
  3. Спасибо Вам огромное за помощь!!!
    Буду пробовать писать)))

    ОтветитьУдалить