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:
@@ -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.
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 0–100
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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 0–100 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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (0–100%).
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 0x20–0xFF, \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, 0x20–0xFF).
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user