diff --git a/backend/pom.xml b/backend/pom.xml index 1ec581bd9..b1d216b32 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -458,5 +458,10 @@ osx-aarch_64 test + + org.apache.poi + poi-ooxml + 5.4.1 + diff --git a/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceImpl.java b/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceImpl.java index 0372057ca..78b7883b7 100644 --- a/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceImpl.java +++ b/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceImpl.java @@ -18,6 +18,8 @@ public class AttachmentConversionServiceImpl implements AttachmentConversionServ private static final String CONTENT_TYPE_TIKA_OOXML = "application/x-tika-ooxml"; private static final String CONTENT_TYPE_DOC = "application/msword"; private static final String CONTENT_TYPE_TIKA_MSOFFICE = "application/x-tika-msoffice"; + private static final String CONTENT_TYPE_TIKA_XLSX = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private final AttachmentService attachmentService; @@ -117,6 +119,7 @@ private FileTypeConverter getConverter(String contentType) { } case CONTENT_TYPE_DOCX, CONTENT_TYPE_TIKA_OOXML -> new DocxConverter(); case CONTENT_TYPE_DOC, CONTENT_TYPE_TIKA_MSOFFICE -> new DocConverter(); + case CONTENT_TYPE_TIKA_XLSX -> new XlsxConverter(); default -> { throw new IllegalArgumentException("Unsupported content type"); } diff --git a/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/XlsxConverter.java b/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/XlsxConverter.java new file mode 100644 index 000000000..79bd684eb --- /dev/null +++ b/backend/src/main/java/eu/bbmri_eric/negotiator/attachment/XlsxConverter.java @@ -0,0 +1,171 @@ +package eu.bbmri_eric.negotiator.attachment; + +import eu.bbmri_eric.negotiator.common.exceptions.PdfGenerationException; +import java.awt.Color; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import lombok.extern.apachecommons.CommonsLog; +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.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; + +/** Converter for XLSX (Excel) files to PDF format. */ +@CommonsLog +class XlsxConverter implements FileTypeConverter { + /** Page margin in points. */ + private static final float MARGIN = 50; + + /** Font size for cell text. */ + private static final float FONT_SIZE = 10; + + /** Line leading (spacing between rows). */ + private static final float LEADING = 14; + + /** Padding inside cells. */ + private static final float CELL_PADDING = 5; + + /** Minimum width for columns. */ + private static final float MIN_COLUMN_WIDTH = 60; + + @Override + public byte[] convertToPdf(final byte[] xlsxBytes) throws IOException, PdfGenerationException { + if (xlsxBytes == null || xlsxBytes.length == 0) { + throw new IllegalArgumentException("Input XLSX bytes are null or empty"); + } + + log.debug("Converting XLSX to PDF, input size: " + xlsxBytes.length); + + try (ByteArrayInputStream xlsxInputStream = new ByteArrayInputStream(xlsxBytes); + Workbook workbook = new XSSFWorkbook(xlsxInputStream); + PDDocument document = new PDDocument(); + ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream()) { + + for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++) { + Sheet sheet = workbook.getSheetAt(sheetIndex); + if (sheet.getPhysicalNumberOfRows() > 0) { + convertSheetToPdf(document, sheet); + } + } + + if (document.getNumberOfPages() == 0) { + document.addPage(new PDPage(PDRectangle.A4)); + } + + document.save(pdfOutputStream); + byte[] result = pdfOutputStream.toByteArray(); + log.debug("Successfully converted XLSX to PDF, output size: " + result.length); + return result; + } catch (Exception e) { + log.error("Error converting XLSX to PDF: " + e.getMessage(), e); + throw new PdfGenerationException(); + } + } + + private void convertSheetToPdf(final PDDocument document, final Sheet sheet) throws IOException { + PDPage page = new PDPage(PDRectangle.A4); + document.addPage(page); + + try (PDPageContentStream contentStream = + new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true)) { + + float pageWidth = page.getMediaBox().getWidth() - (2 * MARGIN); + float yPosition = + drawSheetHeader(contentStream, sheet.getSheetName(), page.getMediaBox().getHeight()); + + int maxCols = calculateMaxColumns(sheet); + + float columnWidth = maxCols > 0 ? pageWidth / maxCols : MIN_COLUMN_WIDTH; + columnWidth = Math.max(columnWidth, MIN_COLUMN_WIDTH); + + for (Row row : sheet) { + if (yPosition < MARGIN + LEADING) { + break; + } + drawRow(contentStream, row, yPosition, columnWidth, maxCols); + yPosition -= LEADING; + } + } + } + + private float drawSheetHeader( + PDPageContentStream contentStream, String sheetName, float pageHeight) throws IOException { + float yPosition = pageHeight - MARGIN; + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), FONT_SIZE + 2); + contentStream.newLineAtOffset(MARGIN, yPosition); + contentStream.showText("Sheet: " + sheetName); + contentStream.endText(); + return yPosition - (LEADING * 2); + } + + private int calculateMaxColumns(Sheet sheet) { + int maxCols = 0; + for (Row row : sheet) { + if (row.getLastCellNum() > maxCols) { + maxCols = row.getLastCellNum(); + } + } + return maxCols; + } + + private void drawRow( + PDPageContentStream contentStream, Row row, float yPosition, float columnWidth, int maxCols) + throws IOException { + float xPosition = MARGIN; + for (int cellIndex = 0; cellIndex < maxCols; cellIndex++) { + Cell cell = row.getCell(cellIndex); + drawCell(contentStream, cell, xPosition, yPosition, columnWidth); + xPosition += columnWidth; + } + } + + private void drawCell(PDPageContentStream contentStream, Cell cell, float x, float y, float width) + throws IOException { + String cellValue = getCellValueAsString(cell); + + contentStream.setStrokingColor(Color.LIGHT_GRAY); + contentStream.addRect(x, y - LEADING, width, LEADING); + contentStream.stroke(); + + if (cellValue != null && !cellValue.isEmpty()) { + contentStream.beginText(); + contentStream.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE); + contentStream.newLineAtOffset(x + CELL_PADDING, y - FONT_SIZE); + String displayText = truncateText(cellValue, width - (2 * CELL_PADDING)); + contentStream.showText(displayText); + contentStream.endText(); + } + } + + private String getCellValueAsString(final Cell cell) { + if (cell == null) { + return ""; + } + + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue(); + case NUMERIC -> String.valueOf(cell.getNumericCellValue()); + case BOOLEAN -> String.valueOf(cell.getBooleanCellValue()); + case FORMULA -> cell.getCellFormula(); + case BLANK -> ""; + default -> ""; + }; + } + + private String truncateText(final String text, final float maxWidth) { + int maxChars = (int) (maxWidth / (FONT_SIZE * 0.6)); + if (text.length() > maxChars) { + return text.substring(0, Math.max(0, maxChars - 3)) + "..."; + } + return text; + } +} diff --git a/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceTest.java b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceTest.java index 694c88e00..9d1d91732 100644 --- a/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceTest.java +++ b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentConversionServiceTest.java @@ -7,7 +7,6 @@ import eu.bbmri_eric.negotiator.attachment.dto.AttachmentDTO; import java.io.IOException; -import java.io.InputStream; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -50,7 +49,7 @@ void testconvertAttachmentsToPDF_WithPdfAttachment_ReturnsOriginalBytes() { @Test void testconvertAttachmentsToPdf_WithDocxAttachment_ConvertsSuccessfully() throws IOException { String attachmentId = "docx-attachment-1"; - byte[] docxBytes = loadTestDocxFile(); + byte[] docxBytes = AttachmentTestHelper.loadTestDocxFile(); AttachmentDTO docxAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -71,7 +70,7 @@ void testconvertAttachmentsToPdf_WithDocxAttachment_ConvertsSuccessfully() throw @Test void testconvertAttachmentsToPdf_WithDocAttachment() throws IOException { String attachmentId = "doc-attachment-1"; - byte[] docBytes = loadTestDocFile(); + byte[] docBytes = AttachmentTestHelper.loadTestDocFile(); AttachmentDTO docAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -91,7 +90,7 @@ void testconvertAttachmentsToPdf_WithDocAttachment() throws IOException { @Test void testconvertAttachmentsToPdf_WithDocAttachment_SkipsInvalidDoc() throws IOException { String attachmentId = "doc-attachment-1"; - byte[] docBytes = loadTestDocFile(); + byte[] docBytes = AttachmentTestHelper.loadTestDocFile(); AttachmentDTO docAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -111,7 +110,7 @@ void testconvertAttachmentsToPdf_WithDocAttachment_SkipsInvalidDoc() throws IOEx @Test void testconvertAttachmentsToPdf_WithTikaDocxType_ConvertsSuccessfully() throws IOException { String attachmentId = "tika-docx-attachment-1"; - byte[] docxBytes = loadTestDocxFile(); + byte[] docxBytes = AttachmentTestHelper.loadTestDocxFile(); AttachmentDTO docxAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -132,7 +131,7 @@ void testconvertAttachmentsToPdf_WithTikaDocxType_ConvertsSuccessfully() throws @Test void testconvertAttachmentsToPdf_WithTikaDocType_SkipsInvalidDoc() throws IOException { String attachmentId = "tika-doc-attachment-1"; - byte[] docBytes = loadTestDocFile(); + byte[] docBytes = AttachmentTestHelper.loadTestDocFile(); AttachmentDTO docAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -152,7 +151,7 @@ void testconvertAttachmentsToPdf_WithTikaDocType_SkipsInvalidDoc() throws IOExce @Test void testconvertAttachmentsToPdf_WithTikaDocType() throws IOException { String attachmentId = "tika-doc-attachment-1"; - byte[] docBytes = loadTestDocFileValid(); + byte[] docBytes = AttachmentTestHelper.loadTestDocFileValid(); AttachmentDTO docAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -175,7 +174,7 @@ void testconvertAttachmentsToPDF_WithMultipleAttachments_ProcessesAll() throws I String docxId = "docx-1"; byte[] pdfBytes = "PDF content".getBytes(); - byte[] docxBytes = loadTestDocxFile(); + byte[] docxBytes = AttachmentTestHelper.loadTestDocxFile(); AttachmentDTO pdfAttachment = AttachmentDTO.builder() @@ -519,7 +518,7 @@ void testconvertAttachmentsToPDF_WithMixedValidAndInvalidAttachments_ProcessesVa String validDocxId = "valid-docx"; byte[] pdfBytes = "PDF content".getBytes(); - byte[] docxBytes = createMinimalDocxBytes(); + byte[] docxBytes = AttachmentTestHelper.createMinimalDocxBytes(); AttachmentDTO validPdfAttachment = AttachmentDTO.builder() @@ -571,7 +570,7 @@ void testconvertAttachmentsToPDF_WithMixedValidAndInvalidAttachments_ProcessesVa void testconvertAttachmentsToPdf_WithValidDocWithContent_ProcessesSuccessfully() throws IOException { String attachmentId = "valid-doc-with-content"; - byte[] docBytes = createValidDocBytes(); + byte[] docBytes = AttachmentTestHelper.createValidDocBytes(); AttachmentDTO docAttachment = AttachmentDTO.builder() .id(attachmentId) @@ -731,7 +730,7 @@ void testConvertDocxToPdf_WithInvalidDocxPackage_ThrowsException() { void testConvertDocToPdf_WithValidDocFile_ConvertsSuccessfully() { // Test successful DOC to PDF conversion with a more realistic DOC structure String attachmentId = "valid-doc-conversion"; - byte[] validDocBytes = createRealisticDocBytes(); + byte[] validDocBytes = AttachmentTestHelper.createRealisticDocBytes(); AttachmentDTO docAttachment = AttachmentDTO.builder() @@ -761,234 +760,4 @@ void testConvertDocToPdf_WithValidDocFile_ConvertsSuccessfully() { "Result should be a valid PDF or substantial content"); } } - - private byte[] loadTestDocxFile() throws IOException { - try (InputStream inputStream = getClass().getResourceAsStream("/test-documents/test.docx")) { - if (inputStream == null) { - return createMinimalDocxBytes(); - } - return inputStream.readAllBytes(); - } - } - - private byte[] loadTestDocFileValid() throws IOException { - try (InputStream inputStream = - getClass().getResourceAsStream("/test-documents/test-valid.doc")) { - if (inputStream == null) { - return createMinimalDocBytes(); - } - return inputStream.readAllBytes(); - } - } - - private byte[] loadTestDocFile() throws IOException { - try (InputStream inputStream = getClass().getResourceAsStream("/test-documents/test.doc")) { - if (inputStream == null) { - return createMinimalDocBytes(); - } - return inputStream.readAllBytes(); - } - } - - private byte[] createMinimalDocxBytes() { - String minimalDocx = - "PK\u0003\u0004\u0014\u0000\u0000\u0000\u0008\u0000\u0000\u0000\u0000\u0000" - + "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000" - + "\u0019\u0000\u0000\u0000[Content_Types].xmlPK\u0003\u0004\u0014\u0000" - + "\u0000\u0000\u0008\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000" - + "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u000b\u0000\u0000\u0000" - + "_rels/.relsPK\u0005\u0006\u0000\u0000\u0000\u0000\u0002\u0000\u0002\u0000" - + "^\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000"; - return minimalDocx.getBytes(); - } - - private byte[] createMinimalDocBytes() { - byte[] docHeader = { - (byte) 0xD0, - (byte) 0xCF, - (byte) 0x11, - (byte) 0xE0, - (byte) 0xA1, - (byte) 0xB1, - (byte) 0x1A, - (byte) 0xE1 - }; - byte[] docContent = new byte[512]; - System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); - return docContent; - } - - private byte[] createValidDocBytes() { - // Create a more complete DOC structure that might be parseable - byte[] docHeader = { - (byte) 0xD0, - (byte) 0xCF, - (byte) 0x11, - (byte) 0xE0, - (byte) 0xA1, - (byte) 0xB1, - (byte) 0x1A, - (byte) 0xE1, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x3E, - 0x00, - 0x03, - 0x00, - (byte) 0xFE, - (byte) 0xFF, - 0x09, - 0x00, - 0x06, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x10, - 0x00, - 0x00 - }; - byte[] docContent = new byte[2048]; - System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); - return docContent; - } - - private byte[] createValidEmptyDocBytes() { - // Create a DOC structure that will parse but have no paragraphs - byte[] docHeader = { - (byte) 0xD0, (byte) 0xCF, (byte) 0x11, (byte) 0xE0, - (byte) 0xA1, (byte) 0xB1, (byte) 0x1A, (byte) 0xE1 - }; - byte[] docContent = new byte[1024]; - System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); - return docContent; - } - - private byte[] createRealisticDocBytes() { - // Create a more realistic DOC file structure that has a better chance of being parsed - // This creates a minimal but more complete OLE2 compound document structure - byte[] docContent = new byte[4096]; // Larger size for more realistic structure - - // OLE2 header (first 512 bytes are the header sector) - byte[] oleHeader = { - // OLE signature - (byte) 0xD0, - (byte) 0xCF, - (byte) 0x11, - (byte) 0xE0, - (byte) 0xA1, - (byte) 0xB1, - (byte) 0x1A, - (byte) 0xE1, - // Minor version - 0x00, - 0x00, - // Major version - 0x3E, - 0x00, - // Byte order - (byte) 0xFE, - (byte) 0xFF, - // Sector size (512 bytes = 2^9) - 0x09, - 0x00, - // Mini sector size (64 bytes = 2^6) - 0x06, - 0x00, - // Reserved fields - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - 0x00, - // Number of directory sectors - 0x00, - 0x00, - 0x00, - 0x00, - // Number of FAT sectors - 0x01, - 0x00, - 0x00, - 0x00, - // Directory first sector - 0x01, - 0x00, - 0x00, - 0x00, - // Transaction signature - 0x00, - 0x00, - 0x00, - 0x00, - // Mini stream cutoff (4096 bytes) - 0x00, - 0x10, - 0x00, - 0x00, - // First mini FAT sector - (byte) 0xFF, - (byte) 0xFF, - (byte) 0xFF, - (byte) 0xFF, - // Number of mini FAT sectors - 0x00, - 0x00, - 0x00, - 0x00, - // First difat sector - (byte) 0xFF, - (byte) 0xFF, - (byte) 0xFF, - (byte) 0xFF - }; - - System.arraycopy(oleHeader, 0, docContent, 0, oleHeader.length); - - // Fill remaining header with appropriate values - // FAT array (starting at offset 76) - int fatOffset = 76; - // Sector 0 points to sector 1 (continuation) - docContent[fatOffset] = (byte) 0xFF; - docContent[fatOffset + 1] = (byte) 0xFF; - docContent[fatOffset + 2] = (byte) 0xFF; - docContent[fatOffset + 3] = (byte) 0xFE; // End of chain - - // Add some realistic Word document content in subsequent sectors - // This creates a minimal Word document structure - int contentOffset = 512; // Start of sector 1 - String wordContent = "Microsoft Word Document Content"; - byte[] contentBytes = wordContent.getBytes(); - System.arraycopy( - contentBytes, - 0, - docContent, - contentOffset, - Math.min(contentBytes.length, docContent.length - contentOffset)); - - return docContent; - } } diff --git a/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentTestHelper.java b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentTestHelper.java new file mode 100644 index 000000000..676a3e368 --- /dev/null +++ b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/AttachmentTestHelper.java @@ -0,0 +1,432 @@ +package eu.bbmri_eric.negotiator.attachment; + +import java.io.IOException; +import java.io.InputStream; +import java.util.function.Supplier; +import lombok.experimental.UtilityClass; + +@UtilityClass +public final class AttachmentTestHelper { + + private byte[] loadResourceOrDefault(String path, Supplier defaultSupplier) + throws IOException { + try (InputStream inputStream = AttachmentTestHelper.class.getResourceAsStream(path)) { + if (inputStream == null) { + return defaultSupplier.get(); + } + return inputStream.readAllBytes(); + } + } + + public byte[] loadTestDocxFile() throws IOException { + return loadResourceOrDefault( + "/test-documents/test.docx", AttachmentTestHelper::createMinimalDocxBytes); + } + + public byte[] loadTestDocFileValid() throws IOException { + return loadResourceOrDefault( + "/test-documents/test-valid.doc", AttachmentTestHelper::createMinimalDocBytes); + } + + public byte[] loadTestDocFile() throws IOException { + return loadResourceOrDefault( + "/test-documents/test.doc", AttachmentTestHelper::createMinimalDocBytes); + } + + public byte[] createMinimalDocxBytes() { + String minimalDocx = + "PK\u0003\u0004\u0014\u0000\u0000\u0000\u0008\u0000\u0000\u0000\u0000\u0000" + + "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000" + + "\u0019\u0000\u0000\u0000[Content_Types].xmlPK\u0003\u0004\u0014\u0000" + + "\u0000\u0000\u0008\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000" + + "\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u000b\u0000\u0000\u0000" + + "_rels/.relsPK\u0005\u0006\u0000\u0000\u0000\u0000\u0002\u0000\u0002\u0000" + + "^\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000"; + return minimalDocx.getBytes(); + } + + public byte[] createMinimalDocBytes() { + byte[] docHeader = { + (byte) 0xD0, + (byte) 0xCF, + (byte) 0x11, + (byte) 0xE0, + (byte) 0xA1, + (byte) 0xB1, + (byte) 0x1A, + (byte) 0xE1 + }; + byte[] docContent = new byte[512]; + System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); + return docContent; + } + + public byte[] createValidDocBytes() { + // Create a more complete DOC structure that might be parseable + byte[] docHeader = { + (byte) 0xD0, + (byte) 0xCF, + (byte) 0x11, + (byte) 0xE0, + (byte) 0xA1, + (byte) 0xB1, + (byte) 0x1A, + (byte) 0xE1, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x3E, + 0x00, + 0x03, + 0x00, + (byte) 0xFE, + (byte) 0xFF, + 0x09, + 0x00, + 0x06, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x10, + 0x00, + 0x00 + }; + byte[] docContent = new byte[2048]; + System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); + return docContent; + } + + public byte[] createValidEmptyDocBytes() { + // Create a DOC structure that will parse but have no paragraphs + byte[] docHeader = { + (byte) 0xD0, (byte) 0xCF, (byte) 0x11, (byte) 0xE0, + (byte) 0xA1, (byte) 0xB1, (byte) 0x1A, (byte) 0xE1 + }; + byte[] docContent = new byte[1024]; + System.arraycopy(docHeader, 0, docContent, 0, docHeader.length); + return docContent; + } + + public byte[] createRealisticDocBytes() { + // Create a more realistic DOC file structure that has a better chance of being parsed + // This creates a minimal but more complete OLE2 compound document structure + byte[] docContent = new byte[4096]; // Larger size for more realistic structure + + // OLE2 header (first 512 bytes are the header sector) + byte[] oleHeader = { + // OLE signature + (byte) 0xD0, + (byte) 0xCF, + (byte) 0x11, + (byte) 0xE0, + (byte) 0xA1, + (byte) 0xB1, + (byte) 0x1A, + (byte) 0xE1, + // Minor version + 0x00, + 0x00, + // Major version + 0x3E, + 0x00, + // Byte order + (byte) 0xFE, + (byte) 0xFF, + // Sector size (512 bytes = 2^9) + 0x09, + 0x00, + // Mini sector size (64 bytes = 2^6) + 0x06, + 0x00, + // Reserved fields + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + // Number of directory sectors + 0x00, + 0x00, + 0x00, + 0x00, + // Number of FAT sectors + 0x01, + 0x00, + 0x00, + 0x00, + // Directory first sector + 0x01, + 0x00, + 0x00, + 0x00, + // Transaction signature + 0x00, + 0x00, + 0x00, + 0x00, + // Mini stream cutoff (4096 bytes) + 0x00, + 0x10, + 0x00, + 0x00, + // First mini FAT sector + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF, + // Number of mini FAT sectors + 0x00, + 0x00, + 0x00, + 0x00, + // First difat sector + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF + }; + + System.arraycopy(oleHeader, 0, docContent, 0, oleHeader.length); + + // Fill remaining header with appropriate values + // FAT array (starting at offset 76) + int fatOffset = 76; + // Sector 0 points to sector 1 (continuation) + docContent[fatOffset] = (byte) 0xFF; + docContent[fatOffset + 1] = (byte) 0xFF; + docContent[fatOffset + 2] = (byte) 0xFF; + docContent[fatOffset + 3] = (byte) 0xFE; // End of chain + + // Add some realistic Word document content in subsequent sectors + // This creates a minimal Word document structure + int contentOffset = 512; // Start of sector 1 + String wordContent = "Microsoft Word Document Content"; + byte[] contentBytes = wordContent.getBytes(); + System.arraycopy( + contentBytes, + 0, + docContent, + contentOffset, + Math.min(contentBytes.length, docContent.length - contentOffset)); + + return docContent; + } + + public byte[] createValidXlsxBytes() throws IOException { + return loadResourceOrDefault( + "/test-documents/test.xlsx", + () -> { + try { + return createMinimalValidXlsxBytes(); + } catch (IOException e) { + throw new RuntimeException("Failed to generate fallback XLSX", e); + } + }); + } + + public byte[] createMinimalValidXlsxBytes() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("Sheet1"); + org.apache.poi.ss.usermodel.Row row = sheet.createRow(0); + org.apache.poi.ss.usermodel.Cell cell = row.createCell(0); + cell.setCellValue("Test"); + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createEmptyXlsxBytes() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + workbook.createSheet("EmptySheet"); + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithMultipleSheets() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet1 = workbook.createSheet("Sheet1"); + org.apache.poi.ss.usermodel.Row row1 = sheet1.createRow(0); + row1.createCell(0).setCellValue("Sheet 1 Data"); + row1.createCell(1).setCellValue("Column 2"); + + org.apache.poi.ss.usermodel.Sheet sheet2 = workbook.createSheet("Sheet2"); + org.apache.poi.ss.usermodel.Row row2 = sheet2.createRow(0); + row2.createCell(0).setCellValue("Sheet 2 Data"); + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithFormulas() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("Formulas"); + org.apache.poi.ss.usermodel.Row row1 = sheet.createRow(0); + row1.createCell(0).setCellValue(10); + row1.createCell(1).setCellValue(20); + + org.apache.poi.ss.usermodel.Row row2 = sheet.createRow(1); + + org.apache.poi.ss.usermodel.Cell formulaCell = row2.createCell(0); + formulaCell.setCellFormula("SUM(A1:B1)"); + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithVariousCellTypes() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("Types"); + org.apache.poi.ss.usermodel.Row row = sheet.createRow(0); + + row.createCell(0).setCellValue("Text"); + row.createCell(1).setCellValue(123.45); + row.createCell(2).setCellValue(true); + org.apache.poi.ss.usermodel.Cell formulaCell = row.createCell(3); + formulaCell.setCellFormula("B1*2"); + row.createCell(4).setBlank(); + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithLongText() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("LongText"); + org.apache.poi.ss.usermodel.Row row = sheet.createRow(0); + + String longText = + "This is a very long text that should be truncated when rendering to PDF because it" + + " exceeds the maximum width allowed for a cell in the PDF output format and we" + + " need to test the truncation logic"; + row.createCell(0).setCellValue(longText); + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithShortText() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("ShortText"); + org.apache.poi.ss.usermodel.Row row = sheet.createRow(0); + row.createCell(0).setCellValue("Short"); + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithManyRows() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("ManyRows"); + + for (int i = 0; i < 60; i++) { + org.apache.poi.ss.usermodel.Row row = sheet.createRow(i); + row.createCell(0).setCellValue("Row " + i); + row.createCell(1).setCellValue(i * 10); + } + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithEmptyCells() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("EmptyCells"); + org.apache.poi.ss.usermodel.Row row1 = sheet.createRow(0); + + row1.createCell(0).setCellValue("A1"); + row1.createCell(2).setCellValue("C1"); + + org.apache.poi.ss.usermodel.Row row2 = sheet.createRow(1); + + row2.createCell(1).setCellValue("B2"); + row2.createCell(3).setCellValue(""); + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithSingleColumn() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("SingleColumn"); + + for (int i = 0; i < 5; i++) { + org.apache.poi.ss.usermodel.Row row = sheet.createRow(i); + row.createCell(0).setCellValue("Value " + i); + } + + workbook.write(baos); + } + return baos.toByteArray(); + } + + public byte[] createXlsxWithBooleanAndBlankCells() throws IOException { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = + new org.apache.poi.xssf.usermodel.XSSFWorkbook()) { + org.apache.poi.ss.usermodel.Sheet sheet = workbook.createSheet("BooleanBlank"); + org.apache.poi.ss.usermodel.Row row1 = sheet.createRow(0); + + row1.createCell(0).setCellValue(true); + row1.createCell(1).setCellValue(false); + row1.createCell(2).setBlank(); + + org.apache.poi.ss.usermodel.Row row2 = sheet.createRow(1); + row2.createCell(0).setCellValue("After Boolean"); + row2.createCell(1).setBlank(); + row2.createCell(2).setCellValue(true); + + workbook.write(baos); + } + return baos.toByteArray(); + } +} diff --git a/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/XlsxConverterTest.java b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/XlsxConverterTest.java new file mode 100644 index 000000000..2d3dc6a31 --- /dev/null +++ b/backend/src/test/java/eu/bbmri_eric/negotiator/attachment/XlsxConverterTest.java @@ -0,0 +1,403 @@ +package eu.bbmri_eric.negotiator.attachment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import eu.bbmri_eric.negotiator.attachment.dto.AttachmentDTO; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class XlsxConverterTest { + + @Mock private AttachmentService attachmentService; + + private AttachmentConversionServiceImpl conversionService; + + @BeforeEach + void setUp() { + conversionService = new AttachmentConversionServiceImpl(attachmentService); + } + + @Test + void testConvertAttachmentsToPdf_WithXlsxAttachment_ConvertsSuccessfully() throws IOException { + String attachmentId = "xlsx-attachment-1"; + byte[] xlsxBytes = AttachmentTestHelper.createValidXlsxBytes(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + + byte[] pdfBytes = result.get(0); + String pdfHeader = new String(pdfBytes, 0, Math.min(4, pdfBytes.length)); + assertTrue(pdfHeader.startsWith("%PDF")); + } + + @Test + void testConvertAttachmentsToPdf_WithXlsxNullPayload_SkipsAttachment() { + String attachmentId = "xlsx-null-payload"; + AttachmentDTO attachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(null) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(attachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testConvertAttachmentsToPdf_WithXlsxEmptyPayload_SkipsAttachment() { + String attachmentId = "xlsx-empty-payload"; + AttachmentDTO attachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(new byte[0]) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(attachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testConvertAttachmentsToPdf_WithInvalidXlsxBytes_SkipsAttachment() { + String attachmentId = "invalid-xlsx-1"; + AttachmentDTO invalidXlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("invalid.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload("Invalid XLSX content".getBytes()) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(invalidXlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testConvertAttachmentsToPdf_WithCorruptedXlsxFile_SkipsAttachment() { + String attachmentId = "corrupted-xlsx-1"; + byte[] corruptedXlsxBytes = "This is not a valid XLSX file".getBytes(); + AttachmentDTO corruptedXlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("corrupted.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(corruptedXlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(corruptedXlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testConvertAttachmentsToPdf_WithMultipleXlsxSheets_ConvertsSuccessfully() + throws IOException { + String attachmentId = "multi-sheet-xlsx"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithMultipleSheets(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("multi-sheet.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + + byte[] pdfBytes = result.get(0); + String pdfHeader = new String(pdfBytes, 0, Math.min(4, pdfBytes.length)); + assertTrue(pdfHeader.startsWith("%PDF")); + } + + @Test + void testConvertAttachmentsToPdf_WithEmptyXlsxWorkbook_ConvertsSuccessfully() throws IOException { + String attachmentId = "empty-xlsx"; + byte[] xlsxBytes = AttachmentTestHelper.createEmptyXlsxBytes(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("empty.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testConvertAttachmentsToPdf_WithMixedDocxAndXlsx_ProcessesBoth() throws IOException { + String docxId = "docx-1"; + String xlsxId = "xlsx-1"; + + byte[] docxBytes = AttachmentTestHelper.loadTestDocxFile(); + byte[] xlsxBytes = AttachmentTestHelper.createValidXlsxBytes(); + + AttachmentDTO docxAttachment = + AttachmentDTO.builder() + .id(docxId) + .name("test.docx") + .contentType("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + .payload(docxBytes) + .build(); + + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(xlsxId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(docxId)).thenReturn(docxAttachment); + when(attachmentService.findById(xlsxId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(docxId, xlsxId)); + + assertEquals(2, result.size()); + assertTrue(result.get(0).length > 0); + assertTrue(result.get(1).length > 0); + } + + @Test + void testConvertAttachmentsToPdf_WithXlsxWithFormulas_ConvertsSuccessfully() throws IOException { + String attachmentId = "xlsx-with-formulas"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithFormulas(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("formulas.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithNullBytes_ThrowsIllegalArgumentException() { + String attachmentId = "xlsx-null-bytes"; + AttachmentDTO attachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(null) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(attachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testXlsxConverter_WithEmptyBytes_ThrowsIllegalArgumentException() { + String attachmentId = "xlsx-empty-bytes"; + AttachmentDTO attachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("test.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(new byte[0]) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(attachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(0, result.size()); + } + + @Test + void testXlsxConverter_WithVariousCellTypes_HandlesAllTypes() throws IOException { + String attachmentId = "xlsx-various-cell-types"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithVariousCellTypes(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("various-types.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + + byte[] pdfBytes = result.get(0); + String pdfHeader = new String(pdfBytes, 0, Math.min(4, pdfBytes.length)); + assertTrue(pdfHeader.startsWith("%PDF")); + } + + @Test + void testXlsxConverter_WithLongText_TruncatesCorrectly() throws IOException { + String attachmentId = "xlsx-long-text"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithLongText(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("long-text.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithShortText_DoesNotTruncate() throws IOException { + String attachmentId = "xlsx-short-text"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithShortText(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("short-text.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithManyRows_HandlesPageBreak() throws IOException { + String attachmentId = "xlsx-many-rows"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithManyRows(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("many-rows.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithEmptyCells_HandlesCorrectly() throws IOException { + String attachmentId = "xlsx-empty-cells"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithEmptyCells(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("empty-cells.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithSingleColumn_UsesMinWidth() throws IOException { + String attachmentId = "xlsx-single-column"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithSingleColumn(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("single-column.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } + + @Test + void testXlsxConverter_WithBooleanAndBlankCells_HandlesCorrectly() throws IOException { + String attachmentId = "xlsx-boolean-blank"; + byte[] xlsxBytes = AttachmentTestHelper.createXlsxWithBooleanAndBlankCells(); + AttachmentDTO xlsxAttachment = + AttachmentDTO.builder() + .id(attachmentId) + .name("boolean-blank.xlsx") + .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .payload(xlsxBytes) + .build(); + + when(attachmentService.findById(attachmentId)).thenReturn(xlsxAttachment); + + List result = conversionService.listToPdf(List.of(attachmentId)); + + assertEquals(1, result.size()); + assertTrue(result.get(0).length > 0); + } +}