feat(#3): Use Latex for PDF output

This commit is contained in:
2025-12-22 10:34:03 +01:00
parent 374c8d8905
commit 9999ac3bb4
12 changed files with 473 additions and 29 deletions

View File

@@ -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

View File

@@ -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'

View File

@@ -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)

View File

@@ -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", "\\\\")
}
}

View File

@@ -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,
)

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -2,8 +2,8 @@
<div class="bg-white dark:bg-white rounded-md border border-gray-200 dark:border-gray-200 overflow-hidden">
<UEditor
v-slot="{ editor }"
v-model="htmlContent"
content-type="html"
v-model="editorContent"
content-type="json"
:editable="!props.disabled"
:placeholder="t('applicationForms.formElements.richTextPlaceholder')"
:ui="{
@@ -26,6 +26,7 @@
<script setup lang="ts">
import { EmployeeDataCategory, ProcessingPurpose, type FormOptionDto } from '~~/.api-client'
import type { JSONContent } from '@tiptap/vue-3'
const { t } = useI18n()
@@ -38,13 +39,25 @@ const emit = defineEmits<{
(e: 'update:formOptions', value: FormOptionDto[]): void
}>()
const htmlContent = computed({
get: () => props.formOptions[0]?.value ?? '',
set: (newValue: string) => {
const editorContent = computed({
get: () => {
const rawValue = props.formOptions[0]?.value ?? ''
if (rawValue.trim().startsWith('{')) {
try {
return JSON.parse(rawValue) as JSONContent
} catch {
return rawValue // Fallback to HTML if JSON parse fails
}
}
return rawValue // Treat as HTML
},
set: (newValue: string | JSONContent) => {
const updatedOptions: FormOptionDto[] = [...props.formOptions]
const stringifiedValue = typeof newValue === 'string' ? newValue : JSON.stringify(newValue)
if (updatedOptions.length === 0) {
const createdOption: FormOptionDto = {
value: newValue,
value: stringifiedValue,
label: '',
processingPurpose: ProcessingPurpose.None,
employeeDataCategory: EmployeeDataCategory.None
@@ -52,7 +65,7 @@ const htmlContent = computed({
updatedOptions.push(createdOption)
} else {
const firstOption = updatedOptions[0]!
updatedOptions[0] = { ...firstOption, value: newValue }
updatedOptions[0] = { ...firstOption, value: stringifiedValue }
}
emit('update:formOptions', updatedOptions)
}