
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 thing 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 on Kotlin dsl.
Why do we need our uruk-hi?
There are a lot of 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 are printed as is. And also, if there are many 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 a beta, and it seems that you cannot reuse ordinary react components.
- HTML to turn into a canvas and make it a PDF. 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 embodiment, 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 the backend to print PDF, inserting special marks about the page break on the way.
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
|
---|
fun RBuilder.renderReactTable( search: String, onChangeSearch: (String) -> Unit ) { table { thead { tr { th { attrs.colSpan = "2"
| fun TagConsumer<*>.renderHtmlTable( search: String ) { table { thead { tr { th { colSpan = "2"
|
Our task is to combine the left and right sides of the table.
First, let's look at the difference:
- In the html versions,
style
and colSpan
assigned at the top level, in React, on the attached 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, whose field names are slightly different from 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 the most important thing: 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 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 - it will be seen later.
ParentTag and TagWithParent will be the main units. ParentTag is jenified by the HTML tag from the 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.
abstract class ParentTag<T : HTMLTag> { val tags: MutableList<TagWithParent<*, T>> = mutableListOf()
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 for compilation in the form of 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 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) }
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. 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() } } }
We increase meat
You need to add text to the cell:
table { thead { tr { th { +": " } } } }
The trick with '+' is made quite simple: to do this, simply redefine 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 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 } ) } }
Yes, I know, 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 comes out: in the attr.style field, by default, an empty Style () is already lying. 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 (parentheses) 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 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 }
Saruman's Mark
Another touch. You need to inherit ParentTag and TagWithParent from a specially crafted interface with a specially crafted annotation, which has a special
annotation @DslMarker , already from the core of the language:
@DslMarker annotation class StyledTableMarker @StyledTableMarker interface Tag
This is necessary so 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!
Everything is ready for us 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)
Note the (*) - here is exactly the same search function as in the original version of the table for React. 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 exclusively 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 to write 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 a simpler PDF
Repa with the source for the articleOther articles about Kotlin: