diff --git a/legalconsenthub-backend/Dockerfile b/legalconsenthub-backend/Dockerfile index 2f89ab9..c7c8065 100644 --- a/legalconsenthub-backend/Dockerfile +++ b/legalconsenthub-backend/Dockerfile @@ -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 diff --git a/legalconsenthub-backend/build.gradle b/legalconsenthub-backend/build.gradle index 9b5ac59..4d879f8 100644 --- a/legalconsenthub-backend/build.gradle +++ b/legalconsenthub-backend/build.gradle @@ -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' diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt index de31438..e7d08da 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/ApplicationFormFormatService.kt @@ -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) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexEscaper.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexEscaper.kt new file mode 100644 index 0000000..cf4d4bc --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexEscaper.kt @@ -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", "\\\\") + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexExportModel.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexExportModel.kt new file mode 100644 index 0000000..794d9b3 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexExportModel.kt @@ -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, + val createdAt: String, +) + +data class LatexSection( + val title: String, + val description: String?, + val subsections: List, +) + +data class LatexSubSection( + val title: String, + val subtitle: String?, + val elements: List, +) + +data class LatexFormElement( + val title: String, + val description: String?, + val value: String, +) diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexPdfRenderer.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexPdfRenderer.kt new file mode 100644 index 0000000..15e131f --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/LatexPdfRenderer.kt @@ -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() + } + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/RichTextToLatexConverter.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/RichTextToLatexConverter.kt new file mode 100644 index 0000000..152490b --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/application_form/export/latex/RichTextToLatexConverter.kt @@ -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 + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/LatexThymeleafConfig.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/LatexThymeleafConfig.kt new file mode 100644 index 0000000..f877996 --- /dev/null +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/config/LatexThymeleafConfig.kt @@ -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 + } +} diff --git a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt index 937b93d..a68a178 100644 --- a/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt +++ b/legalconsenthub-backend/src/main/kotlin/com/betriebsratkanzlei/legalconsenthub/form_element/FormOption.kt @@ -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, diff --git a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql index 54843fe..c706ebb 100644 --- a/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql +++ b/legalconsenthub-backend/src/main/resources/db/migrations/001-schema.sql @@ -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 diff --git a/legalconsenthub-backend/src/main/resources/templates/application_form_latex_template.tex b/legalconsenthub-backend/src/main/resources/templates/application_form_latex_template.tex new file mode 100644 index 0000000..318702c --- /dev/null +++ b/legalconsenthub-backend/src/main/resources/templates/application_form_latex_template.tex @@ -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} + diff --git a/legalconsenthub/app/components/formelements/TheEditor.vue b/legalconsenthub/app/components/formelements/TheEditor.vue index d8680dd..e3916ea 100644 --- a/legalconsenthub/app/components/formelements/TheEditor.vue +++ b/legalconsenthub/app/components/formelements/TheEditor.vue @@ -2,8 +2,8 @@
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) }