Another dsl on Kotlin or how I printed a PDF from react
You can not just take and print a page written in React: there are page separators, input fields. In addition, I want to write a rendering once, so that it generates both ReactDom and plain HTML, which can be converted to PDF.
The hardest part is that React has its dsl, and html has its own. How to solve this problem? Write one more!
I almost forgot, all this will be written on Kotlin, so, in fact, this is an article about Kotlin dsl.
Why do we need our Uruk-Hai?
There are many reports in my project and all of them must be able to print. There are several options for how to do this:
- Play with print styles, hide all that is not necessary and hope that everything will be fine. Only buttons, filters and the like print as is. And also, if there are a lot of tables, it is necessary that each was on a separate page. And personally, I get beside the added links, dates, etc., that come out when printing from the site
- Try using some specialized library on react that can render PDF. I found this one , this is beta, and it seems that it is not possible to reuse ordinary react components.
- Turn HTML into a canvas and make a PDF out of it. But for this we need HTML, without buttons and the like. It will need to be rendered in a hidden item, and then printed. But it does not seem that in this version you can control page breaks.
In the end, I decided to write code that can generate both ReactDom and HTML. HTML will be sent to print backend PDF, inserting special marks on the way about page breaks.
To work with React, Kotlin has an interlayer library that provides type-safe dsl for working with React. How it looks in general, you can see in my previous article .
JetBrains has also written a library for generating HTML . It is cross-platform, i.e. It can be used in both Java and JS. This is also a dsl, very similar in structure.
We need to find a way to switch between libraries depending on whether we need ReactDom or pure HTML.
What material do we have?
For example, take a table with a search string in the header. This is how drawing a table in React and HTML looks like:
react | html |
---|---|
|
|
Our task is to combine the left and right sides of the table.
First, let's look at the difference:
- In the html version
style
andcolSpan
assigned at the top level, in React - on the nested object attr - Differently filled in style. If in HTML it is a regular css in the form of a string, then in React it is a js object, the field names of which are slightly different from the standard css due to JS restrictions.
- In the React version, we use input for the search, in HTML we just output the text. This is based on the problem statement.
And most importantly: they are different dsl with different consumers and different api. For the compiler, they are completely different. Directly cross them impossible, so you have to write a layer that will look almost the same, but can work with both React api, and with HTML api.
We collect the skeleton
For now, just draw a sign from one empty cell:
table {
thead {
tr {
th {
}
}
}
}
We have an HTML tree and two ways to handle it. The classic solution is to implement the composite and visitor patterns. Only we will not have an interface for visitor. Why - will be seen later.
ParentTag and TagWithParent will be the main units. ParentTag is jenified by the HTML tag from Kotlin api (thank God, it is used in both HTML and React api), and TagWithParent stores the tag itself and two functions that insert it into the parent in two api variants.
abstractclassParentTag<T : HTMLTag> {
val tags: MutableList<TagWithParent<*, T>> = mutableListOf() // сюда будем добавлять детейprotectedfun RDOMBuilder<T>.withChildren() { ... } // вызываем reactAppender на всех детяхprotectedfun T.withChildren() { ... } // вызываем htmlAppender на всех детях
}
classTagWithParent<T, P : HTMLTag>(
val tag: T,
val htmlAppender: (T, P) -> Unit,
val reactAppender: (T, RDOMBuilder<P>) -> Unit
)
Why do you need so many generics? The problem is that dsl for HTML is very strict at compilation. If in React you can call td from anywhere, even from a div, then in the case of HTML it can only be called from the context of tr. Therefore, we will have to drag everywhere the context to compile as generic.
Most tags are written about the same:
- We implement two visit methods. One for React, one for HTML. They are responsible for the final rendering. These methods add styles, classes and the like.
- We write extension which will insert a tag in the parent.
Here is an example of THead
classTHead : ParentTag<THEAD>() {
funvisit(builder: RDOMBuilder<TABLE>) {
builder.thead {
withChildren()
}
}
funvisit(builder: TABLE) {
builder.thead {
withChildren()
}
}
}
fun Table.thead(block: THead.() -> Unit) {
tags += TagWithParent(THead().also(block), THead::visit, THead::visit)
}
Finally, you can explain why the visitor interface was not used. The problem is that tr can be inserted in both thead and tbody. I could not express it within the framework of one interface. There were four overloads of the visit function.
A bunch of duplication that can't be avoided.
classTr(
val classes: String?
) : ParentTag<TR>() {
funvisit(builder: RDOMBuilder<THEAD>) {
builder.tr(classes) {
withChildren()
}
}
funvisit(builder: THEAD) {
builder.tr(classes) {
withChildren()
}
}
funvisit(builder: RDOMBuilder<TBODY>) {
builder.tr(classes) {
withChildren()
}
}
funvisit(builder: TBODY) {
builder.tr(classes) {
withChildren()
}
}
}
We increase meat
You need to add text to the cell:
table {
thead {
tr {
th {
+"Поиск: "
}
}
}
}
The trick with '+' is done quite simply: to do this, simply override unaryPlus in tags, which may include text.
abstract class TableCell<T : HTMLTag> : ParentTag<T>() {
operator fun String.unaryPlus() { ... }
}
This allows you to call '+', being in the context of td or th, which adds a tag with text to the tree.
Sculpt the skin
Now we need to understand the places that differ in api html and react. A small difference with colSpan is solved by itself, but the difference in the formation of the style is more complicated. If anyone does not know, in React, style is a JS object, and a hyphen cannot be used in the field name. So camelCase is used instead. In HTML, api want a regular css from us. We again need both this and that.
It would be possible to try to automatically bring camelCase to writing through a hyphen and leave it as in React api, but I don’t know whether it will always work. Therefore, I wrote another layer:
Who is not lazy, can see how it looks
classStyle{
var border: String? = nullvar borderColor: String? = nullvar width: String? = nullvar padding: String? = nullvar background: String? = nulloperatorfuninvoke(callback: Style.() -> Unit) {
callback()
}
funtoHtmlStyle(): String = properties
.map { it.html to it.property(this) }
.filter { (_, value) -> value != null }
.joinToString("; ") { (name, value) -> "$name: $value" }
funtoReactStyle(): 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>()
}
classStyleProperty(
val html: String,
val react: String,
val property: Style.() -> Any?
)
companionobject {
val properties = listOf(
StyleProperty("border", "border") { border },
StyleProperty("border-color", "borderColor") { borderColor },
StyleProperty("width", "width") { width },
StyleProperty("padding", "padding") { padding },
StyleProperty("background", "background") { background }
)
}
}
Yes, I know, if you want another css property, add to this class. Yes, and map to converter would be easier to implement. But type safe. I even use enums in places. Perhaps, if I had written not for myself, I would have somehow solved the question otherwise.
I cheated a little and allowed this use of the resulting class:
th {
attrs.style {
border = "solid"
borderColor = "red"
}
}
How it goes: in the field attr.style, by default, already empty Style () is placed. If you define operator fun invoke, then the object can be used as a function, i.e. you can call
attrs.style()
, although style is a field, not a function. In such a call, you must pass those parameters that are specified in operator fun invoke. In this case, this is one parameter - callback: Style. () -> Unit. Since this is lambda, then (brackets) are optional.Try on different armor
It remains to learn how to draw input in React, and just text in HTML. I would like to get this syntax:
react {
search(search, onChangeSearch)
} html {
+(search?:"")
}
How it works: the react function accepts lambda for Rreact api and returns the inserted tag. On the tag, you can call the infix function and pass the lambda for HTML api. The infix modifier allows you to call html without a dot. Very similar to if {} else {}. And as in the if-else, the html call is optional, it came up to me several times.
Implementation
classReactTag<T : HTMLTag>(
privateval block: RBuilder.() -> Unit = {}
) {
privatevar htmlAppender: (T) -> Unit = {}
infixfunhtml(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
}
Saruman's Mark
Another touch. It is necessary to inherit ParentTag and TagWithParent from a specially crafted interface with a specially crafted annotation on which there is a special annotation @DslMarker , already from the core of the language:
@DslMarkerannotationclassStyledTableMarker@StyledTableMarkerinterfaceTag
It is necessary that the compiler does not allow writing strange calls like these:
td {
td { }
}
tr {
thead { }
}
It is not clear, really, to whom it would bother to write such a thing ...
To battle!
We are ready to draw a table from the beginning of the article, but this code will already generate both ReactDom and 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 { +"Петров" }
}
}
}
Note the (*) - here is exactly the same search function as in the original version of the table for React. There is no need to transfer everything to the new dsl, only common tags.
What would the output of such a code look like? Here is an example PDF printout of a report from my project. Naturally, all the numbers and names replaced by random. For comparison, PDF printout of the same page, but by browser. Artifacts from breaking a table between pages to text overlay.
When writing dsl, you get a lot of additional code aimed solely at the form of use. And a lot of Kotlin features are used, which you don’t even think about in everyday life.
Perhaps in other cases it will be different, but there is also a lot of duplication, which I could not get rid of (as far as I know, JetBarins uses code generation for writing the HTML library).
But then I got to build dsl almost similar in appearance with React and HTML api (I almost didn’t peek). Interestingly, along with the convenience obtained dsl we have full control over the rendering. You can add a page tag to separate the pages. You can unfold the " accordion " when printing. And you can try to find a way to reuse this code on the server and generate html already for search engines.
PS Surely, there are ways to print PDF easier
Rep for the article source