Ещё один dsl на Kotlin или как я печатал PDF из react



    Нельзя просто взять и распечатать страницу написанную на React: есть разделители страниц, поля для ввода. Кроме того, хочется один раз написать рендеринг, чтобы он генерил как ReactDom, так и обычный HTML, который можно сконвертить в PDF.

    Самое сложное, что у React свой dsl, а у html свой. Как решить эту проблему? Написать ещё один!

    Чуть не забыл, всё это будет написано на Kotlin, так что, на самом деле, это статья о Kotlin dsl.

    Зачем нам нужен свой урук-хай?


    В моем проекте много отчетов и все их надо уметь распечатывать. Есть несколько вариантов как это сделать:

    • Поиграть со стилями печати, скрыть всё что не нужно и надеяться, что всё будет хорошо. Только кнопки, фильтры и подобное распечатаются как есть. А ещё, если таблиц много, надо чтобы каждая была на отдельной странице. И лично меня бесят добавленные ссылки, даты и т.д., которые вылезают при печати с сайта
    • Попробовать использовать какую-нибудь специализированную библиотеку на react, которая умеет рендерить PDF. Нашёл вот такую, это beta и в ней, похоже, нельзя переиспользовать обычные react компоненты.
    • HTML превратить в canvas и сделать из него PDF. Но для этого нам нужен HTML, без кнопок и подобного. Его надо будет рендерить в скрытом элементе, чтобы потом распечатать. Но не похоже, что в этом варианте можно проконтролировать разрывы страниц.

    В итоге я решил написать код, способный генерить как ReactDom, так и HTML. HTML отправлю на бекенд печатать PDF, вставив по дороге спецпометки про разрыв страниц.

    Для работы с React в Kotlin есть библиотека-прослойка, которая дает типобезопасный dsl для работы с React. Как это выглядит в целом, можно посмотреть в моей предидущей статье.

    Ещё JetBrains написала библиотеку для генерации HTML. Она кросплатформенная, т.е. её можно использовать как в Java, так и в JS. Это тоже dsl, очень похожий по структуре.

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

    Какой материал имеем?


    Для примера возьмем таблицу с поисковой строкой в заголовке. Так выглядит отрисовка таблицы на React и на HTML:
    react
    html
    fun RBuilder.renderReactTable(
      search: String,
      onChangeSearch: (String) -> Unit
    ) {
      table {
        thead {
          tr {
            th {
              attrs.colSpan = "2" //(1)
              attrs.style = js {
                border = "solid"
                borderColor = "red"
              } //(2)
              +"Поиск:"
              search(search, 
                onChangeSearch) //(3)
            }
          }
          tr {
            th { +"Имя" }
            th { +"Фамилия" }
          }
        }
        tbody {
          tr {
            td { +"Иван" }
            td { +"Иванов" }
          }
          tr {
            td { +"Петр" }
            td { +"Петров" }
          }
        }
      }
    }
    

    fun TagConsumer<*>.renderHtmlTable(
      search: String
    
    ) {
      table {
        thead {
          tr {
            th {
              colSpan = "2" //(1)
              style = """
                border: solid;
                border-color: red;
              """ //(2)
              +"Поиск: "
              +(search?:"") //(3)
              
            }
          }
          tr {
            th { +"Имя" }
            th { +"Фамилия" }
          }
        }
        tbody {
          tr {
            td { +"Иван" }
            td { +"Иванов" }
          }
          tr {
            td { +"Петр" }
            td { +"Петров" }
          }
        }
      }
    }
    



    Наша задача — объединить левую и правую стороны таблицы.

    Для начала разберемся в чем разница:

    1. В html версии style и colSpan присваиваются на верхнем уровне, в React — на вложенном объекте attr
    2. По-разному заполняется style. Если в HTML это обычный css в виде строки, то в React это js объект, названия полей у которого немного отличаются от стандартных css в силу ограничений JS.
    3. В React версии для поиска мы используем input, в HTML просто выводим текст. Это уже исходит из постановки задачи.

    Ну и самое важное: это разные dsl с разными консьюмерами и разным api. Для компилятора они абсолютно разные. Напрямую скрестить их невозможно, поэтому придется писать прослойку, которая будет выглядеть почти также, но сможет работать как с React api, так и с HTML api.

    Собираем скелет


    Пока просто рисуем табличку из одной пустой ячейки:

    table {
      thead {
        tr {
          th {
          }
        }
      }
    }
    

    У нас есть HTML дерево и два способа его обработки. Классическое решение — реализовать паттерны composite и visitor. Только у нас не будет интерфейса для visitor. Почему — будет видно позднее.

    В качестве основных единиц будут выступать ParentTag и TagWithParent. ParentTag дженифицирован по HTML тегу из api Kotlin (слава Богу, он используется как в HTML, так и в React api), а TagWithParent хранит сам тег и две функции, которые вставляют его в родителя в двух вариантах api.

    abstract class ParentTag<T : HTMLTag> {
        val tags: MutableList<TagWithParent<*, T>> = mutableListOf() // сюда будем добавлять детей
    
        protected fun RDOMBuilder<T>.withChildren() { ... } // вызываем reactAppender на всех детях
        protected fun T.withChildren() { ... } // вызываем htmlAppender на всех детях
    }
    
    class TagWithParent<T, P : HTMLTag>(
        val tag: T,
        val htmlAppender: (T, P) -> Unit,
        val reactAppender: (T, RDOMBuilder<P>) -> Unit
    )
    

    Зачем нужно столько дженериков? Проблема в том, что dsl для HTML очень строг при компиляции. Если в React можно вызывать td откуда угодно, хоть из div, то в случае HTML его можно вызвать только из контекста tr. Поэтому нам придется везде протаскивать контекст для компиляции в виде generic.

    Большая часть тегов пишется примерно одинаково:

    1. Реализуем два метода visit. Один для React, один для HTML. Они отвечают за итоговый рендеринг. В этих методах добавляются стили, классы и подобное.
    2. Пишем extension, который вставит тег в родителя.

    Вот пример THead
    class THead : ParentTag<THEAD>() {
        fun visit(builder: RDOMBuilder<TABLE>) {
            builder.thead {
                withChildren()
            }
        }
    
        fun visit(builder: TABLE) {
            builder.thead {
                withChildren()
            }
        }
    }
    
    fun Table.thead(block: THead.() -> Unit) {
        tags += TagWithParent(THead().also(block), THead::visit, THead::visit)
    }
    


    Наконец-то можно объяснить, почему не использовался интерфейс для visitor. Проблема в том, что tr может быть вставлен и в thead, и в tbody. Выразить это в рамках одного интерфейса мне не удалось. Вышло четыре перегрузки функции visit.

    Куча дублирования, которого не избежать
    class Tr(
        val classes: String?
    ) : ParentTag<TR>() {
        fun visit(builder: RDOMBuilder<THEAD>) {
            builder.tr(classes) {
                withChildren()
            }
        }
    
        fun visit(builder: THEAD) {
            builder.tr(classes) {
                withChildren()
            }
        }
    
        fun visit(builder: RDOMBuilder<TBODY>) {
            builder.tr(classes) {
                withChildren()
            }
        }
    
        fun visit(builder: TBODY) {
            builder.tr(classes) {
                withChildren()
            }
        }
    }
    


    Наращиваем мясо


    Надо добавить текст в ячейку:

      table {
        thead {
          tr {
            th {
              +"Поиск: "
            }
          }
        }
      }
    

    Фокус с '+' делается довольно просто: для этого достаточно переопределить unaryPlus в тегах, которые могут включать в себя текст.

    abstract class TableCell<T : HTMLTag> : ParentTag<T>() {
        operator fun String.unaryPlus() { ... }
    }
    

    Это позволяет вызывать '+', находясь в контексте td или th, что добавит тег с текстом в дерево.

    Лепим кожу


    Теперь надо разобраться с местами, которые отличаются в api html и react. Небольшая разница с colSpan решается сама собой, а вот различие в формировании style — посложнее. Если кто не знает, в React, style — это JS объект, а в имени поля нельзя использовать дефис. Так что вместо этого используется camelCase. В HTML api от нас хотят обычный css. Нам опять нужно и то и то одновременно.

    Можно было бы попробовать автоматически приводить camelCase к написанию через дефис и оставить как в React api, но всегда ли оно будет работать — не знаю. Поэтому написал ещё одну прослойку:

    Кому не лень, может посмотреть как это выглядит
    class Style {
        var border: String? = null
        var borderColor: String? = null
        var width: String? = null
        var padding: String? = null
        var background: String? = null
    
        operator fun invoke(callback: Style.() -> Unit) {
            callback()
        }
    
        fun toHtmlStyle(): String = properties
            .map { it.html to it.property(this) }
            .filter { (_, value) -> value != null }
            .joinToString("; ") { (name, value) -> "$name: $value" }
    
        fun toReactStyle(): String {
            val result = js("{}")
            properties
                .map { it.react to it.property(this) }
                .filter { (_, value) -> value != null }
                .forEach { (name, value) -> result[name] = value.toString() }
            return result.unsafeCast<String>()
        }
    
        class StyleProperty(
            val html: String,
            val react: String,
            val property: Style.() -> Any?
        )
    
        companion object {
            val properties = listOf(
                StyleProperty("border", "border") { border },
                StyleProperty("border-color", "borderColor") { borderColor },
                StyleProperty("width", "width") { width },
                StyleProperty("padding", "padding") { padding },
                StyleProperty("background", "background") { background }
            )
        }
    }
    


    Да, знаю, хочешь ещё одно css свойство — добавляй в этот класс. Да и мапа с конвертером была бы проще в реализации. Зато типобезопасно. Я даже енумы местами использую. Пожалуй, если бы я писал не для себя, то как-то иначе решил бы вопрос.

    Я немного схитрил и позволил вот такое использование получившегося класса:

    th {
      attrs.style {
        border = "solid"
        borderColor = "red"
      }
    }
    

    Как это выходит: в поле attr.style по-умолчанию уже лежит пустой Style(). Если определить operator fun invoke, то объект можно использовать как функцию, т.е. можно вызвать attrs.style(), хоть style — поле, а не функция. В такой вызов надо передавать те параметры, что указаны в operator fun invoke. В данном случае это один параметр — callback: Style.() -> Unit. Так как это лямбда, то (скобочки) не обязательны.

    Примеряем разные доспехи


    Осталось научиться в React нарисовать input, а в HTML просто текст. Хочется получить вот такой синтаксис:

    react {
      search(search, onChangeSearch)
    } html {
      +(search?:"")
    }
    

    Как это работает: функция react принимает лямбду для Rreact api и возвращает вставленный тег. На теге можно вызвать infix функцию и передать лямбду для HTML api. Модификатор infix позволяет вызывать html без точки. Очень похоже на if {} else {}. И как и в if-else, вызов html опционален, мне это несколько раз пригождалось.

    Реализация
    class ReactTag<T : HTMLTag>(
        private val block: RBuilder.() -> Unit = {}
    ) {
        private var htmlAppender: (T) -> Unit = {}
    
        infix fun html(block: (T).() -> Unit) {
            htmlAppender = block
        }
    ...
    }
    
    fun <T : HTMLTag> ParentTag<T>.react(block: RBuilder.() -> Unit): ReactTag<T> {
        val reactTag = ReactTag<T>(block)
        tags += TagWithParent<ReactTag<T>, T>(reactTag, ReactTag<T>::visit, ReactTag<T>::visit)
        return reactTag
    }
    


    Метка Сарумана


    Ещё один штрих. Надо отнаследовать ParentTag и TagWithParent от специально заведенного интерфейса со специально заведенной аннотацией на которой стоит специальная аннотация @DslMarker, уже из ядра языка:

    @DslMarker
    annotation class StyledTableMarker
    
    @StyledTableMarker
    interface Tag
    

    Это нужно, чтобы компилятор не позволял писать странные вызовы вроде таких:

    td {
        td { }
    }
    
    tr {
       thead { }
    }
    

    Непонятно, правда, кому в голову взбредет такое писать…

    В бой!


    У нас всё готово для того, чтобы нарисовать таблицу из начала статьи, но этот код уже будет формировать как ReactDom, так и HTML. Write once run anywhere!

    fun Table.renderUniversalTable(search: String?, onChangeSearch: (String?) -> Unit) {
      thead {
        tr {
          th {
            attrs.colSpan = 2
            attrs.style {
              border = "solid"
              borderColor = "red"
            }
            +"Поиск:"
            react {
              search(search, onChangeSearch) //(*)
            } html {
              +(search?:"")
            }
          }
        }
        tr {
          th { +"Имя" }
          th { +"Фамилия" }
        }
      }
      tbody {
        tr {
          td { +"Иван" }
          td { +"Иванов" }
        }
        tr {
          td { +"Петр" }
          td { +"Петров" }
        }
      }
    }
    

    Обратите внимание на (*) — здесь ровно та же функция search, что и в изначальном варианте таблицы для React. Нет необходимости переносить в новый dsl всё, только общие теги.

    Как может выглядеть результат работы такого кода? Вот пример PDF распечатки отчета из моего проекта. Естественно, все цифры и имена заменил на рандом. Для сравнения PDF распечатка той же страницы, но уже браузером. Артефакты от разрыва таблицы между страницами до наложения текста.

    При написании dsl получается много дополнительного кода нацеленного исключительно на форму использования. Причем используется очень много возможностей Kotlin, о которых в повседневии даже не думаешь.

    Возможно, в других случаях будет иначе, но в данном вышло ещё и очень много дублирования, от которого я так и не смог избавиться (насколько я знаю, JetBarins для написания библиотеки HTML использует кодогенерацию).

    Но зато у меня вышло построить dsl практически схожий по виду с React и HTML api (я почти не подглядывал). Интересно, что наряду с удобством получено dsl у нас есть полный контроль над рендерингом. Можно добавить тег page для разделения страниц. Можно разворачивать "аккордион" при печати. А можно попробовать найти способ переиспользовать этот код на сервере и генерить html уже для поисковиков.

    PS Наверняка, есть способы распечатать PDF попроще

    Репа с исходниками для статьи

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

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

      +2
      Прошу прощения, но зачем подключать дополнительные языки, JVM, писать DSLы, если задача версии для печати давно уже легко решается средствами CSS?

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

      Все, что вы описали, легко можно сделать на CSS (а в связке с JS так вообще что угодно), есть Print Media Queries, которые с точностью до миллиметра спозиционируют любой элемент на странице. Вы ведь фронтэндер, CSS один из ваших основных инструментов, нужно лишь документацию поглубже почитать. Начните с лекции от сотрудника Яндекса, например: www.youtube.com/watch?v=xVPCZFBpjsI
        +1
        Я, если честно, не знал, что есть page-break-before и page-break-inside.
        К сожалению, это не решит всех проблем в моем случае. У меня упертый заказчик и ему нужна кнопка по которой скачивается PDF, а не открывается окно печати. Кроме того, не во всех браузерах получится конролировать ориентацию страницы.

        PS Я не фронтендер на самом деле. Просто в этом проекте сложная админка и я его делаю один.
          0
          jsPDF — сохранит pdf без необходимости показывать окно печати
          codepen.io/AshikNesin/pen/KzgeYX
            0
            А это уже не сочетается с page-break-before и page-break-inside. Скорее всего, эта библиотека переводит HTML в canvas и превращает это в pdf
              0
              1) ага- jsPDF игнорирует css форматирование для печати.
              Но вы можете использовать волшебный коммент
              <!--ADD_PAGE-->
              для разбиения страниц
              2) pdf генерируется без участия canvas на основе HTML разметки страницы. Т е текст в pdf файле это действительно текст
              3) Эта библиотека поддерживает печать только спецсимволов и латиницы. Для всего остального нужно юзать html2canvas

              см пост jsPDF + canvas: экспорт в PDF многостраничной таблицы на русском языке
                +1
                1) и как мне это комент вставлять, если я на react всё нарисовал?
                3) спасибо за информацию. Категорическое нет для использования этой библиотеки в таком случае. Я и так не смог в своем вариенте символ рубля распечатать, но это я поленился нужный шрифт найти. А тут с кириллицей бороться.
        0
        Ну и на худой конец есть wkhtmltopdf, которому на вход можно подать страницу в stdin, а из stdout забрать байты pdf’а.
          +2
          Из всех вариантов получить страницу в пдф, самым адекватным по тому что получается, по гибкости настроек мне показалось взять puppeteer. Из-за того что он тянет полный хром с собой (подводный камень), пдф получаются как если бы прямо нажать на печать страницы в хроме. wkhtmltopdf, например, делает из страницы какое-то месиво с кривым выделением. Также есть возможность настроить как угодно страницу, подсунуть любой скрипт и стиль.

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

          пример на node.js
          const puppeteer = require("puppeteer");
          const path = require("path");
          const base = __dirname;
          
          (async () => {
          	const browser = await puppeteer.launch({});
          	const page = await browser.newPage();
          	await page.goto("file://" + path.resolve(base, "index.html"));
          	const height = await page.evaluate(
          		() => document.documentElement.clientHeight
          	);
          	await page.pdf({
          		path: path.resolve(base, "./output.pdf"),
          		margin: {
          			top: "2cm",
          			bottom: "2cm",
          			left: "1.5cm",
          			right: "2cm",
          		},
          		height: `${height}px`,
          	});
          
          	await browser.close();
          })().catch(e => {
          	console.error(e);
          });
          

            0
            т.е. мне надо будет отпавить всю текущую страницу на бек с настроеными стилями для печати? А как пробросить стили?
              0
              Я бы отправлял на бэк (микросервис на node со ждущим puppeteer) только ту часть страницы, которую составляет теоретический отчет. Прямо брал бы document.querySelector(".report").outerHTML и отправлял бы POST'om на сервер. А на сервере уже вставлял данный отчет в какой-то шаблон, с настроенными стилями и тд. В принципе стили можно на любом этапе присоединять.

              Как добавить стили: github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#pageaddstyletagoptions

              Опять же пример
              const puppeteer = require("puppeteer");
              const path = require("path");
              const base = __dirname;
              
              (async () => {
              	const browser = await puppeteer.launch({});
              	const page = await browser.newPage();
              	await page.goto("file://" + path.resolve(base, "index.html"));
              	const height = await page.evaluate(
              		() => document.documentElement.clientHeight
              	);
              	await page.addStyleTag({
              		content: `
              			button {
              				display: none;
              			}
              		`
              	});
              	await page.pdf({
              		path: path.resolve(base, "./output.pdf"),
              		margin: {
              			top: "2cm",
              			bottom: "2cm",
              			left: "1.5cm",
              			right: "2cm",
              		},
              		height: `${height}px`,
              	});
              
              	await browser.close();
              })().catch(e => {
              	console.error(e);
              });
              

                0

                Так я ровно тоже самое делаю. Только бек у меня на java, а не node.

                  0

                  Но генерирую для этого html на лету, а не беру со страницы

              –3

              Почему котлинистьі упорно раз за разом назьівают узкий набор функцьій неподходящим термином DSL? Где здесь отдельньій язьік?

                –1
                и что самое характерное, ответа ни у кого нет.
                  0
                  Вы хотите сказать, что HTML — это не DSL? Или конфиг gradle — не DSL?
                  Вас, наверное, смущает слово language. Думаю, отличие DSL от набора функций в наличии своего синтакисиса/семантики. Так, возможность вызова тех или иных функций возможна только в опрделенном контексте. Код, написанный в это статье не позволяет вставить текст в tr или tr в td. К этому прибавляется минимизация чего-либо не связанного с доменной моделью.
                  Как яркий пример, можно написать DSL для тестирования в стиле BDD, который будет выглядеть вот так:
                  val button = ...
                  on click button check { ... }
                  

                  Если это не новый маленький язык, то тогда DSL не существует.
                    0
                    html и конфиг — таки ДСЛ.

                    И синтаксис\семантика — одна их причин для создания ДСЛ. Но вторая — ограничить функционал. Когда внутри вашего шаблонизатора можна вставлять любую котлин функцию, почти в любом месте — это уже не domain specific.

                    Посмотрите на hibernate query language или regexp. Вот это примеры DSL, которые используют в перемежку с кодом.

                    BDD тоже хороший пример DSL, в большинстве случаев, но не в Вашем. Да там движек работает поверх регулярок и, мягко говоря, не оптимален. Но там есть очень жесткие ограничения, что вообще будет работать.

                    Именно жесткое сужение функционала делает ДСЛ полезным. Таким, что его можно быстро выучить и начать использовать. Даже не зная основного языка.
                      0
                      Если что, в конфиг gradle можно вставлять любой код, который тебе заблогорассудится, потому что это просто groove код.

                      Именно жесткое сужение функционала делает ДСЛ полезным


                      Это действительно так. Но это же его минус. Сколько шаблонизаторов HTML? Сколько попыток сделать BDD фреймфорк? Все они имеют проблемы из-за того, что рано или поздно им требуется поддержка регулярного языка. От этого возникают специфичные конструкции для for, if и подобного (я боюсь считать сколько РАЗНЫХ вариантов for я видел на этом фоне, иногда по два в одном DSL).

                      С другой стороны оптимально спроектированный DSL будет иметь серьезные ограничения по использованию. На DSL по HTML от JetBrains, как я понял, нельзя нарисовать HTML, который не будет являться XHTML. В DSL по React не меньше ограничений, что и при использовании typeScript + React. И использование этих DSL в перемешку с регулярноым кодом будет просто дурным тоном, как дурной тон не отделять stateless компоненты в React и переносить логику в шаблонизаторы.

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

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