feat(#3): Use Latex for PDF output
This commit is contained in:
@@ -23,6 +23,15 @@ FROM eclipse-temurin:21-jre-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install TeXLive and LuaLaTeX
|
||||
RUN apk add --no-cache \
|
||||
texlive-luatex \
|
||||
texmf-dist-plaingeneric \
|
||||
texmf-dist-latexrecommended \
|
||||
texmf-dist-latexextra \
|
||||
texmf-dist-langgerman \
|
||||
texmf-dist-fontsextra
|
||||
|
||||
RUN addgroup -S spring && adduser -S spring -G spring
|
||||
USER spring:spring
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
ext {
|
||||
openHtmlVersion = '1.0.10'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
||||
@@ -39,11 +35,6 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
implementation "com.openhtmltopdf:openhtmltopdf-core:$openHtmlVersion"
|
||||
implementation "com.openhtmltopdf:openhtmltopdf-pdfbox:$openHtmlVersion"
|
||||
implementation "com.openhtmltopdf:openhtmltopdf-java2d:$openHtmlVersion"
|
||||
implementation "com.openhtmltopdf:openhtmltopdf-slf4j:$openHtmlVersion"
|
||||
implementation "com.openhtmltopdf:openhtmltopdf-svg-support:$openHtmlVersion"
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
implementation 'org.postgresql:postgresql'
|
||||
implementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form
|
||||
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexEscaper
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexExportModel
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexFormElement
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexPdfRenderer
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSection
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.LatexSubSection
|
||||
import com.betriebsratkanzlei.legalconsenthub.application_form.export.latex.RichTextToLatexConverter
|
||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElement
|
||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSection
|
||||
import com.betriebsratkanzlei.legalconsenthub.form_element.FormElementSubSection
|
||||
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionOperator
|
||||
import com.betriebsratkanzlei.legalconsenthub.form_element.VisibilityConditionType
|
||||
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.thymeleaf.TemplateEngine
|
||||
import org.thymeleaf.context.Context
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class ApplicationFormFormatService(
|
||||
private val templateEngine: TemplateEngine,
|
||||
private val richTextToLatexConverter: RichTextToLatexConverter,
|
||||
private val pdfRenderer: LatexPdfRenderer,
|
||||
) {
|
||||
fun generatePdf(applicationForm: ApplicationForm): ByteArray {
|
||||
val htmlContent = generateHtml(applicationForm)
|
||||
val latexContent = generateLatex(applicationForm)
|
||||
return pdfRenderer.render(latexContent)
|
||||
}
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
PdfRendererBuilder()
|
||||
.useFastMode()
|
||||
.withHtmlContent(htmlContent, null)
|
||||
.toStream(outputStream)
|
||||
.run()
|
||||
fun generateLatex(applicationForm: ApplicationForm): String {
|
||||
val filteredForm = filterVisibleElements(applicationForm)
|
||||
val exportModel = buildLatexExportModel(filteredForm)
|
||||
|
||||
return outputStream.toByteArray()
|
||||
val context =
|
||||
Context().apply {
|
||||
setVariable("applicationForm", exportModel)
|
||||
}
|
||||
return templateEngine.process("application_form_latex_template", context)
|
||||
}
|
||||
|
||||
fun generateHtml(applicationForm: ApplicationForm): String {
|
||||
@@ -38,6 +50,83 @@ class ApplicationFormFormatService(
|
||||
return templateEngine.process("application_form_template", context)
|
||||
}
|
||||
|
||||
private fun buildLatexExportModel(applicationForm: ApplicationForm): LatexExportModel {
|
||||
val dateFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
|
||||
return LatexExportModel(
|
||||
id = applicationForm.id,
|
||||
name = LatexEscaper.escape(applicationForm.name),
|
||||
organizationId = LatexEscaper.escape(applicationForm.organizationId),
|
||||
employer = LatexEscaper.escape("Arbeitgeber der Organisation ${applicationForm.organizationId}"),
|
||||
worksCouncil = LatexEscaper.escape("Betriebsrat der Organisation ${applicationForm.organizationId}"),
|
||||
createdAt = applicationForm.createdAt?.format(dateFormatter) ?: "",
|
||||
sections =
|
||||
applicationForm.formElementSections.map { section ->
|
||||
LatexSection(
|
||||
title = LatexEscaper.escape(section.title),
|
||||
description = LatexEscaper.escape(section.description),
|
||||
subsections =
|
||||
section.formElementSubSections.map { subsection ->
|
||||
LatexSubSection(
|
||||
title = LatexEscaper.escape(subsection.title),
|
||||
subtitle = LatexEscaper.escape(subsection.subtitle),
|
||||
elements =
|
||||
subsection.formElements.map { element ->
|
||||
LatexFormElement(
|
||||
title = LatexEscaper.escape(element.title),
|
||||
description = LatexEscaper.escape(element.description),
|
||||
value = renderElementValue(element),
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderElementValue(element: FormElement): String =
|
||||
when (element.type.name) {
|
||||
"TEXTFIELD", "TEXTAREA" -> {
|
||||
val value = element.options.firstOrNull()?.value
|
||||
if (value.isNullOrBlank()) "Keine Eingabe" else LatexEscaper.escape(value)
|
||||
}
|
||||
"DATE" -> {
|
||||
val value = element.options.firstOrNull()?.value
|
||||
if (value.isNullOrBlank()) {
|
||||
"Kein Datum ausgewählt"
|
||||
} else {
|
||||
try {
|
||||
LocalDate.parse(value).format(DateTimeFormatter.ofPattern("dd.MM.yyyy"))
|
||||
} catch (e: Exception) {
|
||||
LatexEscaper.escape(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
"RICH_TEXT" -> {
|
||||
val value = element.options.firstOrNull()?.value
|
||||
if (value.isNullOrBlank()) "Keine Eingabe" else richTextToLatexConverter.convertToLatex(value)
|
||||
}
|
||||
"SELECT", "CHECKBOX" -> {
|
||||
val selected = element.options.filter { it.value == "true" }.map { it.label }
|
||||
if (selected.isEmpty()) {
|
||||
"Keine Auswahl getroffen"
|
||||
} else {
|
||||
selected.joinToString(
|
||||
", ",
|
||||
) { LatexEscaper.escape(it) }
|
||||
}
|
||||
}
|
||||
"RADIOBUTTON" -> {
|
||||
val selected = element.options.firstOrNull { it.value == "true" }?.label
|
||||
if (selected == null) "Keine Auswahl getroffen" else LatexEscaper.escape(selected)
|
||||
}
|
||||
"SWITCH" -> {
|
||||
if (element.options.any { it.value == "true" }) "Ja" else "Nein"
|
||||
}
|
||||
else -> "Keine Auswahl getroffen"
|
||||
}
|
||||
|
||||
private fun filterVisibleElements(applicationForm: ApplicationForm): ApplicationForm {
|
||||
val allElements = collectAllFormElements(applicationForm)
|
||||
val formElementsByRef = buildFormElementsByRefMap(allElements)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||
|
||||
object LatexEscaper {
|
||||
fun escape(text: String?): String {
|
||||
if (text == null) return ""
|
||||
return text
|
||||
.replace("\\", "\\textbackslash{}")
|
||||
.replace("{", "\\{")
|
||||
.replace("}", "\\}")
|
||||
.replace("$", "\\$")
|
||||
.replace("&", "\\&")
|
||||
.replace("#", "\\#")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
.replace("^", "\\textasciicircum{}")
|
||||
.replace("~", "\\textasciitilde{}")
|
||||
.replace("\n", "\\\\")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class LatexExportModel(
|
||||
val id: UUID?,
|
||||
val name: String,
|
||||
val organizationId: String,
|
||||
val employer: String,
|
||||
val worksCouncil: String,
|
||||
val sections: List<LatexSection>,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class LatexSection(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val subsections: List<LatexSubSection>,
|
||||
)
|
||||
|
||||
data class LatexSubSection(
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val elements: List<LatexFormElement>,
|
||||
)
|
||||
|
||||
data class LatexFormElement(
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val value: String,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Service
|
||||
class LatexPdfRenderer {
|
||||
private val logger = LoggerFactory.getLogger(LatexPdfRenderer::class.java)
|
||||
|
||||
fun render(latexContent: String): ByteArray {
|
||||
val tempDir = Files.createTempDirectory("latex-export-").toFile()
|
||||
try {
|
||||
val texFile = File(tempDir, "main.tex")
|
||||
texFile.writeText(latexContent)
|
||||
|
||||
val extraPaths = listOf("/Library/TeX/texbin", "/usr/local/bin", "/usr/bin", "/bin")
|
||||
val lualatexExecutable =
|
||||
extraPaths
|
||||
.map { File(it, "lualatex") }
|
||||
.firstOrNull { it.exists() && it.canExecute() }
|
||||
?.absolutePath ?: "lualatex"
|
||||
|
||||
logger.info("Using lualatex executable: $lualatexExecutable")
|
||||
|
||||
// Run lualatex twice to resolve TOC and references
|
||||
repeat(2) {
|
||||
val processBuilder =
|
||||
ProcessBuilder(
|
||||
lualatexExecutable,
|
||||
"-halt-on-error",
|
||||
"-interaction=nonstopmode",
|
||||
"-file-line-error",
|
||||
"-output-directory=${tempDir.absolutePath}",
|
||||
texFile.absolutePath,
|
||||
).apply {
|
||||
directory(tempDir)
|
||||
redirectErrorStream(true)
|
||||
}
|
||||
|
||||
// Ensure LaTeX binaries are in the path (common issue on macOS/local dev)
|
||||
val env = processBuilder.environment()
|
||||
val currentPath = env["PATH"] ?: ""
|
||||
env["PATH"] = extraPaths
|
||||
.filter { File(it).exists() && !currentPath.contains(it) }
|
||||
.joinToString(
|
||||
File.pathSeparator,
|
||||
postfix = if (currentPath.isNotEmpty()) File.pathSeparator else "",
|
||||
) +
|
||||
currentPath
|
||||
|
||||
// Set TEXMFVAR and TEXMFCACHE to tempDir to avoid "no writeable cache path" error in Docker
|
||||
env["TEXMFVAR"] = tempDir.absolutePath
|
||||
env["TEXMFCACHE"] = tempDir.absolutePath
|
||||
|
||||
val process = processBuilder.start()
|
||||
|
||||
val completed = process.waitFor(90, TimeUnit.SECONDS)
|
||||
val output = process.inputStream.bufferedReader().readText()
|
||||
|
||||
if (!completed) {
|
||||
process.destroyForcibly()
|
||||
throw RuntimeException("LaTeX compilation timed out")
|
||||
}
|
||||
|
||||
if (process.exitValue() != 0) {
|
||||
logger.error("LaTeX compilation failed:\n$output")
|
||||
throw RuntimeException("LaTeX compilation failed with exit code ${process.exitValue()}")
|
||||
}
|
||||
}
|
||||
|
||||
val pdfFile = File(tempDir, "main.pdf")
|
||||
if (!pdfFile.exists()) {
|
||||
throw RuntimeException("PDF file was not generated")
|
||||
}
|
||||
|
||||
return pdfFile.readBytes()
|
||||
} finally {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.application_form.export.latex
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class RichTextToLatexConverter(
|
||||
private val objectMapper: ObjectMapper,
|
||||
) {
|
||||
fun convertToLatex(jsonString: String?): String {
|
||||
if (jsonString.isNullOrBlank()) return ""
|
||||
|
||||
return try {
|
||||
val root = objectMapper.readTree(jsonString)
|
||||
processNode(root)
|
||||
} catch (e: Exception) {
|
||||
// Fallback to escaped plain text if JSON parsing fails (e.g. legacy HTML)
|
||||
// In a real scenario, we might want to strip HTML tags here too.
|
||||
LatexEscaper.escape(jsonString.replace(Regex("<[^>]*>"), ""))
|
||||
}
|
||||
}
|
||||
|
||||
private fun processNode(node: JsonNode): String {
|
||||
val type = node.get("type")?.asText() ?: return ""
|
||||
val content = node.get("content")
|
||||
|
||||
return when (type) {
|
||||
"doc" -> processContent(content)
|
||||
"paragraph" -> processContent(content) + "\n\n"
|
||||
"heading" -> {
|
||||
val level = node.get("attrs")?.get("level")?.asInt() ?: 1
|
||||
val cmd =
|
||||
when (level) {
|
||||
1 -> "\\subsubsection*{"
|
||||
2 -> "\\paragraph*{"
|
||||
else -> "\\subparagraph*{"
|
||||
}
|
||||
cmd + processContent(content) + "}\n\n"
|
||||
}
|
||||
"bulletList" -> "\\begin{itemize}\n" + processContent(content) + "\\end{itemize}\n"
|
||||
"orderedList" -> "\\begin{enumerate}\n" + processContent(content) + "\\end{enumerate}\n"
|
||||
"listItem" -> "\\item " + processContent(content) + "\n"
|
||||
"blockquote" -> "\\begin{quotation}\n" + processContent(content) + "\\end{quotation}\n"
|
||||
"codeBlock" -> "\\begin{verbatim}\n" + processContent(content) + "\\end{verbatim}\n"
|
||||
"text" -> {
|
||||
val text = node.get("text")?.asText() ?: ""
|
||||
val escapedText = LatexEscaper.escape(text)
|
||||
applyMarks(escapedText, node.get("marks"))
|
||||
}
|
||||
"hardBreak" -> "\\\\\n"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun processContent(content: JsonNode?): String {
|
||||
if (content == null || !content.isArray) return ""
|
||||
val sb = StringBuilder()
|
||||
for (node in content) {
|
||||
sb.append(processNode(node))
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun applyMarks(
|
||||
text: String,
|
||||
marks: JsonNode?,
|
||||
): String {
|
||||
if (marks == null || !marks.isArray) return text
|
||||
var result = text
|
||||
for (mark in marks) {
|
||||
val markType = mark.get("type")?.asText() ?: continue
|
||||
result =
|
||||
when (markType) {
|
||||
"bold" -> "\\textbf{$result}"
|
||||
"italic" -> "\\textit{$result}"
|
||||
"underline" -> "\\underline{$result}"
|
||||
"strike" -> "\\sout{$result}" // Requires ulem package
|
||||
"link" -> {
|
||||
val href = mark.get("attrs")?.get("href")?.asText() ?: ""
|
||||
"\\href{$href}{$result}"
|
||||
}
|
||||
else -> result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.betriebsratkanzlei.legalconsenthub.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.thymeleaf.templatemode.TemplateMode
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
|
||||
|
||||
@Configuration
|
||||
class LatexThymeleafConfig {
|
||||
@Bean
|
||||
fun latexTemplateResolver(): ClassLoaderTemplateResolver =
|
||||
ClassLoaderTemplateResolver().apply {
|
||||
prefix = "templates/"
|
||||
suffix = ".tex"
|
||||
templateMode = TemplateMode.TEXT
|
||||
characterEncoding = "UTF-8"
|
||||
order = 1
|
||||
checkExistence = true
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import jakarta.persistence.Embeddable
|
||||
|
||||
@Embeddable
|
||||
class FormOption(
|
||||
@Column(nullable = false, name = "option_value")
|
||||
@Column(nullable = false, name = "option_value", columnDefinition = "TEXT")
|
||||
var value: String,
|
||||
@Column(nullable = false)
|
||||
var label: String,
|
||||
|
||||
@@ -57,7 +57,7 @@ create table form_element_options
|
||||
processing_purpose smallint not null check (processing_purpose between 0 and 3),
|
||||
form_element_id uuid not null,
|
||||
label varchar(255) not null,
|
||||
option_value varchar(255) not null
|
||||
option_value TEXT not null
|
||||
);
|
||||
|
||||
create table form_element
|
||||
@@ -136,7 +136,9 @@ alter table if exists application_form_version
|
||||
add constraint FKpfri4lhy9wqfsp8esabedkq6c
|
||||
foreign key (application_form_id)
|
||||
references application_form
|
||||
on delete cascade;
|
||||
on
|
||||
delete
|
||||
cascade;
|
||||
|
||||
alter table if exists application_form_version
|
||||
add constraint FKl6fbcrvh439gbwgcvvfyxaggi
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
\documentclass[
|
||||
fontsize=11pt,
|
||||
paper=a4,
|
||||
DIV=12,
|
||||
BCOR=5mm,
|
||||
ngerman
|
||||
]{scrartcl}
|
||||
|
||||
\usepackage{fontspec}
|
||||
\usepackage{babel}
|
||||
\usepackage{microtype}
|
||||
\usepackage{hyperref}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{tcolorbox}
|
||||
\usepackage[normalem]{ulem}
|
||||
|
||||
\hypersetup{
|
||||
colorlinks=true,
|
||||
linkcolor=black,
|
||||
filecolor=black,
|
||||
urlcolor=blue,
|
||||
pdftitle={[[${applicationForm.name}]]},
|
||||
}
|
||||
|
||||
\title{Betriebsvereinbarung}
|
||||
\subtitle{[[${applicationForm.name}]]}
|
||||
\date{[[${applicationForm.createdAt}]]}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\maketitle
|
||||
|
||||
\vspace{2cm}
|
||||
|
||||
\section*{Zwischen}
|
||||
|
||||
\begin{tcolorbox}[colback=white, colframe=gray!50, title=Arbeitgeber]
|
||||
[[${applicationForm.employer}]]
|
||||
\end{tcolorbox}
|
||||
|
||||
\vspace{1cm}
|
||||
|
||||
\section*{und dem}
|
||||
|
||||
\begin{tcolorbox}[colback=white, colframe=gray!50, title=Betriebsrat]
|
||||
[[${applicationForm.worksCouncil}]]
|
||||
\end{tcolorbox}
|
||||
|
||||
\newpage
|
||||
\tableofcontents
|
||||
\newpage
|
||||
|
||||
\section{Gegenstand der Vereinbarung}
|
||||
Dieses Dokument enthält die Details der Betriebsvereinbarung "[[${applicationForm.name}]]" (ID: [[${applicationForm.id}]]).
|
||||
|
||||
[# th:each="section : ${applicationForm.sections}"]
|
||||
\section{[[${section.title}]]}
|
||||
[# th:if="${section.description}"]
|
||||
\textit{[[${section.description}]]}
|
||||
[/]
|
||||
|
||||
[# th:each="subsection : ${section.subsections}"]
|
||||
\subsection{[[${subsection.title}]]}
|
||||
[# th:if="${subsection.subtitle}"]
|
||||
\textit{[[${subsection.subtitle}]]}
|
||||
[/]
|
||||
|
||||
[# th:each="element : ${subsection.elements}"]
|
||||
\paragraph{[[${element.title}]]}
|
||||
[# th:if="${element.description}"]
|
||||
\textit{\small [[${element.description}]]}
|
||||
[/]
|
||||
|
||||
\begin{tcolorbox}[colback=gray!5, colframe=gray!20, arc=0mm, boxrule=0.5pt]
|
||||
[[${element.value}]]
|
||||
\end{tcolorbox}
|
||||
[/]
|
||||
[/]
|
||||
[/]
|
||||
|
||||
\vspace{3cm}
|
||||
|
||||
\begin{minipage}[t]{0.45\textwidth}
|
||||
\rule{\textwidth}{0.4pt}\\
|
||||
Ort, Datum\\
|
||||
(Für den Arbeitgeber)
|
||||
\end{minipage}
|
||||
\hfill
|
||||
\begin{minipage}[t]{0.45\textwidth}
|
||||
\rule{\textwidth}{0.4pt}\\
|
||||
Ort, Datum\\
|
||||
(Für den Betriebsrat)
|
||||
\end{minipage}
|
||||
|
||||
\end{document}
|
||||
|
||||
Reference in New Issue
Block a user