Как стать автором
Обновить

Нормальные таблицы в Markdown

Время на прочтение5 мин
Количество просмотров28K


Таблицы Markdown — это ад кромешный:


  1. В ячейках нельзя написать текст длиннее пары слов, а тем более список.
  2. Если диалект и позволяет пункт 1, это неудобно форматировать.
  3. Если ячейки не выровнены, таблицу невозможно читать.
  4. Нет поддержки однотипных таблиц и автоматики, вроде нумерации строк.

Пришло время написать фильтр для Pandoc, рисующий таблицы из структурированного YAML, с нумерацией строк, горизонтальной ориентацией, шаблонами граф, и заодно разобраться, как писать Lua-фильтры.


Тексты я обычно пишу в Markdown и конвертирую в целевой формат с помощью Pandoc. Это программа, которая преобразует документы между форматами, например, из Markdown можно получить и HTML, и другой диалект MD, и DOCX, и PDF (всего более 30 входных и более 50 выходных форматов). Pandoc Markdown имеет много удобных расширений для ссылок, сносок, подписей, формул.


Pandoc работает как композиция функций (еще бы, он же написан на Haskell): конкретный входной формат → абстрактное представление документа → конкретный выходной формат. Абстрактное представление можно изменять при помощи фильтров, написанных на языке Lua. Фильтрам не требуется знать о выходном формате, но они могут учитывать его.


Наш фильтр будет искать в абстрактном представлении блоки кода на условном языке table, читать YAML внутри них и генерировать абстрактные представления таблиц, которые Pandoc сам выдаст в целевом формате.


pandoc --lua-filter table.lua input.md -o output.html

Какие есть альтернативы и чем они хуже?


  • HTML-таблицы работают только в Markdown и конвертируются только в HTML; решается только проблема богатого форматирования в ячейках.
  • Генераторы таблиц требуют переключаться из текстового редактора, в них неудобно редактировать собственно содержимое ячеек (пример).
  • Плагины редакторов (Emacs Org-Mode, плагины VIM) не универсальны и не всегда доступны.

Напротив, с фильтром для итоговых таблиц работает pandoc-crossref и все плюшки Pandoc. Фильтр можно использовать и для генерации стандартных таблиц Markdown, указав соответствующий выходной формат. Из недостатков:


  • Нельзя объединять ячейки, Pandoc не поддерживает этого (пока).
  • Для горизонтальных таблиц стилизацию приходится делать средствами выходного формата, например, через CSS.

Описание таблицы включает три части:


  1. Структура таблицы


    Упорядоченный список граф (столбцов):


    • Как минимум, у столбца должен быть заголовок (title).
    • Чтобы можно было переставлять столбцы, не трогая данные, должен быть указан атрибут записи, отображаемый в столбце (id).
    • Специальные столбцы не имеют id, а имеют описание, как их заполнять. Для начала нужен порядковый номер (special: number).
    • Выравнивание столбца (align).

    Также таблица может быть вертикальной или горизонтальной (orientation). В последнем случае графы будут строками.


  2. Свойства таблицы: ID для ссылок (id) и подпись (caption). Pandoc позволяет делать подписи к таблицам, но не к блокам кода.


  3. Данные в виде массива словарей YAML.



Структура может быть общей для нескольких таблиц, поэтому можно описать её как непосредственно с таблицей, так и один раз в метаданных (front-matter), после чего сослаться на именованный шаблон (template).


План реализации:


  1. Из метаданных документа формируем словарь шаблонов.


  2. Для каждого блока кода с классом table:


    1. Разбираем YAML таблицы.
    2. Если указан шаблон, берем его из словаря, иначе заполняем шаблон из YAML.
    3. Заполняем индивидуальные свойства таблицы из YAML.
    4. Формируем записи таблицы из YAML (запись — это строка в обычной таблице или столбец в горизонтальной).
    5. «Рисуем» таблицу по шаблону, свойствам и записям.


Верхний уровень реализуется как по писаному (весь код доступен по ссылке в конце статьи):


function Pandoc(doc)
    local meta_templates = doc.meta['table-templates']
    if meta_templates then
        for name, value in pairs(meta_templates) do
            templates[name] = parse_template(value)
        end
    end

    local blocks = pandoc.walk_block(pandoc.Div(doc.blocks), {
        CodeBlock = create_table
    })
    return pandoc.Pandoc(blocks, doc.meta)
end

Функция parse_template() немного преобразует формат метаданных. Pandoc представляет их значения как объекты MetaBlock и MetaInline. Из них делаются либо простые строки функцией pandoc.utils.stringify() (например, ориентация), либо визуальные элементы (например, блок текста в заголовке столбца).


Насчет отладки. В документации Pandoc много примеров, но не очень подробно описаны типы. Для отладки фильтров удобно иметь функцию дампа переменных. Серьезные библиотеки печатают слишком много подробностей, я предпочитаю один из простых вариантов.


Функции преобразования метаданных в элементы документа
local function to_inlines(content)
    if content == nil then
        return {}
    elseif type(content) == 'string' then
        return {pandoc.Str(content)}
    elseif type(content) == 'number' then
        return to_inlines(tostring(content))
    elseif content.t == 'MetaInlines' then
        inlines = {}
        for i, item in ipairs(content) do
            inlines[i] = item
        end
        return inlines
    end
end

local function to_blocks(content)
    if (type(content) == 'table') and content.t == 'MetaBlocks' then
        return content
    else
        return {pandoc.Plain(to_inlines(content))}
    end
end

Функция create_table() вызывается для каждого блока кода в тройных бэктиках.


Нас интересуют только блоки кода «на языке» table:


if not contains('table', block.classes) then
    return block
end

Чтобы разобрать YAML внутри блока кода, формируем документ, состоящий только из YAML-метаданных, разбираем его Pandoc и оставляем только метаданные:


local meta = pandoc.read('---\n' .. block.text .. '\n---').meta

Далее из meta читается ссылка на шаблон или структура таблицы и свойства конкретной таблицы.


Функция fill_table() читает из meta данные по атрибутам, указанным в описании граф. На этом же этапе, если графа отмечена как специальная, генерируется ее содержимое:


local data = {}
for i, serie in ipairs(template.series) do
    if serie.special == 'number' then
        data[i] = to_blocks(#datum + 1)
    else
        data[i] = to_blocks(item[serie.id])
    end
end

Функция format_table() формирует итоговый массив ячеек в зависимости от ориентации таблицы и создает абстрактный объект таблицы. Нужно отметить, что если ширины или заголовки должны быть заданы для всех столбцов либо ни для какого, иначе Pandoc просто не создаст таблицу.


Готовый скрипт можно положить в ~/.local/share/pandoc (data-директорию Pandoc), чтобы обращаться к нему по имени из любого места.


P. S.


Насчет учета выходного формата фильтрами. Например, я пишу спойлеры в Pandoc так:


::: {.spoiler title="Заголовок"}
Содержимое спойлера.
:::

Спойлеров нет в модели документа Pandoc, поэтому фильтр должен выдавать «сырые» блоки примерно следующим образом. Разумеется, реальный код (spoiler.lua) должен учитывать выходной формат через переменную FORMAT, причем не механически: фрагмент ниже выдает raw-блоки в HTML, хотя выходной формат — markdown.


function Div(el)
    if not el.attr or not contains('spoiler', el.attr.classes) then
        return el
    end

    local title = el.attr.attributes['title'] or 'Спойлер'
    table.insert(el.content, 1,
        pandoc.RawBlock('html', '<' .. 'spoiler title="' .. title .. '">', 'RawBlock'))
    table.insert(el.content,
        pandoc.RawBlock('html', '<' .. '/spoiler>', 'RawBlock'))
    return el.content
end

Ссылки


Теги:
Хабы:
+12
Комментарии6

Публикации