Ein weiteres DSL auf Kotlin oder wie ich PDF von React gedruckt habe



Sie können nicht einfach eine in React geschriebene Seite aufnehmen und drucken: Es gibt Seitentrennzeichen und Eingabefelder. Außerdem möchte ich ein Rendering einmal schreiben, damit es sowohl ReactDom als auch reguläres HTML generiert, das in PDF konvertiert werden kann.

Das Schwierigste ist, dass React eine eigene DSL und HTML eine eigene hat. Wie kann man dieses Problem lösen? Schreibe noch einen!

Ich hätte fast vergessen, dass alles in Kotlin geschrieben wird, also ist dies eigentlich ein Artikel über Kotlin dsl.

Warum brauchen wir unser eigenes Uruk-hai?


Mein Projekt enthält viele Berichte, die alle gedruckt werden müssen. Hierfür gibt es verschiedene Möglichkeiten:

  • Spielen Sie mit Druckstilen, verstecken Sie alles, was Sie nicht benötigen, und hoffen Sie, dass alles in Ordnung ist. Es werden nur Schaltflächen, Filter und dergleichen so gedruckt, wie sie sind. Und wenn es viele Tabellen gibt, muss sich jede auf einer separaten Seite befinden. Und persönlich machen mich die hinzugefügten Links, Daten usw., die beim Drucken von der Website herauskommen, wütend
  • Versuchen Sie, beim Reagieren eine spezielle Bibliothek zu verwenden, die PDF rendern kann. Ich habe dieses gefunden, es ist Beta und darin können Sie anscheinend normale Reaktionskomponenten nicht wiederverwenden.
  • Verwandeln Sie HTML in Leinwand und machen Sie PDF daraus. Dafür benötigen wir jedoch HTML ohne Schaltflächen und dergleichen. Es muss in einem versteckten Element gerendert werden, um es später zu drucken. Es scheint jedoch nicht, dass Sie mit dieser Option Seitenumbrüche steuern können.

Am Ende habe ich beschlossen, Code zu schreiben, der sowohl ReactDom als auch HTML generieren kann. Ich sende den HTML-Code an das Backend, um die PDF-Datei zu drucken, indem ich unterwegs spezielle Tags zu Seitenumbrüchen einfüge.

Kotlin verfügt über eine Ebenenbibliothek für die Arbeit mit React, die typsichere DSL für die Arbeit mit React bietet. Wie es im Allgemeinen aussieht, finden Sie in meinem vorherigen Artikel .

JetBrains hat auch eine Bibliothek zum Generieren von HTML geschrieben . Es ist plattformübergreifend, d.h. Es kann sowohl in Java als auch in JS verwendet werden. Dies ist auch dsl, sehr ähnlich in der Struktur.

Wir müssen einen Weg finden, zwischen Bibliotheken zu wechseln, je nachdem, ob wir ReactDom oder reines HTML benötigen.

Welches Material haben wir?


Nehmen Sie zum Beispiel eine Tabelle mit einem Suchfeld in der Kopfzeile. So sieht das Rendern von Tabellen in React und HTML aus:
reagieren
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 { +"" } } } } } 



Unsere Aufgabe ist es, die linke und rechte Seite des Tisches zu kombinieren.

Lassen Sie uns zunächst herausfinden, was der Unterschied ist:

  1. In HTML werden die colSpan style und colSpan auf der obersten Ebene in React für das verschachtelte attr-Objekt zugewiesen
  2. Stil füllt sich anders. Wenn dies in HTML reguläres CSS als Zeichenfolge ist, handelt es sich in React um ein js-Objekt, dessen Feldnamen sich aufgrund von JS-Einschränkungen geringfügig von Standard-CSS unterscheiden.
  3. In der React-Version verwenden wir Eingaben für die Suche, in HTML zeigen wir einfach den Text an. Dies geht bereits von der Problemstellung aus.

Gut und das Wichtigste: Dies sind verschiedene DSL mit verschiedenen Verbrauchern und verschiedenen APIs. Für den Compiler sind sie völlig unterschiedlich. Es ist unmöglich, sie direkt zu kreuzen, daher müssen Sie eine Ebene schreiben, die fast gleich aussieht, aber sowohl mit React-API als auch mit HTML-API arbeiten kann.

Bauen Sie das Skelett zusammen


Zeichnen Sie vorerst einfach eine Tablette aus einer leeren Zelle:

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

Wir haben einen HTML-Baum und zwei Möglichkeiten, ihn zu verarbeiten. Die klassische Lösung besteht darin, zusammengesetzte Muster und Besuchermuster zu implementieren. Nur haben wir keine Schnittstelle für Besucher. Warum - es wird später gesehen.

Die Haupteinheiten sind ParentTag und TagWithParent. ParentTag wird durch das HTML-Tag aus der Kotlin-API generiert (Gott sei Dank wird es sowohl in HTML als auch in der React-API verwendet), und TagWithParent speichert das Tag selbst und zwei Funktionen, die es in zwei API-Varianten in das übergeordnete Element einfügen.

 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 ) 

Warum brauchst du so viele Generika? Das Problem ist, dass dsl für HTML beim Kompilieren sehr streng ist. Wenn Sie in React td von überall aufrufen können, auch von einem div, dann können Sie es im Fall von HTML nur aus dem Kontext von tr aufrufen. Daher müssen wir den Kontext für die Kompilierung überall in Form von Generika ziehen.

Die meisten Tags werden ungefähr gleich geschrieben:

  1. Wir implementieren zwei Besuchsmethoden. Eine für React, eine für HTML. Sie sind für das endgültige Rendering verantwortlich. Diese Methoden fügen Stile, Klassen und dergleichen hinzu.
  2. Wir schreiben eine Erweiterung, die das Tag in das übergeordnete Element einfügt.

Hier ist ein Beispiel für 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) } 


Schließlich können Sie erklären, warum die Schnittstelle für Besucher nicht verwendet wurde. Das Problem ist, dass tr sowohl in den Kopf als auch in den Körper eingefügt werden kann. Ich konnte dies nicht im Rahmen einer Schnittstelle ausdrücken. Es kamen vier Überladungen der Besuchsfunktion heraus.

Eine Reihe von Duplikaten, die nicht vermieden werden können
 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() } } } 


Fleisch bauen


Fügen Sie der Zelle Text hinzu:

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

Das Fokussieren mit '+' ist ganz einfach: Es reicht aus, nur unaryPlus in Tags neu zu definieren, die möglicherweise Text enthalten.

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

Auf diese Weise können Sie im Kontext von td oder th '+' aufrufen, wodurch dem Baum ein Tag mit Text hinzugefügt wird.

Formen Sie die Haut


Jetzt müssen wir uns mit Stellen befassen, die sich im HTML unterscheiden, und auf die API reagieren. Ein kleiner Unterschied zu colSpan wird von selbst gelöst, aber der Unterschied in der Stilbildung ist komplizierter. Wenn jemand nicht weiß, dass Stil in React ein JS-Objekt ist und Sie keinen Bindestrich im Feldnamen verwenden können. Daher wird stattdessen camelCase verwendet. In der HTML-API möchten wir reguläres CSS von uns. Wir brauchen wieder dies und das gleichzeitig.

Ich könnte versuchen, camelCase automatisch zum Bindestrich zu bringen und es wie in React api zu belassen, aber ich weiß nicht, ob es immer funktionieren wird. Deshalb habe ich eine weitere Ebene geschrieben:

Wer nicht faul ist, kann sehen, wie es aussieht
 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 } ) } } 


Ja, ich weiß, wenn Sie eine weitere CSS-Eigenschaft wünschen - fügen Sie dieser Klasse hinzu. Ja, und eine Karte mit einem Konverter wäre einfacher zu implementieren. Aber typsicher. Ich benutze sogar stellenweise Aufzählungen. Wenn ich nicht für mich selbst schreiben würde, würde ich die Frage vielleicht anders lösen.

Ich habe ein wenig geschummelt und diese Verwendung der resultierenden Klasse zugelassen:

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

So geht's: Im Feld attr.style ist standardmäßig bereits ein leerer Style () vorhanden. Wenn Sie Operator Fun Invoke definieren, kann das Objekt als Funktion verwendet werden, d. H. Sie können attrs.style() aufrufen, obwohl style ein Feld und keine Funktion ist. Bei einem solchen Aufruf müssen die im Operator Fun Invoke angegebenen Parameter übergeben werden. In diesem Fall ist dies ein Parameter - Rückruf: Stil. () -> Einheit. Da dies ein Lambda ist, sind (Klammern) optional.

Verschiedene Rüstungen anprobieren


Es bleibt zu lernen, wie man Eingaben in React und nur Text in HTML zeichnet. Ich möchte diese Syntax erhalten:

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

So funktioniert es: Die React-Funktion nimmt ein Lambda für die Rreact-API und gibt das eingefügte Tag zurück. Auf dem Tag können Sie die Infix-Funktion aufrufen und das Lambda an die HTML-API übergeben. Mit dem Infix-Modifikator kann HTML ohne Punkt aufgerufen werden. Sehr ähnlich zu if {} else {}. Und wie in if-else ist der HTML-Aufruf optional, er hat sich mehrmals als nützlich erwiesen.

Implementierung
 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 } 


Sarumans Mal


Noch eine Berührung. Es ist erforderlich, ParentTag und TagWithParent von einer speziell gewickelten Schnittstelle mit einer speziell gewickelten Annotation zu erben, auf der eine spezielle Annotation @DslMarker steht , die bereits aus dem Kern der Sprache stammt:

 @DslMarker annotation class StyledTableMarker @StyledTableMarker interface Tag 

Dies ist notwendig, damit der Compiler keine seltsamen Aufrufe wie diese schreiben kann:

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

Es ist jedoch unklar, wer daran denken würde, so etwas zu schreiben ...

In die Schlacht!


Alles ist bereit, um eine Tabelle vom Anfang des Artikels an zu zeichnen, aber dieser Code generiert bereits sowohl ReactDom als auch HTML. Schreiben Sie einmal laufen überall!

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

Beachten Sie (*) - dies ist genau die gleiche Suchfunktion wie in der Originalversion der Tabelle für React. Es ist nicht erforderlich, alles auf die neue DSL zu übertragen, sondern nur allgemeine Tags.

Was könnte das Ergebnis eines solchen Codes funktionieren? Hier ist ein Beispiel eines PDF-Ausdrucks eines Berichts aus meinem Projekt. Natürlich habe ich alle Zahlen und Namen durch zufällige ersetzt. Zum Vergleich ein PDF-Ausdruck derselben Seite, jedoch vom Browser. Artefakte vom Aufbrechen einer Tabelle zwischen Seiten bis zum Überlagern von Text.

Wenn Sie dsl schreiben, erhalten Sie viel zusätzlichen Code, der sich ausschließlich auf die Verwendungsform konzentriert. Darüber hinaus werden viele Kotlin-Funktionen verwendet, an die Sie im Alltag nicht einmal denken.

Vielleicht wird es in anderen Fällen anders sein, aber in diesem Fall gab es auch viele Duplikate, die ich nicht loswerden konnte (soweit ich weiß, verwendet JetBarins die Codegenerierung, um die HTML-Bibliothek zu schreiben).

Aber es stellte sich heraus, dass ich dsl fast ähnlich wie die React- und HTML-API erstellt habe (ich habe fast nicht geguckt). Interessanterweise haben wir neben der Bequemlichkeit von dsl die volle Kontrolle über das Rendern. Sie können getrennten Seiten ein Seiten-Tag hinzufügen. Sie können das " Akkordeon " beim Drucken erweitern. Und Sie können versuchen, einen Weg zu finden, diesen Code auf dem Server wiederzuverwenden und HTML bereits für Suchmaschinen zu generieren.

PS Sicher gibt es Möglichkeiten, PDF einfacher zu drucken

Rübe mit Quelle für den Artikel

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


All Articles