|
8 | 8 | import com.lowagie.text.Paragraph; |
9 | 9 | import com.lowagie.text.Phrase; |
10 | 10 | import com.lowagie.text.Rectangle; |
| 11 | +import com.lowagie.text.pdf.ColumnText; |
| 12 | +import com.lowagie.text.pdf.PdfContentByte; |
11 | 13 | import com.lowagie.text.pdf.PdfPCell; |
12 | 14 | import com.lowagie.text.pdf.PdfPTable; |
13 | 15 | import com.lowagie.text.pdf.PdfWriter; |
@@ -118,7 +120,7 @@ public void generateReport(UUID fileId, ReportRequest request, OutputStream out) |
118 | 120 |
|
119 | 121 | Document document = new Document(PageSize.A4, 40, 40, 60, 40); |
120 | 122 | try { |
121 | | - PdfWriter.getInstance(document, out); |
| 123 | + PdfWriter writer = PdfWriter.getInstance(document, out); |
122 | 124 | document.open(); |
123 | 125 |
|
124 | 126 | // ── Sections ────────────────────────────────────────────────────────── |
@@ -202,13 +204,15 @@ public void generateReport(UUID fileId, ReportRequest request, OutputStream out) |
202 | 204 | addExtractedFiles(document, extractedFiles, sec++); |
203 | 205 | } |
204 | 206 |
|
205 | | - if (request.getActiveFilters() != null && !request.getActiveFilters().isEmpty()) { |
206 | | - addNetworkDiagramFilters(document, request.getActiveFilters(), sec++); |
207 | | - } |
208 | | - |
209 | | - addTopologyDiagram(document, request.getForceDirectedImage(), "Force-Directed Layout", sec++); |
| 207 | + List<String> activeFilters = |
| 208 | + request.getActiveFilters() != null ? request.getActiveFilters() : List.of(); |
| 209 | + String nodeLimitNote = request.getNodeLimitNote(); |
| 210 | + addTopologyDiagram( |
| 211 | + document, writer, request.getForceDirectedImage(), "Force-Directed Layout", sec++, |
| 212 | + activeFilters, nodeLimitNote); |
210 | 213 | addTopologyDiagram( |
211 | | - document, request.getHierarchicalImage(), "Hierarchical Layout (Top-Down)", sec++); |
| 214 | + document, writer, request.getHierarchicalImage(), "Hierarchical Layout (Top-Down)", sec++, |
| 215 | + activeFilters, nodeLimitNote); |
212 | 216 |
|
213 | 217 | } catch (Exception e) { |
214 | 218 | log.error("PDF generation failed for file {}", fileId, e); |
@@ -828,57 +832,177 @@ private void addNetworkDiagramFilters(Document doc, List<String> filters, int se |
828 | 832 | // ══════════════════════════════════════════════════════════════════════════ |
829 | 833 |
|
830 | 834 | private void addTopologyDiagram( |
831 | | - Document doc, String base64Image, String layoutName, int sectionNum) throws Exception { |
| 835 | + Document doc, PdfWriter writer, String base64Image, String layoutName, int sectionNum, |
| 836 | + List<String> activeFilters, String nodeLimitNote) |
| 837 | + throws Exception { |
832 | 838 |
|
833 | | - // Each topology diagram gets its own full landscape page. |
834 | | - // Use an explicit Rectangle rather than rotate() so the size is applied |
835 | | - // unambiguously regardless of the previous page's orientation. |
836 | | - Rectangle landscape = new Rectangle(PageSize.A4.getHeight(), PageSize.A4.getWidth()); |
837 | | - doc.setPageSize(landscape); |
| 839 | + // ── Page setup ──────────────────────────────────────────────────────────── |
| 840 | + // setPageSize only takes effect on the next newPage(). Calling newPage() |
| 841 | + // twice guarantees the landscape size is active before we draw anything: |
| 842 | + // the first call closes the previous page with its current size, the second |
| 843 | + // opens a fresh page with the new landscape size. |
| 844 | + float pageW = PageSize.A4.getHeight(); // 841.89 pt |
| 845 | + float pageH = PageSize.A4.getWidth(); // 595.28 pt |
| 846 | + doc.setPageSize(new Rectangle(pageW, pageH)); |
| 847 | + doc.setMargins(40, 40, 40, 40); |
838 | 848 | doc.newPage(); |
| 849 | + // Verify the page size took effect; if the writer still reports portrait |
| 850 | + // dimensions, force one more page turn. |
| 851 | + if (writer.getPageSize().getHeight() >= writer.getPageSize().getWidth() |
| 852 | + && writer.getPageSize().getWidth() < pageW - 1f) { |
| 853 | + doc.newPage(); |
| 854 | + } |
| 855 | + |
| 856 | + // ── Constants ───────────────────────────────────────────────────────────── |
| 857 | + final int BADGE_COLS = 5; |
| 858 | + final float MARGIN = 40f; |
| 859 | + final float TITLE_H = 16f; // 11pt font + leading |
| 860 | + final float TITLE_GAP = 6f; // gap between title bottom and image top |
| 861 | + final float FOOTER_GAP = 8f; // gap between image bottom and topmost footer element |
839 | 862 |
|
840 | | - // Compact title — avoids the ~50pt overhead of the banner-style section |
841 | | - // header so the image fits on the same page without shrinking. |
| 863 | + List<String> nonNullFilters = |
| 864 | + (activeFilters != null) |
| 865 | + ? activeFilters.stream().filter(f -> f != null) |
| 866 | + .collect(java.util.stream.Collectors.toList()) |
| 867 | + : List.of(); |
| 868 | + boolean hasFilters = !nonNullFilters.isEmpty(); |
| 869 | + boolean hasNodeNote = nodeLimitNote != null && !nodeLimitNote.isBlank(); |
| 870 | + |
| 871 | + float contentW = pageW - MARGIN * 2; |
| 872 | + |
| 873 | + Font disclaimerFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(120, 120, 120)); |
| 874 | + Font noteFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(80, 80, 80)); |
| 875 | + |
| 876 | + // ── Build badge chips table so getTotalHeight() is exact ───────────────── |
| 877 | + // (disclaimer + note are built below as a single combined table) |
| 878 | + |
| 879 | + // Badge chips (optional) |
| 880 | + PdfPTable badges = null; |
| 881 | + float badgesH = 0f; |
| 882 | + if (hasFilters) { |
| 883 | + Font labelFont = new Font(Font.HELVETICA, 7f, Font.BOLD, new Color(30, 64, 175)); |
| 884 | + Font valueFont = new Font(Font.HELVETICA, 7f, Font.NORMAL, new Color(30, 41, 59)); |
| 885 | + Color chipBg = new Color(219, 234, 254); |
| 886 | + int cols = Math.min(nonNullFilters.size(), BADGE_COLS); |
| 887 | + badges = new PdfPTable(cols); |
| 888 | + badges.setTotalWidth(contentW); |
| 889 | + for (String filter : nonNullFilters) { |
| 890 | + int colon = filter.indexOf(':'); |
| 891 | + Phrase phrase = new Phrase(); |
| 892 | + if (colon > 0) { |
| 893 | + phrase.add(new Phrase(filter.substring(0, colon + 1) + " ", labelFont)); |
| 894 | + phrase.add(new Phrase(filter.substring(colon + 1).trim(), valueFont)); |
| 895 | + } else { |
| 896 | + phrase.add(new Phrase(filter, valueFont)); |
| 897 | + } |
| 898 | + PdfPCell chip = new PdfPCell(phrase); |
| 899 | + chip.setBackgroundColor(chipBg); |
| 900 | + chip.setPaddingTop(2); chip.setPaddingBottom(2); |
| 901 | + chip.setPaddingLeft(5); chip.setPaddingRight(5); |
| 902 | + chip.setBorderColor(new Color(147, 197, 253)); |
| 903 | + chip.setBorderWidth(0.5f); |
| 904 | + badges.addCell(chip); |
| 905 | + } |
| 906 | + int remainder = nonNullFilters.size() % cols; |
| 907 | + if (remainder != 0) { |
| 908 | + for (int p = remainder; p < cols; p++) { |
| 909 | + PdfPCell empty = new PdfPCell(new Phrase("")); |
| 910 | + empty.setBorder(Rectangle.NO_BORDER); |
| 911 | + badges.addCell(empty); |
| 912 | + } |
| 913 | + } |
| 914 | + badgesH = badges.getTotalHeight(); |
| 915 | + } |
| 916 | + |
| 917 | + // ── Merge disclaimer + note into one table for exact combined height ─────── |
| 918 | + // Building them separately risks coordinate drift; one table guarantees |
| 919 | + // the text block height is measured as a single unit. |
| 920 | + PdfPTable textFooter = new PdfPTable(1); |
| 921 | + textFooter.setTotalWidth(contentW); |
| 922 | + if (hasNodeNote) { |
| 923 | + PdfPCell noteCell2 = new PdfPCell(new Phrase(nodeLimitNote, noteFont)); |
| 924 | + noteCell2.setBorder(Rectangle.NO_BORDER); |
| 925 | + noteCell2.setHorizontalAlignment(Element.ALIGN_CENTER); |
| 926 | + noteCell2.setPaddingTop(0); noteCell2.setPaddingBottom(2); |
| 927 | + textFooter.addCell(noteCell2); |
| 928 | + } |
| 929 | + PdfPCell discCell2 = new PdfPCell(new Phrase( |
| 930 | + "Note: This diagram is automatically generated and may not render all connections accurately " |
| 931 | + + "for large or complex network captures. For a complete view, consider taking a manual " |
| 932 | + + "screenshot from the Network Diagram page.", |
| 933 | + disclaimerFont)); |
| 934 | + discCell2.setBorder(Rectangle.NO_BORDER); |
| 935 | + discCell2.setHorizontalAlignment(Element.ALIGN_CENTER); |
| 936 | + discCell2.setPaddingTop(0); discCell2.setPaddingBottom(0); |
| 937 | + textFooter.addCell(discCell2); |
| 938 | + float textFooterH = textFooter.getTotalHeight(); |
| 939 | + |
| 940 | + // ── Exact footer height ─────────────────────────────────────────────────── |
| 941 | + float footerH = FOOTER_GAP + textFooterH + badgesH; |
| 942 | + |
| 943 | + // ── Derive image slot ───────────────────────────────────────────────────── |
| 944 | + float titleTop = pageH - MARGIN; |
| 945 | + float imageTop = titleTop - TITLE_H - TITLE_GAP; |
| 946 | + float imageBot = MARGIN + footerH; |
| 947 | + float usableH = imageTop - imageBot; |
| 948 | + |
| 949 | + log.info("Topology diagram '{}': pageH={} footerH={} (textFooter={} badges={}) imageBot={} usableH={}", |
| 950 | + layoutName, pageH, footerH, textFooterH, badgesH, imageBot, usableH); |
| 951 | + |
| 952 | + // ── Draw title ──────────────────────────────────────────────────────────── |
| 953 | + PdfContentByte cb = writer.getDirectContent(); |
842 | 954 | Font titleFont = new Font(Font.HELVETICA, 11, Font.BOLD, C_HEADER_BG); |
843 | | - Paragraph title = new Paragraph(sectionNum + ". Network Topology — " + layoutName, titleFont); |
844 | | - title.setSpacingBefore(4); |
845 | | - title.setSpacingAfter(6); |
846 | | - doc.add(title); |
| 955 | + ColumnText ctTitle = new ColumnText(cb); |
| 956 | + ctTitle.setSimpleColumn(MARGIN, titleTop - TITLE_H, MARGIN + contentW, titleTop); |
| 957 | + ctTitle.setAlignment(Element.ALIGN_LEFT); |
| 958 | + ctTitle.addText(new Phrase(sectionNum + ". Network Topology \u2014 " + layoutName, titleFont)); |
| 959 | + ctTitle.go(); |
847 | 960 |
|
848 | 961 | if (base64Image == null || base64Image.isBlank()) { |
849 | | - doc.add(new Paragraph("Diagram image not provided.", cellFont())); |
| 962 | + ColumnText ctErr = new ColumnText(cb); |
| 963 | + ctErr.setSimpleColumn(MARGIN, imageBot, MARGIN + contentW, imageTop); |
| 964 | + ctErr.addText(new Phrase("Diagram image not provided.", cellFont())); |
| 965 | + ctErr.go(); |
| 966 | + doc.add(new Paragraph(" ")); |
850 | 967 | return; |
851 | 968 | } |
852 | 969 |
|
853 | | - // Usable area: landscape height minus top+bottom margins (100), title (21), |
854 | | - // image spacingBefore (8), and disclaimer with its spacing (40). |
855 | | - float usableW = landscape.getWidth() - 80f; |
856 | | - float usableH = landscape.getHeight() - 100f - 21f - 8f - 40f; |
857 | | - |
| 970 | + // ── Draw image ──────────────────────────────────────────────────────────── |
858 | 971 | byte[] imageBytes; |
859 | 972 | try { |
860 | 973 | String data = base64Image.contains(",") ? base64Image.split(",")[1] : base64Image; |
861 | 974 | imageBytes = Base64.getDecoder().decode(data); |
862 | 975 | } catch (IllegalArgumentException e) { |
863 | 976 | log.warn("Invalid base64 image data for layout: {}", layoutName); |
864 | | - doc.add(new Paragraph("Invalid diagram image data.", cellFont())); |
| 977 | + ColumnText ctErr = new ColumnText(cb); |
| 978 | + ctErr.setSimpleColumn(MARGIN, imageBot, MARGIN + contentW, imageTop); |
| 979 | + ctErr.addText(new Phrase("Invalid diagram image data.", cellFont())); |
| 980 | + ctErr.go(); |
| 981 | + doc.add(new Paragraph(" ")); |
865 | 982 | return; |
866 | 983 | } |
867 | 984 | Image img = Image.getInstance(imageBytes); |
868 | | - img.scaleToFit(usableW, usableH); |
869 | | - img.setAlignment(Image.ALIGN_CENTER); |
870 | | - img.setSpacingBefore(8); |
871 | | - doc.add(img); |
| 985 | + img.scaleToFit(contentW, usableH); |
| 986 | + float imgX = MARGIN + (contentW - img.getScaledWidth()) / 2f; |
| 987 | + float imgY = imageTop - img.getScaledHeight(); |
| 988 | + img.setAbsolutePosition(imgX, imgY); |
| 989 | + cb.addImage(img); |
| 990 | + |
| 991 | + // ── Draw footer bottom-up — two elements, exact heights, no estimates ───── |
| 992 | + float y = MARGIN; |
| 993 | + |
| 994 | + // 1. Badge chips (bottom-most) |
| 995 | + if (badges != null) { |
| 996 | + badges.writeSelectedRows(0, -1, MARGIN, y + badgesH, cb); |
| 997 | + y += badgesH; |
| 998 | + } |
872 | 999 |
|
873 | | - Font disclaimerFont = new Font(Font.HELVETICA, 7.5f, Font.ITALIC, new Color(120, 120, 120)); |
874 | | - Paragraph disclaimer = |
875 | | - new Paragraph( |
876 | | - "Note: This diagram is automatically generated and may not render all connections accurately for large or complex network captures. " |
877 | | - + "For a complete view, consider taking a manual screenshot from the Network Diagram page.", |
878 | | - disclaimerFont); |
879 | | - disclaimer.setAlignment(Element.ALIGN_CENTER); |
880 | | - disclaimer.setSpacingBefore(6); |
881 | | - doc.add(disclaimer); |
| 1000 | + // 2. Text block (node note + disclaimer) directly above badges |
| 1001 | + y += FOOTER_GAP; |
| 1002 | + textFooter.writeSelectedRows(0, -1, MARGIN, y + textFooterH, cb); |
| 1003 | + |
| 1004 | + // Advance iText's flow cursor so the next section opens a new page. |
| 1005 | + doc.add(new Paragraph(" ")); |
882 | 1006 | } |
883 | 1007 |
|
884 | 1008 | // ══════════════════════════════════════════════════════════════════════════ |
|
0 commit comments