feat: Add Markdown export service for DOCX and PDF formats

- Implemented MarkdownExportService to convert Markdown text into DOCX and PDF formats.
- Supported Markdown elements include headings, bold, italic, blockquotes, tables, and specific emojis.
- Added methods for generating DOCX and PDF documents with proper formatting and rendering.
- Enhanced application properties to enable logging for production environment.
- Introduced a test-run script to reset project state and trigger processing via API.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Wolf G. Beckmann
2026-04-28 14:32:45 +02:00
parent 1816168d1f
commit 522bc21439
24 changed files with 2011 additions and 127 deletions

View File

@@ -19,6 +19,12 @@ FROM eclipse-temurin:21-jre
WORKDIR /app
# Emoji-Schriftart für PDF-Export (Java AWT nutzt fontconfig-Fallback automatisch)
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-color-emoji fontconfig \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
# Quarkus Fast-JAR Layout kopieren
COPY --from=build /build/target/quarkus-app/lib/ ./lib/
COPY --from=build /build/target/quarkus-app/*.jar ./
@@ -36,7 +42,7 @@ EXPOSE 8090
# DC_AI_OCR_MODEL OCR-Modell
# QUARKUS_REST_CLIENT_ORDS_API_URL ORDS-Basis-URL
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager -Djava.awt.headless=true"
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -sf http://localhost:8090/check/health || exit 1

0
dc-backend/history.log Normal file
View File

View File

@@ -24,7 +24,7 @@ spec:
env:
# ORDS-Verbindung (Service im selben Namespace)
- name: QUARKUS_REST_CLIENT_ORDS_API_URL
value: "http://ords:8080/ords/FRIGOSPED_APP"
value: "http://ords:8080/ords/ai_dev"
# API-Key für eingehende Anfragen (DC_API_KEY)
- name: DC_API_KEY
valueFrom:

View File

@@ -7,6 +7,8 @@ import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.ClientHeaderParam;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider;
/**
* MicroProfile REST Client für die ORDS /api/dc/-Endpunkte.
*
@@ -18,6 +20,7 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
* → @ClientHeaderParam(name = "Authorization", value = "${dc.ords.bearer-token}")
*/
@RegisterRestClient(configKey = "ords-api")
@RegisterProvider(OrdsLoggingFilter.class)
@Path("/api/dc")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@@ -94,6 +97,14 @@ public interface OrdsClient {
@Produces(MediaType.WILDCARD)
Response downloadFile(@PathParam("docId") long docId);
/**
* GET /api/dc/documents/:docId/texts
* Liest original_text und translated_text (für Resume nach Phase A).
*/
@GET
@Path("/documents/{docId}/texts")
OrdsDocumentTexts getTexts(@PathParam("docId") long docId);
/**
* PUT /api/dc/documents/:docId/texts
* Schreibt original_text und/oder translated_text zurück.

View File

@@ -3,8 +3,8 @@ package de.frigosped.dc.client;
import jakarta.annotation.Priority;
import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.client.ClientResponseContext;
import jakarta.ws.rs.client.ClientResponseFilter;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
@@ -12,6 +12,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
/**
* Loggt jede HTTP-Anfrage und -Antwort des ORDS REST Clients:
@@ -20,7 +21,7 @@ import java.util.Arrays;
* - Response-Status + Body
*/
@Provider
@Priority(Javax.ws.rs.Priorities.AUTHENTICATION + 1)
@Priority(jakarta.ws.rs.Priorities.AUTHENTICATION + 1)
public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFilter {
private static final Logger LOG = Logger.getLogger(OrdsLoggingFilter.class);
@@ -62,13 +63,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
} else {
body = entity.toString();
}
if (body.isBlank()) return null;
// Entity neu setzen — Stream nicht verbrauchen
ctx.setEntity(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)),
ctx.getHeaders().getFirst("Content-Type", MediaType.APPLICATION_OCTET_STREAM),
Long.valueOf(body.length()));
return body;
return body.isBlank() ? null : body;
}
private String readResponse(ClientResponseContext ctx) throws IOException {
@@ -81,7 +76,7 @@ public class OrdsLoggingFilter implements ClientRequestFilter, ClientResponseFil
ctx.setEntityStream(new ByteArrayInputStream(raw));
if (raw.length > 4000) {
return String.format("[%.4k bytes, trunc. zu 4000]", raw.length);
return String.format("[%d bytes, trunc. zu 4000]", raw.length);
}
String mediaType = ctx.getMediaType() != null ? ctx.getMediaType().toString() : "";

View File

@@ -0,0 +1,15 @@
package de.frigosped.dc.model;
public class ExportRequest {
private String content;
private String format; // DOCX | PDF
public ExportRequest() {}
public String getContent() { return content; }
public void setContent(String v) { this.content = v; }
public String getFormat() { return format; }
public void setFormat(String v) { this.format = v; }
}

View File

@@ -0,0 +1,27 @@
package de.frigosped.dc.model;
public class OrdsDocumentTexts {
private Long id;
private String originalText;
private String translatedText;
private Integer hasOriginalText;
private Integer hasTranslatedText;
public OrdsDocumentTexts() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getOriginalText() { return originalText; }
public void setOriginalText(String v) { this.originalText = v; }
public String getTranslatedText() { return translatedText; }
public void setTranslatedText(String v) { this.translatedText = v; }
public Integer getHasOriginalText() { return hasOriginalText; }
public void setHasOriginalText(Integer v) { this.hasOriginalText = v; }
public Integer getHasTranslatedText() { return hasTranslatedText; }
public void setHasTranslatedText(Integer v) { this.hasTranslatedText = v; }
}

View File

@@ -10,12 +10,14 @@ public class OrdsQuestion {
private Long categoryId;
private String categoryName;
private String categoryDescription;
private Integer categoryOrderNr;
private String questionText;
private String evaluationType; // z.B. SCORE, BINARY
private Integer threshold; // Schwellwert 0100
private String resultHandling; // ABWEICHUNG | HINWEIS
private String example0Percent; // Beispiel: Anforderung nicht erfüllt
private String example100Percent; // Beispiel: Anforderung voll erfüllt
private Integer orderNr;
private Integer rowVersion;
public OrdsQuestion() {}
@@ -50,6 +52,12 @@ public class OrdsQuestion {
public String getExample100Percent() { return example100Percent; }
public void setExample100Percent(String v) { this.example100Percent = v; }
public Integer getCategoryOrderNr() { return categoryOrderNr; }
public void setCategoryOrderNr(Integer v) { this.categoryOrderNr = v; }
public Integer getOrderNr() { return orderNr; }
public void setOrderNr(Integer v) { this.orderNr = v; }
public Integer getRowVersion() { return rowVersion; }
public void setRowVersion(Integer v) { this.rowVersion = v; }
}

View File

@@ -2,11 +2,23 @@ package de.frigosped.dc.model;
/**
* Request-Body für PUT /api/dc/projects/:id/progress
* {"progress": 45}
*
* Felder (alle optional null = nicht aktualisieren):
* progress 0100 Prozent
* currentPhase OCR | QUESTIONS | COMPLETED
* currentDocId ID des aktuell verarbeiteten Dokuments
* currentQuestionId ID der aktuell ausgewerteten Frage (null bei OCR)
* estimatedCompletionAt ISO-8601-Zeitstempel (Schätzung Fertigstellung)
*
* Jackson serialisiert camelCase → snake_case (quarkus.jackson.property-naming-strategy=SNAKE_CASE).
*/
public class ProgressRequest {
private Integer progress;
private String currentPhase;
private Long currentDocId;
private Long currentQuestionId;
private String estimatedCompletionAt;
public ProgressRequest() {}
@@ -14,6 +26,27 @@ public class ProgressRequest {
this.progress = progress;
}
public Integer getProgress() { return progress; }
public void setProgress(Integer v) { this.progress = v; }
public ProgressRequest(int progress, String currentPhase, Long currentDocId,
Long currentQuestionId, String estimatedCompletionAt) {
this.progress = progress;
this.currentPhase = currentPhase;
this.currentDocId = currentDocId;
this.currentQuestionId = currentQuestionId;
this.estimatedCompletionAt = estimatedCompletionAt;
}
public Integer getProgress() { return progress; }
public void setProgress(Integer v) { this.progress = v; }
public String getCurrentPhase() { return currentPhase; }
public void setCurrentPhase(String v) { this.currentPhase = v; }
public Long getCurrentDocId() { return currentDocId; }
public void setCurrentDocId(Long v) { this.currentDocId = v; }
public Long getCurrentQuestionId() { return currentQuestionId; }
public void setCurrentQuestionId(Long v) { this.currentQuestionId = v; }
public String getEstimatedCompletionAt() { return estimatedCompletionAt; }
public void setEstimatedCompletionAt(String v) { this.estimatedCompletionAt = v; }
}

View File

@@ -0,0 +1,59 @@
package de.frigosped.dc.resource;
import de.frigosped.dc.model.ExportRequest;
import de.frigosped.dc.service.MarkdownExportService;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
@Path("/export")
public class ExportResource {
private static final Logger LOG = Logger.getLogger(ExportResource.class);
@Inject
MarkdownExportService exportService;
/**
* POST /export/markdown
* Body: { "content": "...", "format": "DOCX" | "PDF" }
* Returns the binary file with appropriate Content-Type.
*/
@POST
@Path("/markdown")
@Consumes(MediaType.APPLICATION_JSON)
public Response convert(ExportRequest req) {
if (req == null || req.getContent() == null || req.getContent().isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("content must not be empty").build();
}
String format = req.getFormat() == null ? "PDF" : req.getFormat().toUpperCase();
try {
if ("DOCX".equals(format)) {
byte[] bytes = exportService.toDocx(req.getContent());
return Response.ok(bytes)
.header("Content-Type",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document")
.header("Content-Disposition", "attachment; filename=\"report.docx\"")
.build();
} else if ("PDF".equals(format)) {
byte[] bytes = exportService.toPdf(req.getContent());
return Response.ok(bytes)
.header("Content-Type", "application/pdf")
.header("Content-Disposition", "attachment; filename=\"report.pdf\"")
.build();
} else {
return Response.status(Response.Status.BAD_REQUEST)
.entity("format must be DOCX or PDF").build();
}
} catch (Exception e) {
LOG.errorf(e, "Export fehlgeschlagen (format=%s)", format);
return Response.serverError().entity("Export failed: " + e.getMessage()).build();
}
}
}

View File

@@ -8,9 +8,16 @@ import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Orchestriert den kompletten Prüf-Ablauf für ein Projekt:
@@ -18,7 +25,7 @@ import java.util.Map;
* 1. Projekt laden → IN_PROGRESS setzen
* 2. Dokumente herunterladen → OCR / Konvertierung → Texte speichern
* 3. Texte ins Deutsche übersetzen → gespeichert
* 4. Fragenkatalog für jeden Dokument-Text auswerten
* 4. Fragenkatalog für jeden Dokument-Text auswerten (Reihenfolge: question_id aufsteigend)
* 5. Ergebnisse Frage × Dokument in DB speichern
* 6. Projekt auf COMPLETED setzen
*
@@ -28,6 +35,8 @@ import java.util.Map;
public class CheckOrchestrationService {
private static final Logger LOG = Logger.getLogger(CheckOrchestrationService.class);
private static final DateTimeFormatter ISO_FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
@RestClient
OrdsClient ordsClient;
@@ -48,30 +57,27 @@ public class CheckOrchestrationService {
try {
// 1. Projekt laden
OrdsProject project = ordsClient.getProject(projectId);
LOG.infof("Projekt: '%s', Status: %s, Katalog: %d",
project.getName(), project.getStatus(), project.getCatalogId());
LOG.infof("Projekt: '%s', Status: %s", project.getName(), project.getStatus());
// Absicherung: nur PENDING starten
if (!"PENDING".equals(project.getStatus())) {
LOG.warnf("Projekt %d ist nicht PENDING (ist: %s) Abbruch",
projectId, project.getStatus());
// Nur abbrechen wenn bereits aktiv oder fertig (null = neues Projekt)
String status = project.getStatus();
if ("IN_PROGRESS".equals(status) || "COMPLETED".equals(status)) {
LOG.warnf("Projekt %d ist bereits %s Abbruch", projectId, status);
return;
}
// 2. Status → IN_PROGRESS
// 2. Status → IN_PROGRESS (setzt auch processing_started_at in ORDS)
Response startResp = ordsClient.startProject(projectId);
if (startResp.getStatus() == 409) {
LOG.warnf("Projekt %d konnte nicht gestartet werden (409) Abbruch", projectId);
return;
}
Instant startedAt = Instant.now();
// 3. Dokumente + Fragen laden
// 3. Dokumente laden
List<OrdsDocument> documents = ordsClient.getDocuments(projectId).getItems();
List<OrdsQuestion> questions = ordsClient
.getQuestions(project.getCatalogId()).getItems();
LOG.infof("Projekt %d: %d Dokument(e), %d Frage(n)",
projectId, documents.size(), questions.size());
LOG.infof("Projekt %d: %d Dokument(e)", projectId, documents.size());
if (documents.isEmpty()) {
LOG.warnf("Projekt %d hat keine Dokumente markiere als abgeschlossen", projectId);
@@ -79,79 +85,127 @@ public class CheckOrchestrationService {
return;
}
// 4. Fortschrittsberechnung
// Pro Dokument: 1 Schritt OCR/Übersetzung + questions.size() Auswertungsschritte
int totalSteps = documents.size() * (1 + questions.size());
int[] stepHolder = {0}; // Array für Verwendung in Lambda
// 4. Fragen pro Katalog laden (Katalog ist am Dokument, nicht am Projekt)
// Fragen werden nach question_id aufsteigend sortiert.
Map<Long, List<OrdsQuestion>> questionsByCatalog = new HashMap<>();
for (OrdsDocument doc : documents) {
Long catId = doc.getCatalogId();
if (catId != null && !questionsByCatalog.containsKey(catId)) {
List<OrdsQuestion> qs = ordsClient.getQuestions(catId).getItems()
.stream()
.sorted(Comparator
.comparingInt((OrdsQuestion q) ->
q.getCategoryOrderNr() != null ? q.getCategoryOrderNr() : Integer.MAX_VALUE)
.thenComparingInt(q ->
q.getOrderNr() != null ? q.getOrderNr() : Integer.MAX_VALUE))
.collect(Collectors.toList());
questionsByCatalog.put(catId, qs);
LOG.infof("Katalog %d: %d Frage(n) geladen", catId, qs.size());
}
}
// 5. Alte Ergebnisse löschen (ermöglicht Re-Processing)
// 5. Fortschrittsberechnung: 1 Schritt OCR + Anzahl Fragen des Dokument-Katalogs
int totalSteps = 0;
for (OrdsDocument doc : documents) {
Long catId = doc.getCatalogId();
int qCount = catId != null && questionsByCatalog.containsKey(catId)
? questionsByCatalog.get(catId).size() : 0;
totalSteps += 1 + qCount;
}
int[] stepHolder = {0};
// 6. Alte Ergebnisse löschen (ermöglicht Re-Processing)
Response delResp = ordsClient.deleteResults(projectId);
LOG.debugf("Alte Ergebnisse gelöscht: %s", delResp.getStatus());
LOG.debugf("Alte Ergebnisse gelöscht: HTTP %d", delResp.getStatus());
// 6. Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
// Texte sammeln (in-memory: vermeidet Re-Fetch aus ORDS)
Map<Long, String> originalTexts = new HashMap<>();
// ─── PHASE A: OCR + Übersetzung ───────────────────────────────
// ─── PHASE A: OCR + Übersetzung (wird übersprungen wenn Text bereits vorhanden) ──
for (OrdsDocument doc : documents) {
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
// Vor dem Schritt: Status auf "OCR für dieses Dokument" setzen
pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
doc.getId(), null, startedAt);
String originalText = "";
String translatedText = "";
String originalText = "";
try {
// Datei herunterladen
Response fileResp = ordsClient.downloadFile(doc.getId());
if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
doc.getId(), fileResp.getStatus());
} else {
byte[] fileBytes = fileResp.readEntity(byte[].class);
// OCR / Konvertierung → Markdown
originalText = documentProcessingService.extractText(
fileBytes, doc.getMimeType(), doc.getFilename());
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length());
// Übersetzung ins Deutsche
translatedText = evaluationService.translate(originalText);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length());
// Texte in ORDS zurückschreiben
ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText));
if (Integer.valueOf(1).equals(doc.getHasOriginalText())) {
// Resume: Text aus vorherigem Lauf wiederverwenden
LOG.infof("Dokument %d: Text bereits vorhanden überspringe OCR/Übersetzung",
doc.getId());
try {
OrdsDocumentTexts texts = ordsClient.getTexts(doc.getId());
originalText = texts.getOriginalText() != null ? texts.getOriginalText() : "";
} catch (Exception e) {
LOG.warnf("Text-Laden für Dokument %d fehlgeschlagen: %s", doc.getId(), e.getMessage());
}
} else {
LOG.infof("Verarbeite Dokument %d: %s", doc.getId(), doc.getFilename());
String translatedText = "";
try {
Response fileResp = ordsClient.downloadFile(doc.getId());
if (fileResp.getStatus() != 200) {
LOG.warnf("Download Dokument %d fehlgeschlagen (HTTP %d)",
doc.getId(), fileResp.getStatus());
} else {
byte[] fileBytes = fileResp.readEntity(byte[].class);
originalText = documentProcessingService.extractText(
fileBytes, doc.getMimeType(), doc.getFilename());
LOG.debugf("Dokument %d: %d Zeichen extrahiert",
(long) doc.getId(), (long) originalText.length());
translatedText = evaluationService.translate(originalText);
LOG.debugf("Dokument %d: Übersetzung fertig (%d Zeichen)",
(long) doc.getId(), (long) translatedText.length());
ordsClient.updateTexts(doc.getId(),
new TextsRequest(originalText, translatedText));
}
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
} catch (Exception e) {
LOG.errorf(e, "Fehler bei Dokumentenverarbeitung %d", doc.getId());
}
originalTexts.put(doc.getId(), originalText);
// Fortschritt aktualisieren
// Nach dem Schritt: Fortschritt hochzählen + ETA aktualisieren
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
pushProgress(projectId, stepHolder[0], totalSteps, "OCR",
doc.getId(), null, startedAt);
}
// ─── PHASE B: Fragen auswerten ────────────────────────────────
for (OrdsDocument doc : documents) {
String originalText = originalTexts.getOrDefault(doc.getId(), "");
Long catId = doc.getCatalogId();
if (catId == null || !questionsByCatalog.containsKey(catId)) {
LOG.warnf("Dokument %d hat keinen Katalog überspringe Auswertung", doc.getId());
continue;
}
List<OrdsQuestion> questions = questionsByCatalog.get(catId);
if (originalText.isBlank()) {
LOG.warnf("Kein Text für Dokument %d überspringe Auswertung", doc.getId());
stepHolder[0] += questions.size();
pushProgress(projectId, stepHolder[0], totalSteps);
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
doc.getId(), null, startedAt);
continue;
}
for (OrdsQuestion question : questions) {
// Vor dem Schritt: Status auf "wertet Frage X in Dokument Y aus"
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
doc.getId(), question.getQuestionId(), startedAt);
try {
LOG.debugf("Auswertung: Dok %d × Frage %d",
doc.getId(), question.getQuestionId());
EvaluationResult result = evaluationService.evaluate(question, originalText);
// Abweichung / Hinweis aus Schwellwert + result_handling berechnen
int deviation = 0;
int warning = 0;
if (result.getScore() != null && question.getThreshold() != null
@@ -185,8 +239,10 @@ public class CheckOrchestrationService {
question.getQuestionId(), doc.getId());
}
// Nach dem Schritt: Fortschritt + ETA aktualisieren
stepHolder[0]++;
pushProgress(projectId, stepHolder[0], totalSteps);
pushProgress(projectId, stepHolder[0], totalSteps, "QUESTIONS",
doc.getId(), question.getQuestionId(), startedAt);
}
}
@@ -195,8 +251,6 @@ public class CheckOrchestrationService {
LOG.infof("=== Projekt %d erfolgreich abgeschlossen ===", projectId);
} catch (Exception e) {
// Kritischer Fehler: Projekt bleibt in IN_PROGRESS.
// APEX-Oberfläche / Admin muss manuell zurücksetzen.
LOG.errorf(e, "=== Kritischer Fehler bei Projekt %d bleibt IN_PROGRESS ===",
projectId);
}
@@ -207,15 +261,33 @@ public class CheckOrchestrationService {
// =========================================================================
/**
* Sendet den aktuellen Fortschritt an ORDS (0100%).
* Fehler werden nur geloggt, kein Abbruch.
* Sendet Fortschritt + Schrittdetails + ETA an ORDS.
* Wird vor (step = bisherige Schritte) und nach (step = bisherige + 1) jedem Schritt aufgerufen.
*/
private void pushProgress(long projectId, int step, int total) {
int pct = total > 0 ? Math.min(step * 100 / total, 99) : 0;
private void pushProgress(long projectId, int stepsCompleted, int total,
String phase, Long docId, Long questionId, Instant startedAt) {
int pct = total > 0 ? Math.min(stepsCompleted * 100 / total, 99) : 0;
String eta = (stepsCompleted > 0 && startedAt != null)
? calcEta(startedAt, stepsCompleted, total) : null;
try {
ordsClient.updateProgress(projectId, new ProgressRequest(pct));
ordsClient.updateProgress(projectId,
new ProgressRequest(pct, phase, docId, questionId, eta));
} catch (Exception e) {
LOG.debugf("Fortschritt konnte nicht gesetzt werden (%d%%): %s", pct, e.getMessage());
}
}
/**
* Berechnet die geschätzte Fertigstellung auf Basis der bisher verstrichenen Zeit.
* ETA = jetzt + (verbleibende Schritte × Ø Dauer pro Schritt)
*/
private String calcEta(Instant startedAt, int stepsCompleted, int totalSteps) {
if (stepsCompleted <= 0) return null;
long elapsedMs = Duration.between(startedAt, Instant.now()).toMillis();
long avgMs = elapsedMs / stepsCompleted;
int remaining = totalSteps - stepsCompleted;
if (remaining <= 0) return null;
Instant eta = Instant.now().plusMillis(avgMs * remaining).truncatedTo(ChronoUnit.SECONDS);
return ISO_FMT.format(eta.atZone(ZoneId.systemDefault()));
}
}

View File

@@ -0,0 +1,771 @@
package de.frigosped.dc.service;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.poi.xwpf.usermodel.*;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTShd;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STShd;
import org.jboss.logging.Logger;
import java.awt.*;
import java.awt.geom.Ellipse2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* Konvertiert Markdown-Text zu DOCX (Apache POI) oder PDF (PDFBox).
*
* Unterstützte Markdown-Elemente:
* # ## ### #### Überschriften (4 Ebenen)
* **text** Fett
* *text* Kursiv
* --- Trennlinie
* > text Zitat / Blockquote
* | a | b | Tabelle
* ✅ ❌ ⚠️ ❓ 📄 Fünf Report-Emoji → werden als farbige Icons gerendert
* Leerzeile Vertikaler Abstand
*
* Einstiegspunkte:
* toDocx(markdown) → byte[] DOCX-Binärdaten
* toPdf(markdown) → byte[] PDF-Binärdaten
*/
@ApplicationScoped
public class MarkdownExportService {
private static final Logger LOG = Logger.getLogger(MarkdownExportService.class);
// =========================================================================
// Inline-Segment-Modell (gemeinsam für DOCX und PDF)
// =========================================================================
/** Typ eines Inline-Abschnitts innerhalb einer Zeile. */
enum SegType { PLAIN, BOLD, ITALIC, EMOJI }
/** Ein Inline-Abschnitt mit Typ und Rohtext. */
record Seg(SegType type, String text) {}
/**
* Whitelist der fünf Report-Emoji-Codepoints.
* Nur diese werden als Icon-Bild gerendert. Alle anderen Unicode-Sonderzeichen
* (z.B. ● U+25CF, ™ U+2122) bleiben als PLAIN-Text und werden nicht angefasst.
*/
private static boolean isReportEmoji(int cp) {
return cp == 0x2705 // ✅
|| cp == 0x274C // ❌
|| cp == 0x26A0 // ⚠
|| cp == 0x2753 // ❓
|| cp == 0x1F4C4; // 📄
}
/**
* Zerlegt eine Zeile in typisierte Inline-Segmente.
*
* Erkennungsreihenfolge pro Position:
* 1. **text** → BOLD
* 2. *text* → ITALIC (nur wenn nicht Teil von **)
* 3. isReportEmoji() → EMOJI (inkl. nachfolgende Variation-Selektoren U+FE0F / ZWJ U+200D)
* 4. alles andere → PLAIN (gesammelt bis zum nächsten Marker oder Emoji)
*
* Beispiel: "✅ **Frage** ist *kursiv*"
* → [EMOJI "✅"], [PLAIN " "], [BOLD "Frage"], [PLAIN " ist "], [ITALIC "kursiv"]
*/
static List<Seg> parseSegments(String text) {
List<Seg> out = new ArrayList<>();
if (text == null || text.isEmpty()) return out;
int pos = 0;
while (pos < text.length()) {
// **bold**
if (text.startsWith("**", pos)) {
int end = text.indexOf("**", pos + 2);
if (end > pos + 1) {
out.add(new Seg(SegType.BOLD, text.substring(pos + 2, end)));
pos = end + 2;
continue;
}
}
// *italic* (not part of **)
if (text.charAt(pos) == '*'
&& (pos + 1 >= text.length() || text.charAt(pos + 1) != '*')) {
int end = text.indexOf('*', pos + 1);
if (end > pos && (end + 1 >= text.length() || text.charAt(end + 1) != '*')) {
out.add(new Seg(SegType.ITALIC, text.substring(pos + 1, end)));
pos = end + 1;
continue;
}
}
// Bekanntes Report-Emoji
int cp = text.codePointAt(pos);
if (isReportEmoji(cp)) {
StringBuilder eb = new StringBuilder();
while (pos < text.length()) {
int c = text.codePointAt(pos);
if (!isReportEmoji(c) && c != 0xFE0F && c != 0x200D) break;
eb.appendCodePoint(c);
pos += Character.charCount(c);
}
out.add(new Seg(SegType.EMOJI, eb.toString()));
continue;
}
// Klartext bis zum nächsten Marker
int start = pos;
while (pos < text.length()) {
int c = text.codePointAt(pos);
if (isReportEmoji(c) || text.charAt(pos) == '*') break;
pos += Character.charCount(c);
}
if (pos > start) out.add(new Seg(SegType.PLAIN, text.substring(start, pos)));
}
return out;
}
// =========================================================================
// DOCX-Export via Apache POI
// =========================================================================
//
// Aufbau:
// toDocx()
// └── Hauptschleife über Zeilen
// ├── addHeading() → XWPFParagraph mit Heading-Style + Run
// ├── addParagraph() → renderInline() → ein Run pro Segment
// ├── addHorizontalRule()→ Paragraph mit Strich-Zeichen
// ├── addBlockquote() → eingerückter Paragraph + grauer Hintergrund
// └── addTable() → XWPFTable; Zellen via renderInline()
//
// Inline-Formatierung (renderInline):
// parseSegments() liefert die Segmente; für jedes wird ein eigener XWPFRun
// erzeugt und run.setBold() / run.setItalic() gesetzt.
// EMOJI-Segmente landen als Rohtext im Run (Word kann die Codepoints direkt anzeigen).
// =========================================================================
/**
* Erzeugt ein DOCX-Dokument aus dem Markdown-String.
* \r wird vorab entfernt (Oracle CLOBs liefern \r\n-Zeilenenden).
*/
public byte[] toDocx(String markdown) throws Exception {
try (XWPFDocument doc = new XWPFDocument()) {
String[] lines = markdown.replace("\r", "").split("\n", -1);
int i = 0;
while (i < lines.length) {
String line = lines[i];
if (line.startsWith("#### ")) {
addHeading(doc, line.substring(5).trim(), 4);
} else if (line.startsWith("### ")) {
addHeading(doc, line.substring(4).trim(), 3);
} else if (line.startsWith("## ")) {
addHeading(doc, line.substring(3).trim(), 2);
} else if (line.startsWith("# ")) {
addHeading(doc, line.substring(2).trim(), 1);
} else if (line.equals("---")) {
addHorizontalRule(doc);
} else if (line.startsWith("> ")) {
addBlockquote(doc, line.substring(2).trim());
} else if (line.startsWith("|")) {
// Tabellenzeilen sammeln bis die Pipe-Sequenz endet
List<String[]> rows = new ArrayList<>();
while (i < lines.length && lines[i].startsWith("|")) {
if (!isTableSeparator(lines[i])) {
String[] parts = lines[i].split("\\|", -1);
List<String> cells = new ArrayList<>();
for (int p = 1; p < parts.length - 1; p++)
cells.add(parts[p].trim());
if (!cells.isEmpty()) rows.add(cells.toArray(new String[0]));
}
i++;
}
if (!rows.isEmpty()) addTable(doc, rows);
continue; // i wurde schon inkrementiert
} else if (!line.isBlank()) {
addParagraph(doc, line);
}
// Leerzeile erzeugt implizit Abstand durch fehlenden Paragraph-Inhalt
i++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.write(baos);
return baos.toByteArray();
}
}
/** Trennzeilen wie |:---|:---| überspringen; müssen Bindestriche enthalten. */
private static boolean isTableSeparator(String line) {
return line.matches("\\|[-: |]+\\|") && line.contains("-");
}
/**
* Fügt eine Überschrift hinzu.
* Setzt den Word-Style "Heading1""Heading4" und wählt die Schriftgröße:
* Level 1 = 22pt, 2 = 18pt, 3 = 14pt, 4 = 12pt.
*/
private void addHeading(XWPFDocument doc, String text, int level) {
XWPFParagraph p = doc.createParagraph();
p.setStyle("Heading" + level);
XWPFRun run = p.createRun();
run.setText(text);
run.setBold(true);
run.setFontSize(switch (level) { case 1 -> 22; case 2 -> 18; case 3 -> 14; default -> 12; });
}
/**
* Fügt einen normalen Absatz mit Inline-Formatierung hinzu.
* Delegiert an renderInline(), das Bold/Italic/Emoji korrekt als separate Runs setzt.
*/
private void addParagraph(XWPFDocument doc, String text) {
renderInline(doc.createParagraph(), text);
}
/**
* Rendert Inline-Formatierung in einen vorhandenen Paragraph.
* parseSegments() liefert die Segmente; jedes bekommt einen eigenen XWPFRun
* mit setBold()/setItalic(). Emoji-Codepoints landen als Rohtext (Word rendert sie nativ).
*/
private void renderInline(XWPFParagraph p, String text) {
for (Seg seg : parseSegments(text)) {
XWPFRun run = p.createRun();
run.setText(seg.text());
if (seg.type() == SegType.BOLD) run.setBold(true);
if (seg.type() == SegType.ITALIC) run.setItalic(true);
}
}
/**
* Fügt eine visuelle Trennlinie ein (Dashes-Zeichen, kein echtes OOXML-Border).
*/
private void addHorizontalRule(XWPFDocument doc) {
doc.createParagraph().createRun()
.setText("──────────────────────────────────────────────");
}
/**
* Fügt einen eingerückten Blockquote-Absatz ein.
* Einrückung: 720 Twips (= 1,27 cm), Hintergrund: #F0F0F0, Text kursiv.
*/
private void addBlockquote(XWPFDocument doc, String text) {
XWPFParagraph p = doc.createParagraph();
p.setIndentationLeft(720);
try {
CTShd shd = p.getCTP().addNewPPr().addNewShd();
shd.setVal(STShd.CLEAR); shd.setColor("auto"); shd.setFill("F0F0F0");
} catch (Exception ignored) {}
XWPFRun run = p.createRun();
run.setItalic(true);
run.setText(text);
}
/**
* Fügt eine Tabelle ein.
* Wichtig: Zellen werden über renderInline() befüllt, nicht über cell.setText(),
* damit Bold/Italic innerhalb von Tabellenzellen korrekt angewandt werden.
*/
private void addTable(XWPFDocument doc, List<String[]> rows) {
if (rows.isEmpty()) return;
int cols = rows.get(0).length;
XWPFTable table = doc.createTable(rows.size(), cols);
table.setWidth("100%");
for (int r = 0; r < rows.size(); r++) {
XWPFTableRow row = table.getRow(r);
String[] cells = rows.get(r);
for (int c = 0; c < cols; c++) {
XWPFTableCell cell = c < row.getTableCells().size()
? row.getCell(c) : row.addNewTableCell();
List<XWPFParagraph> paras = cell.getParagraphs();
XWPFParagraph para = paras.isEmpty() ? cell.addParagraph() : paras.get(0);
// Vorhandene (leere) Runs entfernen bevor renderInline neue anlegt
while (!para.getRuns().isEmpty()) para.removeRun(0);
if (c < cells.length) renderInline(para, cells[c]);
}
}
}
// =========================================================================
// PDF-Export via PDFBox
// =========================================================================
//
// Koordinatensystem: PDFBox nutzt PDF-Koordinaten (0,0 = unten links).
// y startet bei PAGE_HEIGHT - MARGIN (oben) und läuft nach unten.
// newPage() setzt y zurück auf PAGE_HEIGHT - MARGIN.
//
// Aufrufkette für eine normale Zeile:
// write()
// └── renderLine() zerlegt und bricht Zeile um
// ├── writeWrappedText() reiner Text ohne Formatierung
// │ └── flushText() schreibt eine Textzeile auf den Stream
// └── splitWords() + flushInlineLine() für gemischte Segmente
// └── renderSegsAt() schreibt Segmente inline nebeneinander
// ├── cs.showText() für PLAIN/BOLD/ITALIC
// └── cs.drawImage() für EMOJI (aus emojiImage())
//
// Emoji-Rendering:
// Java AWT im headless-Modus kann NotoColorEmoji (COLR v1) nicht rendern.
// Lösung: emojiImage() zeichnet die Icons als Java2D-Shapes in ein
// BufferedImage und gibt es als PDImageXObject zurück.
// =========================================================================
private static final float MARGIN = 50f;
private static final float LINE_HEIGHT = 14f;
private static final float PAGE_WIDTH = PDRectangle.A4.getWidth();
private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight();
private static final float TEXT_WIDTH = PAGE_WIDTH - 2 * MARGIN;
/**
* Erzeugt ein PDF aus dem Markdown-String.
* \r wird vorab entfernt (Oracle CLOBs liefern \r\n; PDType1Font akzeptiert
* nur Latin-1 0x200xFF, \r würde eine Exception werfen).
*/
public byte[] toPdf(String markdown) throws Exception {
try (PDDocument doc = new PDDocument()) {
new PdfWriter(doc).write(markdown.replace("\r", ""));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
doc.save(baos);
return baos.toByteArray();
}
}
/** Zustandsbehafteter Renderer für ein einzelnes PDF-Dokument. */
private class PdfWriter {
final PDDocument doc;
PDPageContentStream cs; // aktiver Content-Stream der aktuellen Seite
float y; // aktuelle y-Schreibposition (oben nach unten)
final PDType1Font fNorm, fBold, fItalic;
PdfWriter(PDDocument doc) throws Exception {
this.doc = doc;
this.fNorm = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
this.fBold = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
this.fItalic= new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
newPage();
}
/**
* Schließt den aktuellen Content-Stream, fügt eine neue A4-Seite ein
* und setzt y auf den oberen Rand.
*/
void newPage() throws Exception {
if (cs != null) cs.close();
PDPage p = new PDPage(PDRectangle.A4);
doc.addPage(p);
cs = new PDPageContentStream(doc, p);
y = PAGE_HEIGHT - MARGIN;
}
/**
* Prüft ob noch mindestens h Punkte bis zum unteren Rand verbleiben.
* Wenn nicht → newPage(). Muss vor jedem cs.showText() / cs.drawImage() gerufen werden.
*/
void ensureSpace(float h) throws Exception {
if (y - h < MARGIN) newPage();
}
/**
* Bewegt y um n Punkte nach unten (fügt vertikalen Leerraum ein).
* Ruft kein ensureSpace() auf — bei Seitenende greift das folgende renderLine/flushText.
*/
void skip(float n) { y -= n; }
// ── Emoji → Java2D-Shape ──────────────────────────────────────────────
/**
* Erzeugt ein PDImageXObject für eines der fünf Report-Emoji.
*
* Hintergrund: Java AWT headless kann NotoColorEmoji (COLR v1-Format) nicht
* rendern — drawString() erzeugt ein transparentes Bild. Lösung: jedes Icon
* wird per Java2D-Shapes direkt in ein BufferedImage gezeichnet.
*
* Pixel-Größe = max(24, sizePt * 3) für ausreichende Auflösung bei Antialiasing.
* Das Ergebnis wird via LosslessFactory (PNG-Kompression) in das PDF eingebettet.
*/
PDImageXObject emojiImage(String emoji, float sizePt) throws Exception {
int px = Math.max(24, (int)(sizePt * 3));
int pad = Math.max(1, px / 16);
BufferedImage img = new BufferedImage(px + pad * 2, px + pad * 2,
BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL,
RenderingHints.VALUE_STROKE_PURE);
float cx = pad + px / 2f;
float cy = pad + px / 2f;
float r = px / 2f - 0.5f;
int cp = emoji.isEmpty() ? 0 : emoji.codePointAt(0);
switch (cp) {
case 0x2705 -> { // ✅ grüner Kreis + weißes Häkchen
g.setColor(new Color(52, 168, 83));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(px / 6f,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g.drawPolyline(
new int[]{(int)(cx - r*.42f), (int)(cx - r*.08f), (int)(cx + r*.42f)},
new int[]{(int)(cy + r*.05f), (int)(cy + r*.42f), (int)(cy - r*.32f)},
3);
}
case 0x274C -> { // ❌ roter Kreis + weißes X
g.setColor(new Color(234, 67, 53));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(px / 5.5f,
BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
float d = r * 0.34f;
g.drawLine((int)(cx-d),(int)(cy-d),(int)(cx+d),(int)(cy+d));
g.drawLine((int)(cx+d),(int)(cy-d),(int)(cx-d),(int)(cy+d));
}
case 0x26A0 -> { // ⚠️ gelbes Dreieck + !
g.setColor(new Color(251, 188, 4));
g.fillPolygon(
new int[]{(int)cx, pad + px, pad},
new int[]{pad, pad + px, pad + px}, 3);
g.setColor(new Color(50, 50, 50));
g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .42f)));
FontMetrics fm = g.getFontMetrics();
g.drawString("!", (int)(cx - fm.stringWidth("!") / 2f),
(int)(pad + px * .82f));
}
case 0x2753 -> { // ❓ roter Kreis + weißes ?
g.setColor(new Color(234, 67, 53));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
g.setColor(Color.WHITE);
g.setFont(new java.awt.Font("Dialog", java.awt.Font.BOLD, (int)(px * .55f)));
FontMetrics fm = g.getFontMetrics();
g.drawString("?", (int)(cx - fm.stringWidth("?") / 2f),
(int)(cy + px * .20f));
}
case 0x1F4C4 -> { // 📄 blaues Dokument
int dw = (int)(px * .70f), dh = (int)(px * .90f);
int dx = (int)(cx - dw / 2f), dy = pad + (int)(px * .05f);
int fold = (int)(px * .22f);
g.setColor(new Color(66, 133, 244));
g.fillPolygon(
new int[]{dx, dx + dw - fold, dx + dw, dx + dw, dx},
new int[]{dy, dy, dy + fold, dy + dh, dy + dh}, 5);
g.setColor(new Color(30, 90, 200)); // gefaltete Ecke dunkler
g.fillPolygon(
new int[]{dx + dw - fold, dx + dw - fold, dx + dw},
new int[]{dy, dy + fold, dy + fold}, 3);
g.setColor(Color.WHITE); // drei weiße Textzeilen
int lw = (int)(dw * .56f), lx = dx + (int)(dw * .18f);
int lh = Math.max(1, (int)(dh * .08f)), gap = (int)(dh * .13f);
int ly = dy + (int)(dh * .38f);
for (int l = 0; l < 3; l++) g.fillRect(lx, ly + l * gap, lw, lh);
}
default -> { // Unbekannter Codepoint: grauer Kreis (Fallback)
g.setColor(new Color(158, 158, 158));
g.fill(new Ellipse2D.Float(pad, pad, px, px));
}
}
g.dispose();
// LosslessFactory = verlustfreie PNG-Einbettung ins PDF
return LosslessFactory.createFromImage(doc, img);
}
/**
* Filtert einen String auf PDType1Font-sichere Zeichen (Latin-1, 0x200xFF).
* WinAnsiEncoding akzeptiert keine Zeichen außerhalb dieses Bereichs;
* unbekannte Codepoints werden durch Leerzeichen ersetzt.
*/
private String safe(String s) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); ) {
int cp = s.codePointAt(i);
sb.append((cp >= 0x20 && cp <= 0xFF) ? (char) cp : ' ');
i += Character.charCount(cp);
}
return sb.toString();
}
/** Gibt die passende PDType1Font-Instanz für einen Segment-Typ zurück. */
private PDType1Font fontFor(SegType t) {
return t == SegType.BOLD ? fBold : t == SegType.ITALIC ? fItalic : fNorm;
}
/**
* Berechnet die Breite eines Segments in PDF-Punkten.
* Emoji: geschätzt als 1,5 × Schriftgröße pro Codepoint.
* Text: exakt über getStringWidth() der jeweiligen PDType1Font.
*/
float segWidth(Seg seg, float size) throws Exception {
if (seg.type() == SegType.EMOJI)
return size * 1.5f * seg.text().codePointCount(0, seg.text().length());
return fontFor(seg.type()).getStringWidth(safe(seg.text())) / 1000f * size;
}
/**
* Rendert eine Liste von Segmenten inline (nebeneinander) ab x-Position startX
* auf der Basislinie baseline.
*
* EMOJI-Segmente: emojiImage() → cs.drawImage(); vertikal zentriert zur Baseline.
* PLAIN/BOLD/ITALIC: beginText / setFont / newLineAtOffset / showText / endText.
*
* Gibt die neue x-Position nach dem letzten Segment zurück.
*/
float renderSegsAt(List<Seg> segs, float startX, float baseline, float size,
float emojiSize) throws Exception {
float x = startX;
for (Seg seg : segs) {
if (seg.type() == SegType.EMOJI) {
PDImageXObject img = emojiImage(seg.text(), emojiSize);
float imgH = emojiSize;
float imgW = imgH * img.getWidth() / (float) img.getHeight();
// 0.40f: Bild leicht über die Baseline heben (vertikal zentriert zum Text)
cs.drawImage(img, x, baseline - imgH * 0.40f, imgW, imgH);
x += imgW + 1;
} else {
String txt = safe(seg.text());
if (!txt.isEmpty()) {
PDType1Font f = fontFor(seg.type());
cs.beginText();
cs.setFont(f, size);
cs.newLineAtOffset(x, baseline);
cs.showText(txt);
cs.endText();
x += f.getStringWidth(txt) / 1000f * size;
}
}
}
return x;
}
/**
* Rendert eine Markdown-Zeile mit Zeilenumbruch.
*
* Zwei Pfade:
* a) Nur PLAIN-Segmente → writeWrappedText() (einfacher, schneller Pfad)
* b) Gemischt (Bold/Italic/Emoji) → splitWords() zerlegt in Wort-Einheiten,
* dann werden diese zu Zeilen gepackt und mit flushInlineLine() geschrieben.
*
* indent: horizontaler Einzug in Punkten (z.B. 20 für Blockquotes).
*/
void renderLine(String text, PDType1Font defaultFont, float size, float indent)
throws Exception {
List<Seg> segs = parseSegments(text);
boolean onlyPlain = segs.stream().allMatch(s -> s.type() == SegType.PLAIN);
if (onlyPlain) {
writeWrappedText(defaultFont, size, indent, text);
return;
}
float emojiPt = size * 1.5f;
float maxW = TEXT_WIDTH - indent;
List<List<Seg>> words = splitWords(segs);
List<List<Seg>> line = new ArrayList<>();
float lineW = 0;
for (List<Seg> word : words) {
float ww = 0;
for (Seg s : word) ww += segWidth(s, s.type() == SegType.EMOJI ? emojiPt : size);
if (!line.isEmpty() && lineW + ww > maxW) {
flushInlineLine(line, size, emojiPt, indent);
line = new ArrayList<>();
lineW = 0;
// Führendes Leerzeichen am Zeilenanfang überspringen
if (word.size() == 1 && word.get(0).type() == SegType.PLAIN
&& word.get(0).text().isBlank()) continue;
}
line.add(word);
lineW += ww;
}
if (!line.isEmpty()) flushInlineLine(line, size, emojiPt, indent);
}
/**
* Schreibt eine fertig zusammengestellte Inline-Zeile auf den PDF-Stream.
* Berechnet die Zeilenhöhe als Maximum aus LINE_HEIGHT und Emoji-Größe,
* ruft ensureSpace() für den Seitenumbruch und delegiert an renderSegsAt().
*/
private void flushInlineLine(List<List<Seg>> words, float size, float emojiPt,
float indent) throws Exception {
float lineH = Math.max(LINE_HEIGHT, emojiPt * 1.05f);
ensureSpace(lineH);
List<Seg> flat = new ArrayList<>();
for (List<Seg> w : words) flat.addAll(w);
renderSegsAt(flat, MARGIN + indent, y, size, emojiPt);
y -= lineH;
}
/**
* Zerlegt eine Liste von Segmenten in "Wörter" (für den Zeilenumbruch).
*
* Regeln:
* - EMOJI: jedes Emoji ist ein eigenes Wort (kein Umbruch innerhalb)
* - PLAIN/BOLD/ITALIC: an Leerzeichen aufteilen; jedes Leerzeichen
* wird als eigenes PLAIN-" "-Wort eingefügt (Trennstelle beim Umbrechen)
*/
private List<List<Seg>> splitWords(List<Seg> segs) {
List<List<Seg>> words = new ArrayList<>();
List<Seg> cur = new ArrayList<>();
for (Seg seg : segs) {
if (seg.type() == SegType.EMOJI) {
if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
words.add(List.of(seg));
continue;
}
String txt = seg.text();
int start = 0;
for (int i = 0; i <= txt.length(); i++) {
if (i == txt.length() || txt.charAt(i) == ' ') {
if (i > start)
cur.add(new Seg(seg.type(), txt.substring(start, i)));
if (i < txt.length()) {
if (!cur.isEmpty()) { words.add(cur); cur = new ArrayList<>(); }
words.add(List.of(new Seg(SegType.PLAIN, " ")));
}
start = i + 1;
}
}
if (start < txt.length())
cur.add(new Seg(seg.type(), txt.substring(start)));
}
if (!cur.isEmpty()) words.add(cur);
return words;
}
/**
* Wortumbruch für reinen Text (keine Inline-Formatierung).
* Misst jedes Wort mit getStringWidth(); bricht um wenn die Zeile voll ist.
* Schreibt jede fertige Zeile via flushText().
*/
void writeWrappedText(PDType1Font font, float size, float indent, String text)
throws Exception {
float maxW = TEXT_WIDTH - indent;
String[] words = text.split(" ", -1);
StringBuilder cur = new StringBuilder();
for (String w : words) {
String candidate = cur.isEmpty() ? w : cur + " " + w;
float width;
try { width = font.getStringWidth(safe(candidate)) / 1000f * size; }
catch (Exception e) { width = maxW + 1; }
if (width > maxW && !cur.isEmpty()) {
flushText(font, size, indent, cur.toString());
cur = new StringBuilder(w);
} else {
cur = new StringBuilder(candidate);
}
}
if (!cur.isEmpty()) flushText(font, size, indent, cur.toString());
}
/**
* Schreibt eine einzelne Textzeile auf den PDF-Content-Stream.
* Ruft ensureSpace() → ggf. newPage() vor dem Schreiben.
* Dekrementiert y um LINE_HEIGHT danach.
*/
void flushText(PDType1Font font, float size, float indent, String text) throws Exception {
ensureSpace(LINE_HEIGHT);
cs.beginText();
cs.setFont(font, size);
cs.newLineAtOffset(MARGIN + indent, y);
cs.showText(safe(text));
cs.endText();
y -= LINE_HEIGHT;
}
/**
* Rendert eine Tabelle ins PDF.
* Spaltenbreite = TEXT_WIDTH / Anzahl Spalten (gleichmäßig verteilt).
* Zellen werden inline via renderSegsAt() geschrieben — Emoji erscheinen als Icons.
* Kein Rahmen (PDFBox-Rahmen wären aufwendiger und nicht nötig für Report-Tabellen).
*/
void renderTable(List<String[]> rows) throws Exception {
if (rows.isEmpty()) return;
int maxCols = rows.stream().mapToInt(r -> r.length).max().orElse(1);
float colW = TEXT_WIDTH / maxCols;
float rowH = LINE_HEIGHT + 8;
for (String[] row : rows) {
ensureSpace(rowH);
float cx = MARGIN;
for (int c = 0; c < maxCols; c++) {
String cell = c < row.length ? row[c].trim() : "";
List<Seg> segs = parseSegments(cell);
float emojiPt = rowH * 0.85f;
renderSegsAt(segs, cx + 2, y, 10, emojiPt);
cx += colW;
}
y -= rowH;
}
skip(2);
}
// ── Hauptschleife ─────────────────────────────────────────────────────
/**
* Verarbeitet den Markdown-String Zeile für Zeile und baut das PDF auf.
*
* Dispatching:
* "#### " / "### " / "## " / "# "
* → skip() (Abstand davor) + renderLine() mit Bold + größerer Schrift
* + skip() (Abstand danach)
* "---"
* → skip(4) + horizontale Linie direkt auf Content-Stream (moveTo/lineTo/stroke)
* + y -= 6
* "> text"
* → renderLine() mit Italic-Font, 20pt Einzug
* "|..."
* → Zeilen in tableAccum sammeln; sobald keine Pipe-Zeile mehr kommt
* → renderTable(); Trenner-Zeilen (|---|) werden übersprungen
* Leerzeile
* → skip(4): 4pt vertikaler Abstand
* Sonstiger Text
* → renderLine() mit Normal-Font, 11pt
*/
void write(String markdown) throws Exception {
String[] lines = markdown.split("\n", -1);
List<String[]> tableAccum = new ArrayList<>();
for (String line : lines) {
// Tabellenpuffer flushen sobald eine Nicht-Pipe-Zeile kommt
if (!line.startsWith("|") && !tableAccum.isEmpty()) {
renderTable(tableAccum);
tableAccum.clear();
}
if (line.startsWith("#### ")) {
skip(4); renderLine(line.substring(5).trim(), fBold, 11, 0); skip(2);
} else if (line.startsWith("### ")) {
skip(6); renderLine(line.substring(4).trim(), fBold, 13, 0); skip(3);
} else if (line.startsWith("## ")) {
skip(8); renderLine(line.substring(3).trim(), fBold, 15, 0); skip(4);
} else if (line.startsWith("# ")) {
skip(10); renderLine(line.substring(2).trim(), fBold, 18, 0); skip(6);
} else if (line.equals("---")) {
skip(4);
ensureSpace(2);
cs.setLineWidth(0.5f);
cs.moveTo(MARGIN, y);
cs.lineTo(PAGE_WIDTH - MARGIN, y);
cs.stroke();
y -= 6;
} else if (line.startsWith("> ")) {
renderLine(line.substring(2).trim(), fItalic, 10, 20);
} else if (line.startsWith("|")) {
if (!isTableSeparator(line)) {
String[] parts = line.split("\\|", -1);
List<String> cells = new ArrayList<>();
for (int p = 1; p < parts.length - 1; p++)
cells.add(parts[p].trim());
if (!cells.isEmpty()) tableAccum.add(cells.toArray(new String[0]));
}
} else if (line.isBlank()) {
skip(4);
} else {
renderLine(line, fNorm, 11, 0);
}
}
if (!tableAccum.isEmpty()) renderTable(tableAccum);
cs.close();
}
}
}

View File

@@ -49,8 +49,8 @@ quarkus.log.category."de.frigosped".level=DEBUG
# KI-Kommunikation mitloggen (nur in Dev)
%dev.dc.ai.log-requests=true
%dev.dc.ai.log-responses=true
%prod.dc.ai.log-requests=false
%prod.dc.ai.log-responses=false
%prod.dc.ai.log-requests=true
%prod.dc.ai.log-responses=true
# =============================================================================
# Jackson: snake_case für ORDS-Responses

66
dc-backend/test-run.sh Normal file
View File

@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Setzt Projekt auf Ausgangszustand (status=NULL) und triggert POST /check/{id}
# Verwendung: bash test-run.sh [projectId]
set -euo pipefail
PROJECT_ID="${1:-1}"
BACKEND="http://localhost:8090"
DB_USER="wksp_ai"
DB_PASS='s!)löyx209aasgtrasdasiudkash5235FDSXljLJ'
DB_HOST="10.75.10.171"
DB_PORT="32710"
DB_SID="freepdb1"
KUBECONFIG_FILE="$(cd "$(dirname "$0")/.." && pwd)/env/frigo-dev.yaml"
echo "=== Test-Run: Projekt $PROJECT_ID ==="
# 1. Projekt zurücksetzen (status=NULL, progress=0, completed_at=NULL)
# Ergebnisse löschen, aber original_text/translated_text BLEIBEN erhalten
# → Phase A (OCR) wird beim nächsten Lauf automatisch übersprungen
echo ""
echo "→ DB-Reset: status=NULL, Ergebnisse löschen (Texte bleiben) ..."
sql -S "$DB_USER/$DB_PASS@$DB_HOST:$DB_PORT/$DB_SID" <<SQL
DELETE FROM dc_results WHERE project_id = $PROJECT_ID;
UPDATE dc_projects
SET status = NULL,
progress = 0,
completed_at = NULL
WHERE id = $PROJECT_ID;
COMMIT;
SELECT id, name, status, catalog_id, progress FROM dc_projects WHERE id = $PROJECT_ID;
SQL
# 2. Port-Forward sicherstellen (im Hintergrund, falls noch nicht aktiv)
if ! curl -sf "$BACKEND/check/health" > /dev/null 2>&1; then
echo ""
echo "→ Starte Port-Forward localhost:8090 → dc-backend:8090 ..."
KUBECONFIG="$KUBECONFIG_FILE" kubectl port-forward \
-n ai-env svc/dc-backend 8090:8090 &
PF_PID=$!
echo " Port-Forward PID: $PF_PID"
sleep 3
else
PF_PID=""
echo "→ Port-Forward bereits aktiv."
fi
# 3. Trigger
echo ""
echo "→ POST $BACKEND/check/$PROJECT_ID ..."
RESPONSE=$(curl -sf -X POST "$BACKEND/check/$PROJECT_ID" \
-H "Content-Type: application/json" || echo "FEHLER: curl fehlgeschlagen")
echo " Response: $RESPONSE"
# 4. Logs streamen
echo ""
echo "→ Logs (Ctrl+C zum Beenden):"
POD=$(KUBECONFIG="$KUBECONFIG_FILE" kubectl get pod -n ai-env -l app=dc-backend \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null)
KUBECONFIG="$KUBECONFIG_FILE" kubectl logs -f "$POD" -n ai-env --since=10s
# Cleanup Port-Forward
if [ -n "${PF_PID:-}" ]; then
kill "$PF_PID" 2>/dev/null || true
fi