Нельзя просто взять и распечатать страницу написанную на 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 попроще

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

Комментарии (11)


  1. antoo
    29.11.2018 20:32
    +1

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

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

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


    1. gnefedev Автор
      29.11.2018 21:12

      Я, если честно, не знал, что есть page-break-before и page-break-inside.
      К сожалению, это не решит всех проблем в моем случае. У меня упертый заказчик и ему нужна кнопка по которой скачивается PDF, а не открывается окно печати. Кроме того, не во всех браузерах получится конролировать ориентацию страницы.

      PS Я не фронтендер на самом деле. Просто в этом проекте сложная админка и я его делаю один.


      1. stopwaiting
        30.11.2018 00:55

        jsPDF — сохранит pdf без необходимости показывать окно печати
        codepen.io/AshikNesin/pen/KzgeYX


        1. gnefedev Автор
          30.11.2018 00:56

          А это уже не сочетается с page-break-before и page-break-inside. Скорее всего, эта библиотека переводит HTML в canvas и превращает это в pdf


  1. nomoreload
    30.11.2018 00:56

    Ну и на худой конец есть wkhtmltopdf, которому на вход можно подать страницу в stdin, а из stdout забрать байты pdf’а.


  1. Reey
    30.11.2018 04:05

    Из всех вариантов получить страницу в пдф, самым адекватным по тому что получается, по гибкости настроек мне показалось взять 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);
    });
    


    1. gnefedev Автор
      30.11.2018 10:48

      т.е. мне надо будет отпавить всю текущую страницу на бек с настроеными стилями для печати? А как пробросить стили?


      1. Reey
        30.11.2018 11:07

        Я бы отправлял на бэк (микросервис на 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);
        });
        


        1. gnefedev Автор
          30.11.2018 18:19

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


        1. gnefedev Автор
          30.11.2018 18:19

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


  1. bm13kk
    30.11.2018 11:11

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