files) throws IOException {
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ZipOutputStream zos = new ZipOutputStream(baos)) {
+
+ for (ZipFile file : files) {
+ zos.putNextEntry(new ZipEntry(file.filename()));
+ zos.write(file.data());
+ zos.closeEntry();
+ }
+ zos.finish();
+ return baos.toByteArray();
+ }
+ }
+
+ public static record ZipFile(byte[] data, String filename) { }
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java
new file mode 100644
index 0000000..e4793d2
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlBR.java
@@ -0,0 +1,8 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+public class HtmlBR implements IHtmlElement{
+ @Override
+ public String toHtml() {
+ return "
";
+ }
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java
new file mode 100644
index 0000000..63699d3
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlCode.java
@@ -0,0 +1,44 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+
+public class HtmlCode implements IHtmlElement {
+ private final String code;
+ private final boolean preserveWhitespace;
+
+ public HtmlCode(String code) {
+ this(code, false);
+ }
+
+ /**
+ * @param code the code text
+ * @param preserveWhitespace when true uses to preserve exact whitespace/newlines (no wrapping).
+ * when false uses inline with CSS enabling wrapping.
+ */
+ public HtmlCode(String code, boolean preserveWhitespace) {
+ this.code = code == null ? "" : code;
+ this.preserveWhitespace = preserveWhitespace;
+ }
+
+ private String escapeHtml(String s) {
+ return s
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+
+ @Override
+ public String toHtml() {
+ String escaped = escapeHtml(code);
+ if (preserveWhitespace) {
+ // preserve exact formatting, don't enable wrapping
+ return "" + escaped + "
";
+ } else {
+ // allow wrapping inside table cells while keeping line breaks
+ return ""
+ + escaped
+ + "";
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java
new file mode 100644
index 0000000..c753907
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlDoc.java
@@ -0,0 +1,84 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+
+public final class HtmlDoc {
+
+ private List elements = new ArrayList<>();
+ private List css = new ArrayList<>();
+
+ public HtmlDoc(){
+
+ String baseCss = """
+ * {
+ font-family: Arial, sans-serif;
+ font-size: 12px;
+ };
+ body {
+ margin: 20px;
+ };
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ }
+ .codeBlock {
+
+ font-family: "Courier New", Courier, monospace;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: anywhere;
+ }
+ """;
+
+ this.css.add(baseCss);
+ }
+
+ public static String sanitize(String input){
+ if(input == null) return null;
+ return input.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+
+ public HtmlDoc addElement(IHtmlElement element){
+ this.elements.add(element);
+ return this;
+ }
+
+ public HtmlDoc setCss(String css){
+ this.css.add(css);
+ return this;
+ }
+
+ public String getCss(){
+ StringBuilder sb = new StringBuilder();
+ for(String cssPart : css){
+ sb.append(cssPart).append("\n");
+ }
+ return sb.toString();
+ }
+
+ public String getHtmlContent(){
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n\n\n")
+// .append("\n")
+ .append("\n")
+ .append("Document\n\n\n");
+
+ for(IHtmlElement element : elements){
+ sb.append(element.toHtml()).append("\n");
+ }
+
+ sb.append("\n");
+ return sb.toString();
+ }
+
+ public void exportAsHtmlFile(String filePath) throws IOException {
+ Files.writeString(java.nio.file.Paths.get(filePath), getHtmlContent());
+ }
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java
new file mode 100644
index 0000000..1edcc37
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlHeading.java
@@ -0,0 +1,66 @@
+// java
+package de.haevn.snippetmanage.common.utils.html;
+
+public class HtmlHeading implements IHtmlElement {
+ public static enum HeadingLevel {
+ H1, H2, H3, H4, H5, H6
+ }
+
+ public static HtmlHeading h1(String text) { return new HtmlHeading(HeadingLevel.H1, text); }
+ public static HtmlHeading h2(String text) { return new HtmlHeading(HeadingLevel.H2, text); }
+ public static HtmlHeading h3(String text) { return new HtmlHeading(HeadingLevel.H3, text); }
+ public static HtmlHeading h4(String text) { return new HtmlHeading(HeadingLevel.H4, text); }
+ public static HtmlHeading h5(String text) { return new HtmlHeading(HeadingLevel.H5, text); }
+ public static HtmlHeading h6(String text) { return new HtmlHeading(HeadingLevel.H6, text); }
+
+ public static HtmlHeading h1(String text, boolean center) { return new HtmlHeading(HeadingLevel.H1, text, center); }
+ public static HtmlHeading h2(String text, boolean center) { return new HtmlHeading(HeadingLevel.H2, text, center); }
+ public static HtmlHeading h3(String text, boolean center) { return new HtmlHeading(HeadingLevel.H3, text, center); }
+ public static HtmlHeading h4(String text, boolean center) { return new HtmlHeading(HeadingLevel.H4, text, center); }
+ public static HtmlHeading h5(String text, boolean center) { return new HtmlHeading(HeadingLevel.H5, text, center); }
+ public static HtmlHeading h6(String text, boolean center) { return new HtmlHeading(HeadingLevel.H6, text, center); }
+
+
+ private final HeadingLevel level;
+ private final String text;
+ private final boolean center;
+
+ public HtmlHeading(HeadingLevel level, String text) {
+ this(level, text, false);
+ }
+
+ public HtmlHeading(HeadingLevel level, String text, boolean center) {
+ this.level = level;
+ this.text = HtmlDoc.sanitize(text);
+ this.center = center;
+ }
+
+ public HtmlHeading(String text) {
+ this(HeadingLevel.H1, text);
+ }
+
+ public HtmlHeading(String text, boolean center) {
+ this(HeadingLevel.H1, text, center);
+ }
+
+ /**
+ * Return a new instance with centering enabled.
+ */
+ public HtmlHeading withCenter(boolean center) {
+ return new HtmlHeading(this.level, this.text, center);
+ }
+
+ /**
+ * Convenience: enable centering.
+ */
+ public HtmlHeading center() {
+ return withCenter(true);
+ }
+
+ @Override
+ public String toHtml() {
+ String tag = level.name().toLowerCase();
+ String style = center ? " style=\"text-align:center;\"" : "";
+ return "<" + tag + style + ">" + text + "" + tag + ">";
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java
new file mode 100644
index 0000000..d41df9b
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlList.java
@@ -0,0 +1,41 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+public class HtmlList implements IHtmlElement {
+ private final java.util.List items = new java.util.ArrayList<>();
+ private final boolean ordered;
+ private final String title;
+
+ public HtmlList(boolean ordered) {
+ this(ordered, null);
+ }
+
+ public HtmlList(boolean ordered, String title) {
+ this.ordered = ordered;
+ this.title = HtmlDoc.sanitize(title);
+ }
+
+ public HtmlList addItem(IHtmlElement item) {
+ items.add(item);
+ return this;
+ }
+
+ public HtmlList addItem(String item) {
+ items.add(new HtmlText(item));
+ return this;
+ }
+
+
+ @Override
+ public String toHtml() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(ordered ? "" : "");
+ if(title != null && !title.isEmpty()) {
+ sb.append("").append(title).append("");
+ }
+ for (IHtmlElement item : items) {
+ sb.append("- ").append(item.toHtml()).append("
");
+ }
+ sb.append(ordered ? "
" : "");
+ return sb.toString();
+ }
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java
new file mode 100644
index 0000000..d25b91a
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlTable.java
@@ -0,0 +1,187 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+public class HtmlTable implements IHtmlElement {
+ public List> rows = new ArrayList<>();
+ private int[] dimensionRatios = null;
+ private final int maxColumns;
+ private boolean dimensionsTotalValues = false;
+
+ // wrapping controls
+ private Boolean[] columnWrapping = null; // null = use table-level default
+ private boolean tableCellWrapping = false; // default: no wrap (preserve previous truncation)
+
+ public HtmlTable(int maxColumns) {
+ this.maxColumns = maxColumns;
+ }
+
+ public HtmlTable(List headers) {
+ this(headers.size());
+ List headerRow = new ArrayList<>();
+ for (String header : headers) {
+ headerRow.add(new HtmlText(header));
+ }
+ addRow(headerRow);
+ }
+
+ public HtmlTable(String ... headers){
+ this(Arrays.asList(headers));
+ }
+
+ public HtmlTable addRow(List row) {
+ if (row.size() > maxColumns) {
+ throw new IllegalArgumentException("Row exceeds maximum number of columns: " + maxColumns);
+ }
+ rows.add(row);
+ return this;
+ }
+
+ public HtmlTable addRow(IHtmlElement... elements) {
+ List row = new ArrayList<>();
+ for (IHtmlElement element : elements) {
+ row.add(element);
+ }
+ return addRow(row);
+ }
+
+ public HtmlTable addRow(String... texts) {
+ List row = new ArrayList<>();
+ for (String text : texts) {
+ row.add(new HtmlText(text));
+ }
+ return addRow(row);
+ }
+
+ public HtmlTable addEmptyRow() {
+ List row = new ArrayList<>();
+ for (int i = 0; i < maxColumns; i++) {
+ row.add(new HtmlText(""));
+ }
+ return addRow(row);
+ }
+
+ /**
+ * Set column width ratios. Example: setDimensions(1, 1, 2) -> 25% / 25% / 50%.
+ * Length must match the table's maxColumns and all values must be positive.
+ */
+ public HtmlTable setDimensions(int... dimensions) {
+ return setDimensions(false, dimensions);
+ }
+
+ public HtmlTable setDimensions(boolean totalValues, int... dimensions) {
+ if (dimensions == null || dimensions.length != maxColumns) {
+ throw new IllegalArgumentException("Dimensions length must match number of columns: " + maxColumns);
+ }
+ for (int d : dimensions) {
+ if (d <= 0) {
+ throw new IllegalArgumentException("Dimension ratios must be positive values");
+ }
+ }
+ this.dimensionRatios = Arrays.copyOf(dimensions, dimensions.length);
+ this.dimensionsTotalValues = totalValues;
+ return this;
+ }
+
+ /**
+ * Enable or disable wrapping for all cells (table-level default).
+ * If per-column wrapping is set via setColumnWrapping, that overrides the table-level setting.
+ */
+ public HtmlTable setCellWrapping(boolean wrap) {
+ this.tableCellWrapping = wrap;
+ return this;
+ }
+
+ /**
+ * Set per-column wrapping. Length must match maxColumns. Values:
+ * - true: wrap this column
+ * - false: do not wrap this column (use ellipsis)
+ */
+ public HtmlTable setColumnWrapping(boolean... wraps) {
+ if (wraps == null || wraps.length != maxColumns) {
+ throw new IllegalArgumentException("Wrapping length must match number of columns: " + maxColumns);
+ }
+ this.columnWrapping = new Boolean[maxColumns];
+ for (int i = 0; i < wraps.length; i++) {
+ this.columnWrapping[i] = wraps[i];
+ }
+ return this;
+ }
+
+ /**
+ * Convenience: set wrapping for all columns at once.
+ * Example: setAllColumnsWrapping(true) -> all columns will wrap.
+ */
+ public HtmlTable setAllColumnsWrapping(boolean wrap) {
+ this.columnWrapping = new Boolean[maxColumns];
+ Arrays.fill(this.columnWrapping, wrap);
+ return this;
+ }
+
+ private String tdStyleForColumn(int colIndex) {
+ boolean wrap;
+ if (columnWrapping != null && columnWrapping[colIndex] != null) {
+ wrap = columnWrapping[colIndex];
+ } else {
+ wrap = tableCellWrapping;
+ }
+
+ if (wrap) {
+ // allow wrapping and breaking long words
+ return "style=\"white-space:normal;word-wrap:break-word;word-break:break-word;\"";
+ } else {
+ // prevent wrap and show ellipsis when overflow
+ return "style=\"white-space:nowrap;overflow:hidden;text-overflow:ellipsis;\"";
+ }
+ }
+
+ @Override
+ public String toHtml() {
+ StringBuilder sb = new StringBuilder();
+
+ if (dimensionRatios != null) {
+ int sum = 0;
+ for (int r : dimensionRatios) sum += r;
+
+ if (dimensionsTotalValues && sum > 0) {
+ // Treat provided values as absolute pixel widths
+ sb.append("\n");
+ sb.append(" \n");
+ for (int r : dimensionRatios) {
+ sb.append(" \n");
+ }
+ sb.append(" \n");
+ } else {
+ // Treat provided values as ratios and emit percentages
+ sb.append("\n");
+ sb.append(" \n");
+ for (int r : dimensionRatios) {
+ double pct = (sum == 0) ? 0.0 : (r * 100.0) / sum;
+ sb.append(" \n");
+ }
+ sb.append(" \n");
+ }
+ } else {
+ sb.append("\n");
+ }
+
+ for (List row : rows) {
+ sb.append(" \n");
+ int i = 0;
+ for (IHtmlElement cell : row) {
+ sb.append(" | ").append(cell.toHtml()).append(" | \n");
+ i++;
+ }
+ // pad missing cells to reach maxColumns
+ for (; i < maxColumns; i++) {
+ sb.append(" | \n");
+ }
+ sb.append("
\n");
+ }
+ sb.append("
");
+ return sb.toString();
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java
new file mode 100644
index 0000000..b1e5a08
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/HtmlText.java
@@ -0,0 +1,15 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+public class HtmlText implements IHtmlElement{
+
+ private String text = "";
+
+ public HtmlText(String text){
+ this.text = HtmlDoc.sanitize(text);
+ }
+
+ @Override
+ public String toHtml() {
+ return "" + text + "
";
+ }
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java
new file mode 100644
index 0000000..5e3a534
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/html/IHtmlElement.java
@@ -0,0 +1,5 @@
+package de.haevn.snippetmanage.common.utils.html;
+
+public interface IHtmlElement {
+ String toHtml();
+}
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java
new file mode 100644
index 0000000..ed4d351
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PageCanvas.java
@@ -0,0 +1,401 @@
+package de.haevn.snippetmanage.common.utils.pdf;
+
+
+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.PDImageXObject;
+
+import java.awt.*;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Repräsentiert eine einzelne Seite ("Leinwand") zum Zeichnen.
+ * Implementiert AutoCloseable, um den PDPageContentStream zu schließen.
+ */
+public class PageCanvas implements AutoCloseable {
+
+ // --- Öffentliche Konstanten für Schriftarten ---
+ public static final PDType1Font FONT_BOLD = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
+ public static final PDType1Font FONT_NORMAL = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
+ public static final PDType1Font FONT_OBLIQUE = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE);
+ public static final PDType1Font FONT_CODE = new PDType1Font(Standard14Fonts.FontName.COURIER);
+
+ // --- Tabellen-Konstanten ---
+ private static final float FONT_SIZE_TABLE_HEADER = 8;
+ private static final float FONT_SIZE_TABLE_BODY = 8;
+ private static final float TABLE_ROW_MIN_HEIGHT = 15;
+ private static final float TABLE_LINE_HEIGHT = 10; // Für umgebrochenen Text
+ private static final float CELL_PADDING = 5;
+
+ private final PDDocument document;
+ private final PDPageContentStream contentStream;
+ private final PDRectangle mediaBox;
+
+ /**
+ * Erstellt eine neue Zeichenleinwand für eine Seite.
+ * (Wird intern von PdfDocument aufgerufen)
+ */
+ PageCanvas(PDDocument document, PDPage page) throws IOException {
+ this.document = document;
+ this.mediaBox = page.getMediaBox();
+ this.contentStream = new PDPageContentStream(document, page);
+ }
+
+ // --- ÖFFENTLICHE ZEICHENMETHODEN ---
+
+ /**
+ * Zeichnet einen Text-String.
+ */
+ public void drawText(float x, float y, String text, PDType1Font font, float fontSize, Color color) throws IOException {
+ if (text == null) return;
+ String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " ");
+
+ contentStream.beginText();
+ contentStream.setFont(font, fontSize);
+ contentStream.setNonStrokingColor(color);
+ contentStream.newLineAtOffset(x, y);
+ contentStream.showText(sanitizedText);
+ contentStream.endText();
+ contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen
+ }
+
+ /**
+ * Zeichnet ein gefülltes Rechteck.
+ */
+ public void drawRectangle(float x, float y, float width, float height, Color color) throws IOException {
+ contentStream.setNonStrokingColor(color);
+ contentStream.addRect(x, y, width, height);
+ contentStream.fill();
+ contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen
+ }
+
+ /**
+ * Zeichnet einen gefüllten Kreis.
+ */
+ public void drawCircle(float cx, float cy, float r, Color color) throws IOException {
+ final float k = 0.552284749831f;
+
+ contentStream.setNonStrokingColor(color);
+ contentStream.moveTo(cx - r, cy);
+ contentStream.curveTo(cx - r, cy + k * r, cx - k * r, cy + r, cx, cy + r);
+ contentStream.curveTo(cx + k * r, cy + r, cx + r, cy + k * r, cx + r, cy);
+ contentStream.curveTo(cx + r, cy - k * r, cx + k * r, cy - r, cx, cy - r);
+ contentStream.curveTo(cx - k * r, cy - r, cx - r, cy - k * r, cx - r, cy);
+ contentStream.fill();
+ contentStream.setNonStrokingColor(Color.BLACK); // Zurücksetzen
+ }
+
+ /**
+ * Zeichnet einen Textblock mit Hintergrund, ideal für Code oder Notizen.
+ */
+ public void drawCode(float x, float yTop, String text, float width, Color bgColor) throws IOException {
+ if (text == null) return;
+
+ float padding = 5;
+ float lineHeight = 12;
+ float fontSize = 9;
+
+ List lines = getLines(text, width - padding * 2, FONT_CODE, fontSize);
+
+ float height = lines.size() * lineHeight + padding * 2;
+ float yBottom = yTop - height;
+ drawRectangle(x, yBottom, width, height, bgColor);
+
+ float textY = yTop - padding - lineHeight + 2;
+
+ contentStream.setFont(FONT_CODE, fontSize);
+ contentStream.setNonStrokingColor(Color.BLACK);
+
+ float textX = x + padding;
+ for (String line : lines) {
+ contentStream.beginText();
+ contentStream.newLineAtOffset(textX, textY);
+ contentStream.showText(line);
+ contentStream.endText();
+ textY -= lineHeight;
+ }
+ }
+
+ /**
+ * Zeichnet ein Bild.
+ */
+ public void drawImage(float x, float y, byte[] imageBytes, float width, float height) throws IOException {
+ PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "image");
+ contentStream.drawImage(pdImage, x, y, width, height);
+ }
+
+
+ /**
+ * Zeichnet eine PdfTable-Datenklasse mit Paginierungs-Logik.
+ *
+ * @param x X-Koordinate der Tabelle.
+ * @param yStart Y-Koordinate für die *Oberkante* der Tabelle.
+ * @param table Das PdfTable-Objekt, das die Daten enthält.
+ * @param yBottomLimit Die Y-Koordinate (z.B. der untere Rand), an der nicht mehr gezeichnet werden darf.
+ * @return Ein neues PdfTable-Objekt, das alle Zeilen enthält, die nicht gezeichnet wurden,
+ * oder 'null', wenn alle Zeilen gezeichnet wurden.
+ * @throws IOException Wenn das Zeichnen fehlschlägt.
+ */
+ public PdfTable drawTable(float x, float yStart, PdfTable table, float yBottomLimit) throws IOException {
+ float y = yStart;
+
+ // 1. Header zeichnen
+ List header = table.getHeader();
+ if (header == null) {
+ throw new IOException("Table must have a header for pagination.");
+ }
+
+ float headerHeight = calculateRowHeight(header, table.getColWidths(), FONT_SIZE_TABLE_HEADER, true);
+
+ // Prüfen, ob der Header selbst auf die Seite passt
+ if (y - headerHeight < yBottomLimit) {
+ throw new IOException("Table Header does not fit on page. availableHeight is too small.");
+ }
+
+ // Header zeichnen
+ drawTableRow(x, y, header, table.getColWidths(), headerHeight, true);
+ y -= headerHeight;
+
+ // 2. Datenzeilen zeichnen
+ List> rows = table.getRows();
+ List> remainingRows = new ArrayList<>();
+ boolean pageBreakOccurred = false;
+
+ for (List row : rows) {
+ float rowHeight = calculateRowHeight(row, table.getColWidths(), FONT_SIZE_TABLE_BODY, false);
+
+ // Prüfen, ob die Zeile auf die Seite passt
+ if (y - rowHeight < yBottomLimit) {
+ pageBreakOccurred = true;
+ }
+
+ if (pageBreakOccurred) {
+ // Diese und alle folgenden Zeilen für die nächste Seite aufheben
+ remainingRows.add(row);
+ } else {
+ // Zeile passt, also zeichnen
+ drawTableRow(x, y, row, table.getColWidths(), rowHeight, false);
+ y -= rowHeight;
+ }
+ }
+
+ // 3. Reste zurückgeben
+ if (!pageBreakOccurred) {
+ return null; // Alles wurde gezeichnet
+ }
+
+ // Es gibt Reste, erstelle eine neue Tabelle für die nächste Seite
+ PdfTable leftoverTable = new PdfTable(table.getColWidths());
+ leftoverTable.addHeaderRow(header); // WICHTIG: Header erneut hinzufügen
+ remainingRows.forEach(leftoverTable::addRow);
+
+ return leftoverTable;
+ }
+
+
+ // --- HILFSMETHODEN FÜR TABELLEN ---
+
+ /**
+ * Zeichnet eine einzelne Tabellenzeile (Header oder Daten).
+ * (Kombiniert Linien- und Inhaltszeichnung)
+ */
+ private void drawTableRow(float x, float y, List row, float[] colWidths, float rowHeight, boolean isHeader) throws IOException {
+ float tableWidth = 0;
+ for (float w : colWidths) tableWidth += w;
+
+ // 1. Linien zeichnen
+ drawRowLines(x, y, rowHeight, colWidths, tableWidth);
+
+ // 2. Inhalt zeichnen
+ PDType1Font font = isHeader ? FONT_BOLD : FONT_NORMAL;
+ float fontSize = isHeader ? FONT_SIZE_TABLE_HEADER : FONT_SIZE_TABLE_BODY;
+
+ float currentX = x;
+ float textY = y - TABLE_LINE_HEIGHT + 2; // Y-Startposition für Text (oben in Zelle)
+
+ for (int i = 0; i < row.size(); i++) {
+ String text = (row.get(i) == null) ? "-" : row.get(i);
+ float cellWidth = colWidths[i] - CELL_PADDING * 2;
+
+ // Wähle die Schriftart (Code-Schriftart für Spalte 5)
+ PDType1Font cellFont = font;
+ if (!isHeader && i == 4) { // Index 5 ist "Regex / Erlaubte Werte"
+ cellFont = FONT_CODE;
+ }
+
+
+
+ // Hole alle Zeilen (unterstützt \n und automatischen Umbruch)
+ List lines = getLines(text, cellWidth, cellFont, fontSize);
+ float currentTextY = textY;
+
+ contentStream.setFont(cellFont, fontSize);
+ contentStream.setNonStrokingColor(Color.BLACK);
+
+ // Zeichne jede Zeile des Zellinhalts
+ for (String line : lines) {
+ contentStream.beginText();
+ contentStream.newLineAtOffset(currentX + CELL_PADDING, currentTextY);
+ contentStream.showText(line);
+ contentStream.endText();
+ currentTextY -= TABLE_LINE_HEIGHT;
+ }
+ currentX += colWidths[i];
+ }
+ }
+
+
+ /**
+ * Berechnet die Höhe einer Tabellenzeile basierend auf dem Inhalt.
+ */
+ private float calculateRowHeight(List row, float[] colWidths, float fontSize, boolean isHeader) throws IOException {
+ int maxLines = 1;
+
+ for (int i = 0; i < row.size(); i++) {
+ String text = (row.get(i) == null) ? "-" : row.get(i);
+ float cellWidth = colWidths[i] - CELL_PADDING * 2;
+
+ PDType1Font font = FONT_NORMAL;
+ if (isHeader) {
+ font = FONT_BOLD;
+ } else if (i == 5) { // Index 5 ist "Regex / Erlaubte Werte"
+ font = FONT_CODE;
+ }
+
+ List lines = getLines(text, cellWidth, font, fontSize);
+ maxLines = Math.max(maxLines, lines.size());
+ }
+
+ float height = (maxLines * TABLE_LINE_HEIGHT) + (CELL_PADDING * 2);
+ return Math.max(TABLE_ROW_MIN_HEIGHT, height);
+ }
+
+ /**
+ * Zeichnet die Gitterlinien für EINE Zeile.
+ */
+ private void drawRowLines(float x, float y, float rowHeight, float[] colWidths, float tableWidth) throws IOException {
+ contentStream.setStrokingColor(Color.DARK_GRAY);
+ contentStream.setLineWidth(0.5f);
+
+ // Horizontale Linien
+ contentStream.moveTo(x, y);
+ contentStream.lineTo(x + tableWidth, y);
+ contentStream.moveTo(x, y - rowHeight);
+ contentStream.lineTo(x + tableWidth, y - rowHeight);
+
+ // Vertikale Linien
+ float currentX = x;
+ contentStream.moveTo(currentX, y);
+ contentStream.lineTo(currentX, y - rowHeight); // Erste Linie links
+
+ for (float width : colWidths) {
+ currentX += width;
+ contentStream.moveTo(currentX, y);
+ contentStream.lineTo(currentX, y - rowHeight); // Linien zwischen den Spalten
+ }
+ contentStream.stroke();
+ }
+
+ /**
+ * Bricht Text in Zeilen um, *behält* aber manuelle \n-Umbrüche bei.
+ */
+ private List getLines(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException {
+ List finalLines = new ArrayList<>();
+ if (text == null || text.isEmpty()) {
+ finalLines.add("");
+ return finalLines;
+ }
+
+ String[] parts = text.split("\n");
+
+ for (String part : parts) {
+ // Jedes Teil dann automatisch umbrechen
+ finalLines.addAll(wrapText(part, maxWidth, font, fontSize));
+ }
+ return finalLines;
+ }
+
+
+ /**
+ * Bricht einen langen Text in mehrere Zeilen um, damit er in eine Breite passt.
+ * (Ignoriert/entfernt \n-Zeichen)
+ */
+ private List wrapText(String text, float maxWidth, PDType1Font font, float fontSize) throws IOException {
+ List lines = new ArrayList<>();
+ if (text == null || text.isEmpty()) {
+ lines.add("");
+ return lines;
+ }
+
+ String sanitizedText = text.replaceAll("\\r\\n|\\r|\\n", " ");
+
+ String[] words = sanitizedText.split(" ");
+ StringBuilder line = new StringBuilder();
+
+ for (String word : words) {
+ if (word.isEmpty()) continue;
+
+ float wordWidth = font.getStringWidth(word) / 1000 * fontSize;
+ float lineWidth = font.getStringWidth(line.toString()) / 1000 * fontSize;
+
+ if (wordWidth > maxWidth) {
+ String tempWord = word;
+ while (!tempWord.isEmpty()) {
+ int breakIndex = findBreakIndex(tempWord, maxWidth, font, fontSize);
+ String part = tempWord.substring(0, breakIndex);
+
+ if (line.length() > 0) {
+ lines.add(line.toString());
+ line = new StringBuilder(part);
+ } else {
+ lines.add(part);
+ }
+ tempWord = tempWord.substring(breakIndex);
+ }
+ } else if (lineWidth + wordWidth < maxWidth) {
+ if (line.length() > 0) line.append(" ");
+ line.append(word);
+ } else {
+ lines.add(line.toString());
+ line = new StringBuilder(word);
+ }
+ }
+ lines.add(line.toString()); // Letzte Zeile hinzufügen
+ return lines;
+ }
+
+ /**
+ * Hilfsfunktion für wrapText: Findet den Umbruchpunkt in einem zu langen Wort.
+ */
+ private int findBreakIndex(String word, float maxWidth, PDType1Font font, float fontSize) throws IOException {
+ int i = 0;
+ float width = 0;
+ while (i < word.length()) {
+ char c = word.charAt(i);
+ float charWidth = font.getStringWidth(String.valueOf(c)) / 1000 * fontSize;
+ if (width + charWidth > maxWidth) {
+ break;
+ }
+ width += charWidth;
+ i++;
+ }
+ return Math.max(1, i); // Mindestens 1 Zeichen zurückgeben
+ }
+
+ /**
+ * Schließt den Content-Stream.
+ * Erforderlich durch AutoCloseable.
+ */
+ @Override
+ public void close() throws IOException {
+ if (this.contentStream != null) {
+ this.contentStream.close();
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java
new file mode 100644
index 0000000..07b390d
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfDocument.java
@@ -0,0 +1,165 @@
+package de.haevn.snippetmanage.common.utils.pdf;
+
+import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
+import de.haevn.snippetmanage.common.exception.InternalServerErrorException;
+import de.haevn.snippetmanage.common.utils.html.HtmlDoc;
+import org.apache.pdfbox.Loader; // NEUER Import
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Map;
+
+/**
+ * Repräsentiert ein komplettes PDF-Dokument.
+ * Diese Klasse ist AutoCloseable, um sicherzustellen, dass das
+ * zugrundeliegende PDDocument immer geschlossen wird.
+ *
+ * Beispiel-Nutzung:
+ * try (PdfDocument doc = new PdfDocument()) {
+ * doc.addTitlePage("Titel", infoMap);
+ *
+ * PageCanvas page1 = doc.addPage();
+ * page1.drawText(50, 700, "Hallo Welt");
+ * page1.close(); // WICHTIG: Jede Seite nach dem Zeichnen schließen
+ *
+ * doc.addHtmlPages(new PdfHtml("Titel
")); // HTML-Seiten hinzufügen
+ *
+ * doc.save("pfad/zur/datei.pdf");
+ * }
+ */
+public class PdfDocument implements AutoCloseable {
+
+ private final PDDocument document;
+
+ /**
+ * Erstellt ein neues, leeres PDF-Dokument.
+ */
+ public PdfDocument() {
+ this.document = new PDDocument();
+ }
+
+ /**
+ * Fügt eine standardmäßige A4-Inhaltsseite hinzu.
+ * Sie MÜSSEN .close() auf dem zurückgegebenen PageCanvas aufrufen,
+ * wenn Sie mit dem Zeichnen fertig sind.
+ *
+ * @return Ein PageCanvas-Objekt zum Zeichnen auf der neuen Seite.
+ * @throws IOException Wenn der Content-Stream nicht erstellt werden kann.
+ */
+ public PageCanvas addPage() throws IOException {
+ PDPage page = new PDPage(PDRectangle.A4);
+ this.document.addPage(page);
+ return new PageCanvas(this.document, page);
+ }
+
+ /**
+ * Fügt eine Titelseite mit einem Haupttitel und einer zentrierten
+ * Key-Value-Informationskarte hinzu.
+ *
+ * @param title Der Haupttitel der Seite.
+ * @param info Eine Map von Key-Value-Paaren (z.B. "Autor", "Max Mustermann").
+ * @throws IOException Wenn beim Zeichnen ein Fehler auftritt.
+ */
+ public void addTitlePage(String title, Map info) throws IOException {
+ TitlePageRenderer.draw(this.document, title, info);
+ }
+
+ /**
+ * Rendert den Inhalt eines PdfHtml-Objekts (mithilfe von openhtmltopdf)
+ * und fügt alle resultierenden Seiten diesem Dokument hinzu.
+ *
+ * @param html Das PdfHtml-Builder-Objekt, das den HTML-Code enthält.
+ * @throws IOException Wenn das Rendern oder Importieren fehlschlägt.
+ */
+ public void addHtmlPages(HtmlDoc html) throws IOException {
+ // 1. Rendere das HTML in ein *temporäres*, separates In-Memory-PDDocument
+ PDDocument tempDoc = null;
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ PdfRendererBuilder builder = new PdfRendererBuilder();
+ builder.withHtmlContent(html.getHtmlContent(), "file:/"); // Basis-URI (wichtig für relative Pfade)
+ builder.toStream(baos);
+ builder.run();
+
+ // KORREKTUR: Verwende Loader.loadPDF statt PDDocument.load
+ tempDoc = Loader.loadPDF(baos.toByteArray()); // Lade das gerenderte PDF
+
+ }catch (Exception ex) {
+ throw new InternalServerErrorException(ex);
+ }
+
+ // 2. Importiere alle Seiten aus dem temporären Dokument in das Hauptdokument
+ if (tempDoc != null) {
+ for (PDPage page : tempDoc.getPages()) {
+ // Importiere die Seite in den Kontext unseres Hauptdokuments
+ this.document.importPage(page);
+
+ // (Optional) Manchmal ist es nötig, die Boxen anzupassen,
+ // aber openhtmltopdf sollte korrekte A4-Seiten erstellen.
+ // this.document.getPage(this.document.getNumberOfPages() - 1).setMediaBox(PDRectangle.A4);
+ }
+
+ // 3. Schließe das temporäre Dokument, um Speicher freizugeben
+ tempDoc.close();
+ }
+ }
+
+
+ /**
+ * Speichert das Dokument im Dateisystem.
+ *
+ * @param path Der vollständige Pfad, einschließlich Dateiname.
+ * @throws IOException Wenn das Speichern fehlschlägt.
+ */
+ public void save(String path) throws IOException {
+ this.document.save(path);
+ }
+
+ /**
+ * Speichert das Dokument im Dateisystem.
+ *
+ * @param path Der Pfad als Path-Objekt.
+ * @throws IOException Wenn das Speichern fehlschlägt.
+ */
+ public void save(Path path) throws IOException {
+ this.document.save(path.toFile());
+ }
+
+ /**
+ * Exportiert das Dokument als Byte-Array.
+ *
+ * @return Ein Byte-Array des PDF-Dokuments.
+ * @throws IOException Wenn das Speichern in den Stream fehlschlägt.
+ */
+ public byte[] getAsBytes() throws IOException {
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ this.document.save(baos);
+ return baos.toByteArray();
+ }
+ }
+
+ /**
+ * Schließt das zugrundeliegende PDDocument.
+ * Erforderlich durch AutoCloseable.
+ *
+ * @throws IOException Wenn das Schließen fehlschlägt.
+ */
+ @Override
+ public void close() throws IOException {
+ if (this.document != null) {
+ this.document.close();
+ }
+ }
+
+ /**
+ * Gibt das interne PDDocument zurück, falls erweiterte PDFBox-Operationen
+ * erforderlich sind.
+ * @return Das PDDocument.
+ */
+ public PDDocument getInternalDocument() {
+ return this.document;
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java
new file mode 100644
index 0000000..abda64e
--- /dev/null
+++ b/backend/src/main/java/de/haevn/snippetmanage/common/utils/pdf/PdfHtml.java
@@ -0,0 +1,268 @@
+package de.haevn.snippetmanage.common.utils.pdf;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Eine Datenklasse, die HTML-Inhalt für den PDF-Export repräsentiert.
+ * Sie verwenden diese Klasse, um den HTML-Inhalt zu übergeben,
+ * und rufen dann PdfDocument.addHtmlPages() auf, um ihn zu rendern
+ * und dem Dokument hinzuzufügen.
+ *
+ * Dies ist eine Builder-Klasse, um HTML-Strukturen flüssig zu erstellen.
+ */
+public class PdfHtml {
+
+ // Liste für die einzelnen HTML-Element-Strings
+ private final List elements = new ArrayList<>();
+
+ // Speicher für zusätzliches, benutzerdefiniertes CSS
+ private String customCss = "";
+
+ /**
+ * Standard-Konstruktor.
+ */
+ public PdfHtml() {
+ // Startet einen leeren HTML-Builder
+ }
+
+ // --- ÖFFENTLICHE BUILDER-METHODEN ---
+
+ /**
+ * Fügt einen rohen HTML-Element-String hinzu.
+ * @param element Ein vollständiger HTML-String (z.B. "Hallo
")
+ * @return this (für flüssige Verkettung)
+ */
+ public PdfHtml addElement(String element) {
+ elements.add(element);
+ return this;
+ }
+
+ /**
+ * Fügt eine Überschrift der Ebene 1 hinzu.
+ * @param text Der Text für die Überschrift.
+ * @return this
+ */
+ public PdfHtml addH1(String text) {
+ elements.add(String.format("%s
", escapeHtml(text)));
+ return this;
+ }
+
+ /**
+ * Fügt eine Überschrift der Ebene 2 hinzu.
+ * @param text Der Text für die Überschrift.
+ * @return this
+ */
+ public PdfHtml addH2(String text) {
+ elements.add(String.format("%s
", escapeHtml(text)));
+ return this;
+ }
+
+ /**
+ * Fügt eine Überschrift der Ebene 3 hinzu.
+ * @param text Der Text für die Überschrift.
+ * @return this
+ */
+ public PdfHtml addH3(String text) {
+ elements.add(String.format("%s
", escapeHtml(text)));
+ return this;
+ }
+
+ /**
+ * Fügt einen Standard-Absatz hinzu.
+ * @param text Der Text für den Absatz (HTML-Tags wie oder sind erlaubt).
+ * @return this
+ */
+ public PdfHtml addParagraph(String text) {
+ elements.add(String.format("%s
", escapeHtml(text)));
+ return this;
+ }
+
+ /**
+ * Fügt eine unsortierte Liste (Bullet-Points) hinzu.
+ * @param items Eine Liste von Strings für die Listeneinträge.
+ * @return this
+ */
+ public PdfHtml addList(List items) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("");
+ for (String item : items) {
+ sb.append(String.format("- %s
", escapeHtml(item))); // Erlaubt HTML im Item
+ }
+ sb.append("
");
+ elements.add(sb.toString());
+ return this;
+ }
+
+ public PdfHtml addList(String title, List items) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("");
+ sb.append(String.format("%s", escapeHtml(title)));
+ for (String item : items) {
+ sb.append(String.format("- %s
", escapeHtml(item))); // Erlaubt HTML im Item
+ }
+ sb.append("
");
+ elements.add(sb.toString());
+ return this;
+ }
+
+ /**
+ * Fügt eine sortierte (nummerierte) Liste hinzu.
+ * @param items Eine Liste von Strings für die Listeneinträge.
+ * @return this
+ */
+ public PdfHtml addOrderedList(List items) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("");
+ for (String item : items) {
+ sb.append(String.format("- %s
", escapeHtml(item))); // Erlaubt HTML im Item
+ }
+ sb.append("
");
+ elements.add(sb.toString());
+ return this;
+ }
+
+ public PdfHtml addOrderedList(String title, List items) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("");
+ sb.append(String.format("%s", escapeHtml(title)));
+ for (String item : items) {
+ sb.append(String.format("- %s
", escapeHtml(item))); // Erlaubt HTML im Item
+ }
+ sb.append("
");
+ elements.add(sb.toString());
+ return this;
+ }
+
+ /**
+ * Fügt einen Code-Block hinzu, ideal für Quellcode.
+ * Der Inhalt wird automatisch HTML-escaped.
+ * @param code Der rohe Quellcode.
+ * @return this
+ */
+ public PdfHtml addCodeBlock(String code) {
+ String escapedCode = escapeHtml(code);
+ elements.add(String.format("%s
", escapedCode));
+ return this;
+ }
+
+ public PdfHtml addCodeBlock(String subTitle, String code) {
+ String escapedCode = escapeHtml(code);
+ elements.add(String.format("%s
%s
", escapeHtml(subTitle), escapedCode));
+ return this;
+ }
+
+ /**
+ * Fügt ein Zitat (Blockquote) hinzu.
+ * @param text Der Text des Zitats.
+ * @return this
+ */
+ public PdfHtml addBlockquote(String text) {
+ elements.add(String.format("%s
", text)); // Erlaubt HTML
+ return this;
+ }
+
+ /**
+ * Fügt eine horizontale Trennlinie hinzu.
+ * @return this
+ */
+ public PdfHtml addHorizontalRule() {
+ elements.add("
");
+ return this;
+ }
+
+ /**
+ * Fügt eine HTML-Tabelle hinzu.
+ * @param headers Spaltenüberschriften (kann null oder leer sein).
+ * @param data Eine Liste von Listen für die Zeilendaten.
+ * @return this
+ */
+ public PdfHtml addTable(List headers, List> data) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("");
+
+ // 1. Header
+ if (headers != null && !headers.isEmpty()) {
+ sb.append("");
+ for (String header : headers) {
+ sb.append(String.format("| %s | ", escapeHtml(header)));
+ }
+ sb.append("
");
+ }
+
+ // 2. Body
+ sb.append("");
+ if (data != null) {
+ for (List row : data) {
+ sb.append("");
+ for (String cell : row) {
+ // Zelleninhalt nicht escapen, um HTML (z.B. ) zu erlauben
+ sb.append(String.format("| %s | ", cell));
+ }
+ sb.append("
");
+ }
+ }
+ sb.append("
");
+ elements.add(sb.toString());
+ return this;
+ }
+
+ /**
+ * Fügt eine benutzerdefinierte CSS-Regel zum " + content + "