Un autre DSL sur Kotlin ou comment j'ai imprimé un PDF à partir de React



Vous ne pouvez pas simplement prendre et imprimer une page écrite en React: il y a des séparateurs de page, des champs de saisie. De plus, je veux écrire une fois un rendu afin qu'il génère à la fois ReactDom et du HTML simple, qui peut être converti en PDF.

La partie la plus difficile est que React a son propre dsl, et html a le sien. Comment résoudre ce problème? Écrivez-en un autre!

J'ai presque oublié, tout sera écrit en Kotlin, donc c'est en fait un article sur Kotlin dsl.

Pourquoi avons-nous besoin de notre propre Uruk-hai?


Il y a beaucoup de rapports dans mon projet et tous doivent pouvoir être imprimés. Il existe plusieurs options pour ce faire:

  • Jouez avec les styles d'impression, cachez tout ce dont vous n'avez pas besoin et espérez que tout ira bien. Seuls les boutons, filtres et similaires seront imprimés tels quels. Et aussi, s'il y a beaucoup de tableaux, il faut que chacun soit sur une page distincte. Et personnellement, les liens ajoutés, les dates, etc. qui sortent lors de l'impression à partir du site me rendent furieux
  • Essayez d'utiliser une bibliothèque spécialisée sur react, qui peut rendre le PDF. J'ai trouvé celui-ci , il s'agit de la version bêta et il semble que vous ne puissiez pas réutiliser les composants de réaction ordinaires.
  • Transformez HTML en canevas et faites-en un PDF. Mais pour cela, nous avons besoin de HTML, sans boutons et similaires. Il devra être rendu dans un élément caché afin de l'imprimer plus tard. Mais il ne semble pas que cette option vous permette de contrôler les sauts de page.

Au final, j'ai décidé d'écrire du code capable de générer à la fois ReactDom et HTML. J'enverrai le code HTML au backend pour imprimer le PDF en insérant des balises spéciales sur les sauts de page en cours de route.

Kotlin possède une bibliothèque de couches pour travailler avec React qui fournit un dsl de type sécurisé pour travailler avec React. À quoi cela ressemble en général peut être trouvé dans mon article précédent.

JetBrains a également écrit une bibliothèque pour générer du HTML . Il est multiplateforme, c'est-à-dire il peut être utilisé à la fois en Java et en JS. C'est aussi dsl, très similaire dans la structure.

Nous devons trouver un moyen de basculer entre les bibliothèques selon que nous avons besoin de ReactDom ou de HTML pur.

Quel matériel avons-nous?


Par exemple, prenez un tableau avec un champ de recherche dans l'en-tête. Voici à quoi ressemble le rendu des tableaux sur React et HTML:
réagir
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 { +"" } } } } } 



Notre tâche consiste à combiner les côtés gauche et droit de la table.

Voyons d'abord quelle est la différence:

  1. En html, les colSpan style et colSpan affectées au niveau supérieur, dans React, sur l'objet imbriqué attr
  2. Le style se remplit différemment. Si, en HTML, il s'agit d'un CSS standard sous forme de chaîne, alors dans React, il s'agit d'un objet js dont les noms de champ sont légèrement différents du CSS standard en raison des limitations de JS.
  3. Dans la version React, nous utilisons l'entrée pour la recherche, en HTML nous affichons simplement le texte. Cela commence déjà à partir de l'énoncé du problème.

Eh bien et le plus important: ce sont des DSL différents avec différents consommateurs et différentes API. Pour le compilateur, ils sont complètement différents. Il est impossible de les croiser directement, vous devrez donc écrire un calque qui aura presque le même aspect, mais qui pourra fonctionner avec React api et HTML api.

Assembler le squelette


Pour l'instant, il suffit de dessiner une tablette à partir d'une cellule vide:

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

Nous avons une arborescence HTML et deux façons de la traiter. La solution classique consiste à implémenter des modèles composites et visiteurs. Seulement, nous n'aurons pas d'interface pour les visiteurs. Pourquoi - cela sera vu plus tard.

Les unités principales seront ParentTag et TagWithParent. ParentTag est généré par la balise HTML de l'API Kotlin (Dieu merci, il est utilisé à la fois en HTML et dans l'API React), et TagWithParent stocke la balise elle-même et deux fonctions qui l'insèrent dans le parent dans deux variantes d'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 ) 

Pourquoi avez-vous besoin de tant de génériques? Le problème est que dsl pour HTML est très strict lors de la compilation. Si dans React vous pouvez appeler td de n'importe où, même à partir d'un div, alors dans le cas de HTML, vous ne pouvez l'appeler qu'à partir du contexte de tr. Par conséquent, nous devrons faire glisser le contexte de compilation sous forme de générique partout.

La plupart des balises sont orthographiées à peu près de la même manière:

  1. Nous mettons en œuvre deux méthodes de visite. Un pour React, un pour HTML. Ils sont responsables du rendu final. Ces méthodes ajoutent des styles, des classes et similaires.
  2. Nous écrivons une extension qui insère la balise dans le parent.

Voici un exemple de 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) } 


Enfin, vous pouvez expliquer pourquoi l'interface pour visiteur n'a pas été utilisée. Le problème est que tr peut être inséré à la fois dans la tête et dans le corps. Je ne pouvais pas l'exprimer dans le cadre d'une interface. Quatre surcharges de la fonction de visite sont sorties.

Un tas de duplication qui ne peut être évitée
 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() } } } 


Construire de la viande


Ajoutez du texte à la cellule:

  table { thead { tr { th { +": " } } } } 

La mise au point avec '+' est assez simple: il suffit de redéfinir unaryPlus dans les balises, qui peut inclure du texte, est suffisant.

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

Cela vous permet d'appeler '+' dans le contexte de td ou th, ce qui ajoutera une balise avec du texte à l'arborescence.

Scalp la peau


Maintenant, nous devons traiter des endroits qui diffèrent dans le html et réagir à l'api. Une petite différence avec colSpan est résolue par elle-même, mais la différence dans la formation du style est plus compliquée. Si quelqu'un ne le sait pas, dans React, le style est un objet JS et vous ne pouvez pas utiliser de trait d'union dans le nom du champ. Donc, camelCase est utilisé à la place. Dans l'API HTML, nous voulons des CSS réguliers de notre part. Nous avons encore besoin de ceci et de cela en même temps.

Je pourrais essayer de mettre automatiquement camelCase en trait d'union et de le laisser comme dans React api, mais je ne sais pas si cela fonctionnera toujours. Par conséquent, j'ai écrit une autre couche:

Qui n'est pas paresseux, peut voir à quoi ça ressemble
 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 } ) } } 


Oui, je sais si vous voulez une propriété CSS supplémentaire - ajoutez à cette classe. Oui, et une carte avec un convertisseur serait plus facile à mettre en œuvre. Mais sans danger pour les caractères. J'utilise même des énumérations à certains endroits. Peut-être que si je n’écrivais pas pour moi, je résoudrais la question différemment.

J'ai un peu triché et autorisé cette utilisation de la classe résultante:

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

Comment ça se passe: dans le champ attr.style, par défaut, il y a déjà un Style () vide. Si vous définissez l'opérateur fun invoke, l'objet peut être utilisé comme une fonction, c'est-à-dire vous pouvez appeler attrs.style() , bien que le style soit un champ, pas une fonction. Dans un tel appel, les paramètres spécifiés dans l'opérateur fun invoke doivent être passés. Dans ce cas, il s'agit d'un paramètre - rappel: Style. () -> Unité. Comme il s'agit d'un lambda, les (crochets) sont facultatifs.

Essayer différentes armures


Reste à apprendre comment dessiner des entrées dans React, et juste du texte en HTML. Je voudrais obtenir cette syntaxe:

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

Comment ça marche: La fonction react prend un lambda pour l'api Rreact et retourne la balise insérée. Sur la balise, vous pouvez appeler la fonction infixe et passer le lambda à l'api HTML. Le modificateur infix permet d'appeler html sans point. Très similaire à if {} else {}. Et comme dans if-else, l'appel html est facultatif, il a été utile plusieurs fois.

Implémentation
 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 } 


Marque de Saroumane


Une autre touche. Il est nécessaire d'hériter de ParentTag et TagWithParent d'une interface spécialement enroulée avec une annotation spécialement enroulée sur laquelle se trouve une annotation spéciale @DslMarker , déjà du cœur du langage:

 @DslMarker annotation class StyledTableMarker @StyledTableMarker interface Tag 

Ceci est nécessaire pour que le compilateur ne permette pas d'écrire des appels étranges comme ceux-ci:

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

On ne sait cependant pas qui penserait à écrire une telle chose ...

À la bataille!


Tout est prêt pour que nous puissions dessiner un tableau depuis le début de l'article, mais ce code va déjà générer à la fois ReactDom et HTML. Écrivez une fois n'importe où!

 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 { +"" } } } } 

Faites attention à (*) - c'est exactement la même fonction de recherche que dans la version originale du tableau pour React. Il n'est pas nécessaire de tout transférer vers le nouveau dsl, uniquement des balises courantes.

Quel pourrait être le résultat d'un tel code? Voici un exemple d'impression PDF d'un rapport de mon projet. Naturellement, j'ai remplacé tous les nombres et noms par des nombres aléatoires. A titre de comparaison, une impression PDF de la même page, mais par le navigateur. Artefacts de rupture d'un tableau entre les pages à superposition de texte.

Lors de l'écriture de dsl, vous obtenez beaucoup de code supplémentaire axé uniquement sur la forme d'utilisation. De plus, de nombreuses fonctionnalités de Kotlin sont utilisées, auxquelles vous ne pensez même pas dans la vie quotidienne.

Peut-être que dans d'autres cas, ce sera différent, mais il y a eu beaucoup de duplication, dont je n'ai pas pu me débarrasser (à ma connaissance, JetBarins utilise la génération de code pour écrire la bibliothèque HTML).

Mais il s'est avéré que je construisais dsl presque similaire en apparence à l'API React et HTML (je n'ai presque pas jeté un coup d'œil). Fait intéressant, avec la commodité de dsl, nous avons un contrôle total sur le rendu. Vous pouvez ajouter une balise de page à des pages distinctes. Vous pouvez étendre "l' accordéon " lors de l'impression. Et vous pouvez essayer de trouver un moyen de réutiliser ce code sur le serveur et de générer déjà du code HTML pour les moteurs de recherche.

PS Il y a sûrement des moyens d'imprimer des PDF plus facilement

Navet avec source pour l'article

Source: https://habr.com/ru/post/fr430238/


All Articles