From 0733f01f4b8cd62c2c185726dc15e9841ea81ca0 Mon Sep 17 00:00:00 2001
From: akpmarcelin Structure du rapport :
+ *
+ *
+ *
Utilise Apache PDFBox 3.x. Toutes les polices utilisées sont des + * polices standard PDF (Helvetica) pour garantir la compatibilité.
*/ public class PdfExporter { - private static final float MARGIN = 50f; - private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight(); - private static final float PAGE_WIDTH = PDRectangle.A4.getWidth(); + // ── Mise en page ────────────────────────────────────────────────────────── + private static final float PAGE_HEIGHT = PDRectangle.A4.getHeight(); // 841.89 pts + private static final float PAGE_WIDTH = PDRectangle.A4.getWidth(); // 595.28 pts + private static final float MARGIN = 45f; private static final float CONTENT_WIDTH = PAGE_WIDTH - 2 * MARGIN; - private static final float FONT_TITLE = 18f; - private static final float FONT_HEADING = 13f; - private static final float FONT_SUBHEAD = 11f; - private static final float FONT_BODY = 10f; + // ── Bandeau d'en-tête (haut de la première page) ────────────────────────── + private static final float HEADER_HEIGHT = 52f; + private static final float HEADER_Y = PAGE_HEIGHT - HEADER_HEIGHT; + + // ── Tailles de police ───────────────────────────────────────────────────── + private static final float SIZE_APP_NAME = 20f; // "CyberLab Manager" + private static final float SIZE_SUBTITLE = 8f; // sous-titre dans le bandeau + private static final float SIZE_TITLE = 16f; // titre du labo + private static final float SIZE_HEADING = 11f; // titres de section + private static final float SIZE_LABEL = 9f; // étiquettes (UUID, Statut…) + private static final float SIZE_BODY = 9f; // corps de texte + private static final float SIZE_FOOTER = 7.5f; // pied de page - private static final PDType1Font FONT_BOLD = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); - private static final PDType1Font FONT_REGULAR = new PDType1Font(Standard14Fonts.FontName.HELVETICA); - private static final PDType1Font FONT_OBLIQUE = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE); + // ── Polices PDFBox 3.x ──────────────────────────────────────────────────── + private static final PDType1Font F_BOLD = new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD); + private static final PDType1Font F_REGULAR = new PDType1Font(Standard14Fonts.FontName.HELVETICA); + private static final PDType1Font F_OBLIQUE = new PDType1Font(Standard14Fonts.FontName.HELVETICA_OBLIQUE); - private static final DateTimeFormatter DISPLAY_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); + // ── Formatage des dates ──────────────────────────────────────────────────── + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); + // ── État interne ────────────────────────────────────────────────────────── private PDDocument document; - private PDPageContentStream content; - private float cursorY; + private PDPageContentStream cs; // content stream de la page courante + private float y; // curseur vertical (descend vers 0) + private int pageNum; // numéro de page courant + + // ========================================================================= + // Méthode publique principale + // ========================================================================= /** * Génère et sauvegarde le rapport PDF d'un laboratoire. * * @param lab le laboratoire exporté - * @param vms liste des VMs du labo (déjà filtrée par le contrôleur) - * @param snapshots liste des snapshots (déjà filtrée par le contrôleur) - * @param journals liste des entrées de journal (déjà filtrée par le contrôleur) + * @param vms liste des VMs (déjà filtrée par le contrôleur) + * @param snapshots liste des snapshots (déjà filtrée) + * @param journals liste des entrées de journal (déjà filtrée) * @param filePath chemin complet du fichier PDF à créer * @throws IOException si l'écriture du fichier échoue */ @@ -62,196 +90,364 @@ public void exportLabToPdf(Lab lab, String filePath) throws IOException { document = new PDDocument(); + pageNum = 0; newPage(); - // En-tête - writeLine("Rapport – " + nvl(lab.getTitle(), "Sans titre"), FONT_BOLD, FONT_TITLE, true); - writeLine("Catégorie : " + nvl(lab.getCategory(), "Non définie"), FONT_REGULAR, FONT_BODY, false); - writeLine("Généré le : " + now(), FONT_REGULAR, FONT_BODY, false); - if (lab.getDescription() != null && !lab.getDescription().isBlank()) { - writeLine("Description : " + lab.getDescription(), FONT_OBLIQUE, FONT_BODY, false); - } - spacer(12f); - separator(); - spacer(8f); + // ── Bandeau principal ───────────────────────────────────────────────── + drawHeaderBanner(lab); - // Section 1 : Machines virtuelles - writeLine("1. Machines virtuelles (" + vms.size() + ")", FONT_BOLD, FONT_HEADING, false); - spacer(6f); + // ── Bloc infos labo ─────────────────────────────────────────────────── + drawLabInfoBlock(lab); + gap(10f); + + // ── Section 1 — VMs ─────────────────────────────────────────────────── + drawSectionTitle("1. Machines virtuelles", vms.size()); if (vms.isEmpty()) { - writeLine(" Aucune machine virtuelle dans ce laboratoire.", FONT_OBLIQUE, FONT_BODY, false); + writeBodyLine("Aucune machine virtuelle dans ce laboratoire.", F_OBLIQUE); } else { for (VirtualMachine vm : vms) { - checkPageBreak(60f); - writeLine(" ▸ " + nvl(vm.getName(), "Sans nom"), FONT_BOLD, FONT_SUBHEAD, false); - writeLine(" UUID : " + nvl(vm.getUuid(), "—"), FONT_REGULAR, FONT_BODY, false); - writeLine(" Statut : " + (vm.getStatus() != null ? vm.getStatus().name() : "UNKNOWN"), - FONT_REGULAR, FONT_BODY, false); - if (vm.getDocumentationUrl() != null && !vm.getDocumentationUrl().isBlank()) { - writeLine(" Doc : " + vm.getDocumentationUrl(), FONT_REGULAR, FONT_BODY, false); - } - spacer(5f); + drawVmCard(vm); } } - spacer(8f); - separator(); - spacer(8f); - - // Section 2 : Snapshots - writeLine("2. Snapshots (" + snapshots.size() + ")", FONT_BOLD, FONT_HEADING, false); - spacer(6f); + gap(6f); + // ── Section 2 — Snapshots ───────────────────────────────────────────── + drawSectionTitle("2. Snapshots", snapshots.size()); if (snapshots.isEmpty()) { - writeLine(" Aucun snapshot enregistré.", FONT_OBLIQUE, FONT_BODY, false); + writeBodyLine("Aucun snapshot enregistre.", F_OBLIQUE); } else { for (Snapshot snap : snapshots) { - checkPageBreak(40f); - writeLine(" ▸ " + nvl(snap.getName(), "Sans nom"), FONT_BOLD, FONT_SUBHEAD, false); - writeLine(" Date : " + formatDate(snap.getCreatedAt()), FONT_REGULAR, FONT_BODY, false); - writeLine(" Type : " + (snap.isOnline() ? "À chaud (VM allumée)" : "À froid (VM éteinte)"), - FONT_REGULAR, FONT_BODY, false); - if (snap.getDescription() != null && !snap.getDescription().isBlank()) { - writeLine(" Note : " + snap.getDescription(), FONT_OBLIQUE, FONT_BODY, false); - } - spacer(4f); + drawSnapshotCard(snap); } } - spacer(8f); - separator(); - spacer(8f); - - // Section 3 : Journal de bord - writeLine("3. Journal de bord (" + journals.size() + " entrée(s))", FONT_BOLD, FONT_HEADING, false); - spacer(6f); + gap(6f); + // ── Section 3 — Journal ─────────────────────────────────────────────── + drawSectionTitle("3. Journal de bord", journals.size()); if (journals.isEmpty()) { - writeLine(" Aucune note dans le journal.", FONT_OBLIQUE, FONT_BODY, false); + writeBodyLine("Aucune note dans le journal.", F_OBLIQUE); } else { for (JournalEntry entry : journals) { - checkPageBreak(40f); - writeLine(" [" + formatDate(entry.getTimestamp()) + "]", FONT_BOLD, FONT_BODY, false); - writeWrappedText(" " + nvl(entry.getContent(), ""), FONT_REGULAR, FONT_BODY); - spacer(5f); + drawJournalCard(entry); } } + // ── Fermeture ───────────────────────────────────────────────────────── writeFooter(); - - content.close(); + cs.close(); document.save(filePath); document.close(); } + // ========================================================================= + // Composants visuels + // ========================================================================= - // Gestion des pages - + /** + * Dessine le bandeau sombre en haut de la première page. + * Contient le nom de l'application et le sous-titre "Rapport de laboratoire". + */ + private void drawHeaderBanner(Lab lab) throws IOException { + // Rectangle de fond gris foncé + cs.setNonStrokingColor(0.18f, 0.18f, 0.18f); // ~#2E2E2E + cs.addRect(0, HEADER_Y, PAGE_WIDTH, HEADER_HEIGHT); + cs.fill(); + + // Ligne d'accent colorée en bas du bandeau (bleu-gris) + cs.setNonStrokingColor(0.25f, 0.47f, 0.70f); // #4078B3 + cs.addRect(0, HEADER_Y, PAGE_WIDTH, 3f); + cs.fill(); + + // Texte "CyberLab Manager" en blanc + cs.setNonStrokingColor(1f, 1f, 1f); + cs.beginText(); + cs.setFont(F_BOLD, SIZE_APP_NAME); + cs.newLineAtOffset(MARGIN, HEADER_Y + 24f); + cs.showText("CyberLab Manager"); + cs.endText(); + + // Sous-titre "Rapport de laboratoire" en gris clair + cs.setNonStrokingColor(0.75f, 0.75f, 0.75f); + cs.beginText(); + cs.setFont(F_REGULAR, SIZE_SUBTITLE); + cs.newLineAtOffset(MARGIN, HEADER_Y + 10f); + cs.showText("Rapport de laboratoire | " + now()); + cs.endText(); + + // Remettre la couleur par défaut + cs.setNonStrokingColor(0f, 0f, 0f); + + // Positionner le curseur sous le bandeau + y = HEADER_Y - 18f; + } - private void newPage() throws IOException { - if (content != null) { - content.close(); + /** + * Dessine le bloc d'informations du laboratoire (titre, catégorie, description). + */ + private void drawLabInfoBlock(Lab lab) throws IOException { + // Titre du labo + cs.beginText(); + cs.setFont(F_BOLD, SIZE_TITLE); + cs.newLineAtOffset(MARGIN, y); + cs.showText(sanitize(nvl(lab.getTitle(), "Laboratoire sans titre"))); + cs.endText(); + y -= SIZE_TITLE + 5f; + + // Ligne de séparation fine sous le titre + cs.setStrokingColor(0.25f, 0.47f, 0.70f); + cs.setLineWidth(1.5f); + cs.moveTo(MARGIN, y); + cs.lineTo(PAGE_WIDTH - MARGIN, y); + cs.stroke(); + cs.setStrokingColor(0f, 0f, 0f); + y -= 8f; + + // Catégorie + writeKeyValue("Categorie", nvl(lab.getCategory(), "Non definie")); + + // Description + if (lab.getDescription() != null && !lab.getDescription().isBlank()) { + writeKeyValue("Description", lab.getDescription()); } - PDPage page = new PDPage(PDRectangle.A4); - document.addPage(page); - content = new PDPageContentStream(document, page); - cursorY = PAGE_HEIGHT - MARGIN; } - private void checkPageBreak(float needed) throws IOException { - if (cursorY - needed < MARGIN + 20f) { - newPage(); + /** + * Dessine le titre d'une section avec un fond gris léger et le nombre d'éléments. + */ + private void drawSectionTitle(String title, int count) throws IOException { + ensureSpace(28f); + + // Rectangle de fond gris très léger + cs.setNonStrokingColor(0.93f, 0.93f, 0.93f); + cs.addRect(MARGIN - 5f, y - 4f, CONTENT_WIDTH + 10f, SIZE_HEADING + 10f); + cs.fill(); + cs.setNonStrokingColor(0f, 0f, 0f); + + // Texte de la section + cs.beginText(); + cs.setFont(F_BOLD, SIZE_HEADING); + cs.newLineAtOffset(MARGIN, y + 2f); + cs.showText(sanitize(title) + " (" + count + ")"); + cs.endText(); + + y -= SIZE_HEADING + 12f; + } + + /** + * Dessine la carte d'une VM avec une légère indentation et une puce. + */ + private void drawVmCard(VirtualMachine vm) throws IOException { + ensureSpace(55f); + + // Puce ">" remplace le ▸ non supporté + writeLabel("> " + sanitize(nvl(vm.getName(), "VM sans nom")), F_BOLD, SIZE_LABEL + 1f); + writeKeyValue(" UUID ", nvl(vm.getUuid(), "-")); + writeKeyValue(" Statut", vm.getStatus() != null ? vm.getStatus().name() : "UNKNOWN"); + if (vm.getDocumentationUrl() != null && !vm.getDocumentationUrl().isBlank()) { + writeKeyValue(" Doc ", vm.getDocumentationUrl()); } + gap(4f); } - // Écriture de contenu - - private void writeLine(String text, PDType1Font font, float size, boolean underline) - throws IOException { - checkPageBreak(size + 6f); - cursorY -= size + 2f; - - content.beginText(); - content.setFont(font, size); - content.newLineAtOffset(MARGIN, cursorY); - content.showText(sanitize(text)); - content.endText(); - - if (underline) { - float textWidth = font.getStringWidth(sanitize(text)) / 1000 * size; - content.moveTo(MARGIN, cursorY - 2f); - content.lineTo(MARGIN + textWidth, cursorY - 2f); - content.stroke(); - cursorY -= 4f; + /** + * Dessine la carte d'un snapshot. + */ + private void drawSnapshotCard(Snapshot snap) throws IOException { + ensureSpace(45f); + + writeLabel("> " + sanitize(nvl(snap.getName(), "Snapshot sans nom")), F_BOLD, SIZE_LABEL + 1f); + writeKeyValue(" Date ", formatDate(snap.getCreatedAt())); + writeKeyValue(" Type ", snap.isOnline() ? "A chaud (VM allumee)" : "A froid (VM eteinte)"); + if (snap.getDescription() != null && !snap.getDescription().isBlank()) { + writeKeyValue(" Note ", snap.getDescription()); } + gap(4f); + } + + /** + * Dessine la carte d'une entrée de journal. + */ + private void drawJournalCard(JournalEntry entry) throws IOException { + ensureSpace(35f); + + // Horodatage en gras + writeLabel("[" + formatDate(entry.getTimestamp()) + "]", F_BOLD, SIZE_BODY); + + // Contenu avec wrapping automatique + writeWrappedText(" " + sanitize(nvl(entry.getContent(), "")), F_REGULAR, SIZE_BODY); + gap(5f); + } + + // ========================================================================= + // Primitives d'écriture + // ========================================================================= + + /** Écrit une ligne clé : valeur avec la clé en gras et la valeur en regular. */ + private void writeKeyValue(String key, String value) throws IOException { + ensureSpace(SIZE_LABEL + 4f); + + float keyWidth = F_BOLD.getStringWidth(key + " : ") / 1000 * SIZE_LABEL; + + cs.beginText(); + cs.setFont(F_BOLD, SIZE_LABEL); + cs.newLineAtOffset(MARGIN, y); + cs.showText(sanitize(key) + " : "); + cs.endText(); + + cs.beginText(); + cs.setFont(F_REGULAR, SIZE_LABEL); + cs.newLineAtOffset(MARGIN + keyWidth, y); + cs.showText(sanitize(value)); + cs.endText(); + + y -= SIZE_LABEL + 4f; + } + + /** Écrit une ligne simple avec la police et taille données. */ + private void writeLabel(String text, PDType1Font font, float size) throws IOException { + ensureSpace(size + 4f); + cs.beginText(); + cs.setFont(font, size); + cs.newLineAtOffset(MARGIN, y); + cs.showText(sanitize(text)); + cs.endText(); + y -= size + 4f; + } + + /** Écrit une ligne de corps de texte simple. */ + private void writeBodyLine(String text, PDType1Font font) throws IOException { + writeLabel(" " + text, font, SIZE_BODY); } + /** Écrit un texte long en le découpant automatiquement sur plusieurs lignes. */ private void writeWrappedText(String text, PDType1Font font, float size) throws IOException { - float maxWidth = CONTENT_WIDTH; + float maxWidth = CONTENT_WIDTH - 10f; String[] words = sanitize(text).split(" "); StringBuilder line = new StringBuilder(); for (String word : words) { + if (word.isEmpty()) continue; String candidate = line.isEmpty() ? word : line + " " + word; float w = font.getStringWidth(candidate) / 1000 * size; if (w > maxWidth && !line.isEmpty()) { - writeLine(line.toString(), font, size, false); + writeLabel(line.toString(), font, size); line = new StringBuilder(word); } else { line = new StringBuilder(candidate); } } if (!line.isEmpty()) { - writeLine(line.toString(), font, size, false); + writeLabel(line.toString(), font, size); } } - private void spacer(float points) { - cursorY -= points; + /** Écrit le pied de page de la page courante. */ + private void writeFooter() throws IOException { + // Ligne de séparation + cs.setStrokingColor(0.7f, 0.7f, 0.7f); + cs.setLineWidth(0.5f); + cs.moveTo(MARGIN, MARGIN - 5f); + cs.lineTo(PAGE_WIDTH - MARGIN, MARGIN - 5f); + cs.stroke(); + cs.setStrokingColor(0f, 0f, 0f); + + // Texte gauche + cs.beginText(); + cs.setFont(F_OBLIQUE, SIZE_FOOTER); + cs.newLineAtOffset(MARGIN, MARGIN - 16f); + cs.showText("CyberLab Manager - Rapport genere le " + now()); + cs.endText(); + + // Numéro de page à droite + String pageLabel = "Page " + pageNum; + float labelWidth = F_REGULAR.getStringWidth(pageLabel) / 1000 * SIZE_FOOTER; + cs.beginText(); + cs.setFont(F_REGULAR, SIZE_FOOTER); + cs.newLineAtOffset(PAGE_WIDTH - MARGIN - labelWidth, MARGIN - 16f); + cs.showText(pageLabel); + cs.endText(); } - private void separator() throws IOException { - content.setLineWidth(0.5f); - content.moveTo(MARGIN, cursorY); - content.lineTo(PAGE_WIDTH - MARGIN, cursorY); - content.stroke(); - cursorY -= 2f; - } + // ========================================================================= + // Gestion des pages + // ========================================================================= - private void writeFooter() throws IOException { - float footerY = MARGIN - 10f; - content.beginText(); - content.setFont(FONT_OBLIQUE, 8f); - content.newLineAtOffset(MARGIN, footerY); - content.showText("Cyber Lab Manager – Rapport généré le " + now()); - content.endText(); + /** Crée une nouvelle page A4 et positionne le curseur en haut. */ + private void newPage() throws IOException { + if (cs != null) { + writeFooter(); + cs.close(); + } + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + cs = new PDPageContentStream(document, page); + pageNum++; + y = PAGE_HEIGHT - MARGIN; } + /** + * Vérifie si l'espace restant est suffisant, sinon crée une nouvelle page. + * + * @param needed espace vertical nécessaire en points + */ + private void ensureSpace(float needed) throws IOException { + if (y - needed < MARGIN + 25f) { + newPage(); + } + } + // ========================================================================= // Utilitaires + // ========================================================================= + + /** Ajoute un espace vertical. */ + private void gap(float points) { + y -= points; + } + /** Retourne la valeur si non nulle/vide, sinon le fallback. */ private String nvl(String value, String fallback) { return (value != null && !value.isBlank()) ? value : fallback; } - private String now() { - return LocalDateTime.now().format(DISPLAY_FORMAT); + /** Formate une date LocalDateTime pour affichage. */ + private String formatDate(LocalDateTime dt) { + return dt != null ? dt.format(FMT) : "-"; } - private String formatDate(LocalDateTime dt) { - return dt != null ? dt.format(DISPLAY_FORMAT) : "—"; + /** Date/heure courante formatée. */ + private String now() { + return LocalDateTime.now().format(FMT); } /** - * Nettoie les caractères non supportés par les polices standard PDF (Latin-1 uniquement). - * Remplace les caractères hors plage par '?'. + * Nettoie les caractères hors Latin-1 non supportés par les polices PDF standard. + * Les caractères spéciaux comme les flèches Unicode sont remplacés par des + * équivalents ASCII. + * + * @param text texte à nettoyer + * @return texte compatible Latin-1 */ private String sanitize(String text) { if (text == null) return ""; StringBuilder sb = new StringBuilder(); for (char c : text.toCharArray()) { - sb.append(c <= 255 ? c : '?'); + if (c <= 255) { + sb.append(c); + } else { + // Remplacements explicites pour les caractères courants + switch (c) { + case '\u25B8': sb.append(">"); break; // ▸ + case '\u2192': sb.append("->"); break; // → + case '\u2022': sb.append("-"); break; // • + case '\u2013': sb.append("-"); break; // – + case '\u2014': sb.append("--"); break; // — + default: sb.append("?"); break; + } + } } return sb.toString(); }