diff --git a/TestFiles/ods/SPDXSpreadsheetExample-2.0.ods b/TestFiles/ods/SPDXSpreadsheetExample-2.0.ods new file mode 100644 index 0000000..91dd2ff Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-2.0.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.2.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.2.ods new file mode 100644 index 0000000..9b4d626 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.2.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3-leading-empty-row.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-leading-empty-row.ods new file mode 100644 index 0000000..ce0f0c9 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-leading-empty-row.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3-snippets-header-only.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-snippets-header-only.ods new file mode 100644 index 0000000..61b6570 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-snippets-header-only.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-many-columns.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-many-columns.ods new file mode 100644 index 0000000..a3a1d66 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-many-columns.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-repeated-5000.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-repeated-5000.ods new file mode 100644 index 0000000..603e4b1 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-repeated-5000.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-styled-blank.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-styled-blank.ods new file mode 100644 index 0000000..388e44a Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3-trailing-styled-blank.ods differ diff --git a/TestFiles/ods/SPDXSpreadsheetExample-v2.3.ods b/TestFiles/ods/SPDXSpreadsheetExample-v2.3.ods new file mode 100644 index 0000000..79f3cb3 Binary files /dev/null and b/TestFiles/ods/SPDXSpreadsheetExample-v2.3.ods differ diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java index 260c29d..e043675 100644 --- a/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java +++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java @@ -37,11 +37,12 @@ public OdsRow(OdsSheet sheet, int rowNum) { } /** - * Clears all cells in this row, setting their SODS range values to null. + * Clears value, formula, annotation and style of every cell in this row. */ - public void clear() { - for (OdsCell cell : cells.values()) { - cell.getSodsRange().setValue(null); + public synchronized void clear() { + com.github.miachm.sods.Sheet sodsSheet = sheet.getSodsSheet(); + if (rowNum < sodsSheet.getMaxRows() && sodsSheet.getMaxColumns() > 0) { + sodsSheet.getRange(rowNum, 0, 1, sodsSheet.getMaxColumns()).clear(); } cells.clear(); } @@ -95,40 +96,32 @@ public synchronized Cell getCell(int cellnum) { return null; } + // A cell exists if it has a value, formula, annotation or non-default style (see getCell). @Override public short getFirstCellNum() { - com.github.miachm.sods.Range dataRange = sheet.getSodsSheet().getDataRange(); - if (!cells.isEmpty()) { - int col = cells.firstKey(); - if (dataRange != null && rowNum >= dataRange.getRow() && rowNum <= dataRange.getLastRow()) { - col = Math.min(col, dataRange.getColumn()); + int maxCols = sheet.getSodsSheet().getMaxColumns(); + for (int col = 0; col < maxCols; col++) { + if (getCell(col) != null) { + return toShort(col); } - return col > Short.MAX_VALUE ? Short.MAX_VALUE : (short) col; - } - if (dataRange != null && rowNum >= dataRange.getRow() && rowNum <= dataRange.getLastRow()) { - int col = dataRange.getColumn(); - return col > Short.MAX_VALUE ? Short.MAX_VALUE : (short) col; } return -1; } @Override public short getLastCellNum() { - com.github.miachm.sods.Range dataRange = sheet.getSodsSheet().getDataRange(); - if (!cells.isEmpty()) { - int nextCol = cells.lastKey() + 1; - if (dataRange != null && rowNum >= dataRange.getRow() && rowNum <= dataRange.getLastRow()) { - nextCol = Math.max(nextCol, dataRange.getLastColumn() + 1); + for (int col = sheet.getSodsSheet().getMaxColumns() - 1; col >= 0; col--) { + if (getCell(col) != null) { + return toShort(col + 1); } - return nextCol > Short.MAX_VALUE ? Short.MAX_VALUE : (short) nextCol; - } - if (dataRange != null && rowNum >= dataRange.getRow() && rowNum <= dataRange.getLastRow()) { - int nextCol = dataRange.getLastColumn() + 1; - return nextCol > Short.MAX_VALUE ? Short.MAX_VALUE : (short) nextCol; } return -1; } + private static short toShort(int col) { + return col > Short.MAX_VALUE ? Short.MAX_VALUE : (short) col; + } + @Override public int getRowNum() { return rowNum; @@ -217,10 +210,10 @@ public Iterator iterator() { return cellIterator(); } @Override - public void removeCell(Cell cell) { + public synchronized void removeCell(Cell cell) { if (cell instanceof OdsCell) { int col = cell.getColumnIndex(); - ((OdsCell) cell).getSodsRange().setValue(null); + ((OdsCell) cell).getSodsRange().clear(); cells.remove(col); } } diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java index fbffe7f..ea44401 100644 --- a/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java +++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java @@ -46,6 +46,14 @@ public class OdsSheet implements Sheet { private final OdsWorkbook workbook; private final com.github.miachm.sods.Sheet sodsSheet; private final NavigableMap rows = new TreeMap<>(); + /** + * Lowest and highest SODS rows that may hold content, -1 if none. + * Set by the first scan, then only narrowed. + */ + private int firstContentRow = -1; + private int lastContentRow = -1; + private boolean contentScanned = false; + private static final int PROBE_COLUMNS = 8; private final List mergedRegions = new ArrayList<>(); @@ -75,10 +83,12 @@ public synchronized Row createRow(int rownum) { throw new IllegalArgumentException("Row number must be >= 0"); } int currentRows = sodsSheet.getMaxRows(); + OdsRow row = new OdsRow(this, rownum); if (rownum >= currentRows) { sodsSheet.appendRows(rownum - currentRows + 1); + } else { + resetRow(row); // replaces the existing row, as in POI } - OdsRow row = new OdsRow(this, rownum); rows.put(rownum, row); return row; } @@ -89,7 +99,10 @@ public synchronized Row getRow(int rownum) { if (row != null) { return row; } - if (rownum >= 0 && rownum < sodsSheet.getMaxRows()) { + // Null outside the content range, as in POI: SODS loads the empty rows LibreOffice + // writes around the data as real rows. + // Empty rows inside the range are returned; POI returns null for those not created. + if (rownum >= 0 && rownum < sodsSheet.getMaxRows() && inContentRange(rownum)) { OdsRow newRow = new OdsRow(this, rownum); rows.put(rownum, newRow); return newRow; @@ -98,64 +111,101 @@ public synchronized Row getRow(int rownum) { } @Override - public void removeRow(Row row) { + public synchronized void removeRow(Row row) { if (row instanceof OdsRow) { int rowNum = row.getRowNum(); - OdsRow odsRow = (OdsRow) row; - odsRow.clear(); + resetRow((OdsRow) row); rows.remove(rowNum); + // Narrow the cached range so getRow returns null for the removed edge row. + if (contentScanned) { + scanContentRows(); + } } } - private boolean hasData() { - if (!rows.isEmpty()) { - return true; + /** Leaves the row as POI has it after the row is dropped: no cells, default height, visible. */ + private void resetRow(OdsRow row) { + row.clear(); + int rowNum = row.getRowNum(); + if (rowNum < sodsSheet.getMaxRows()) { + sodsSheet.setRowHeight(rowNum, null); + sodsSheet.showRow(rowNum); } - com.github.miachm.sods.Range dataRange = sodsSheet.getDataRange(); - if (dataRange == null) { + } + + /** + * Finds the first and last rows with content. + * First scan covers the whole sheet; later scans narrow the cached range. + */ + private void scanContentRows() { + if (contentScanned && firstContentRow < 0) { + return; + } + int first = contentScanned ? firstContentRow : 0; + int last = contentScanned ? lastContentRow : sodsSheet.getMaxRows() - 1; + while (first <= last && !rowHasContent(first)) { + first++; + } + while (last >= first && !rowHasContent(last)) { + last--; + } + boolean hasContent = first <= last; + firstContentRow = hasContent ? first : -1; + lastContentRow = hasContent ? last : -1; + contentScanned = true; + } + + /** + * @param row SODS row index + * @return true if any cell in the row has a value, formula or annotation (style alone is not content) + */ + private boolean rowHasContent(int row) { + int maxCols = sodsSheet.getMaxColumns(); + // Probe leading cells first: content rows exit without a full-width read. + int probe = Math.min(maxCols, PROBE_COLUMNS); + for (int col = 0; col < probe; col++) { + com.github.miachm.sods.Range cell = sodsSheet.getRange(row, col); + if (cell.getValue() != null || cell.getFormula() != null || cell.getAnnotation() != null) { + return true; + } + } + if (maxCols <= probe) { return false; } - for (int r = dataRange.getRow(); r <= dataRange.getLastRow(); r++) { - for (int c = dataRange.getColumn(); c <= dataRange.getLastColumn(); c++) { - com.github.miachm.sods.Range range = sodsSheet.getRange(r, c); - if (range.getValue() != null || range.getFormula() != null || range.getAnnotation() != null) { - return true; - } + com.github.miachm.sods.Range rest = sodsSheet.getRange(row, probe, 1, maxCols - probe); + return anyNonNull(rest.getValues()[0]) || anyNonNull(rest.getFormulas()[0]) + || anyNonNull(rest.getAnnotations()[0]); + } + + private static boolean anyNonNull(Object[] cells) { + for (Object cell : cells) { + if (cell != null) { + return true; } } return false; } - @Override - public int getFirstRowNum() { - if (!hasData()) { - return -1; + private boolean inContentRange(int row) { + if (!contentScanned) { + scanContentRows(); } - com.github.miachm.sods.Range dataRange = sodsSheet.getDataRange(); - if (!rows.isEmpty()) { - int firstRow = rows.firstKey(); - if (dataRange != null) { - return Math.min(firstRow, dataRange.getRow()); - } - return firstRow; - } - return dataRange.getRow(); + return row >= firstContentRow && row <= lastContentRow; } @Override - public int getLastRowNum() { - if (!hasData()) { - return -1; - } - com.github.miachm.sods.Range dataRange = sodsSheet.getDataRange(); - if (!rows.isEmpty()) { - int lastRow = rows.lastKey(); - if (dataRange != null) { - return Math.max(lastRow, dataRange.getLastRow()); - } - return lastRow; + public synchronized int getFirstRowNum() { + scanContentRows(); + if (rows.isEmpty()) { + return firstContentRow; } - return dataRange.getLastRow(); + return firstContentRow < 0 ? rows.firstKey() : Math.min(rows.firstKey(), firstContentRow); + } + + @Override + public synchronized int getLastRowNum() { + scanContentRows(); + return rows.isEmpty() ? lastContentRow : Math.max(rows.lastKey(), lastContentRow); } @Override diff --git a/src/test/java/org/spdx/spreadsheetstore/OdsEmptyRowsTest.java b/src/test/java/org/spdx/spreadsheetstore/OdsEmptyRowsTest.java new file mode 100644 index 0000000..723ca35 --- /dev/null +++ b/src/test/java/org/spdx/spreadsheetstore/OdsEmptyRowsTest.java @@ -0,0 +1,177 @@ +/* + * SPDX-FileContributor: Arthit Suriyawongkul + * SPDX-FileCopyrightText: 2026 SPDX Contributors + * SPDX-FileType: SOURCE + * SPDX-License-Identifier: Apache-2.0 + */ +package org.spdx.spreadsheetstore; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +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.ss.usermodel.WorkbookFactory; +import org.junit.Test; + +import org.spdx.core.InvalidSPDXAnalysisException; +import org.spdx.library.ModelCopyManager; +import org.spdx.library.SpdxModelFactory; +import org.spdx.library.model.v2.SpdxConstantsCompatV2; +import org.spdx.library.model.v2.SpdxDocument; +import org.spdx.library.model.v2.SpdxElement; +import org.spdx.spreadsheetstore.ods.OdsWorkbook; +import org.spdx.storage.simple.InMemSpdxStore; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + + +/** + * ODS files from LibreOffice end every sheet with empty rows. + * Those rows must be ignored: content matches the XLSX example. + * Fixtures: TestFiles/ods. + */ +public class OdsEmptyRowsTest { + + private static final String TEST_FILES = "TestFiles"; + private static final String ODS_DIR = TEST_FILES + File.separator + "ods"; + private static final String V2_3 = "SPDXSpreadsheetExample-v2.3"; + private static final String[] EXAMPLES = new String[] {"SPDXSpreadsheetExample-2.0", "SPDXSpreadsheetExample-v2.2", V2_3}; + /** v2.3 variants with extra empty rows that must load the same as the XLSX */ + private static final String[] EMPTY_ROW_VARIANTS = new String[] { + V2_3 + "-trailing-repeated-5000", V2_3 + "-trailing-styled-blank", + V2_3 + "-trailing-many-columns", V2_3 + "-leading-empty-row"}; + + /** Element IDs and counts found in a spreadsheet */ + private static class Contents { + List files = new ArrayList<>(); + List snippets = new ArrayList<>(); + List packages = new ArrayList<>(); + int relationships = 0; + int annotations = 0; + } + + private Contents load(String path) throws InvalidSPDXAnalysisException, IOException { + SpreadsheetStore sst = new SpreadsheetStore(new InMemSpdxStore()); + try (FileInputStream stream = new FileInputStream(path)) { + sst.deSerialize(stream, false); + } + ModelCopyManager cm = new ModelCopyManager(); + String documentUri; + try (Stream docs = SpdxModelFactory.getSpdxObjects(sst, cm, + SpdxConstantsCompatV2.CLASS_SPDX_DOCUMENT, null, null)) { + List allDocs = docs.collect(Collectors.toList()); + assertEquals(1, allDocs.size()); + documentUri = ((SpdxDocument)allDocs.get(0)).getDocumentUri(); + } + Contents retval = new Contents(); + SpdxDocument doc = new SpdxDocument(sst, documentUri, cm, false); + retval.annotations = doc.getAnnotations().size(); + retval.relationships = doc.getRelationships().size(); + retval.files = ids(sst, cm, SpdxConstantsCompatV2.CLASS_SPDX_FILE, documentUri); + retval.snippets = ids(sst, cm, SpdxConstantsCompatV2.CLASS_SPDX_SNIPPET, documentUri); + retval.packages = ids(sst, cm, SpdxConstantsCompatV2.CLASS_SPDX_PACKAGE, documentUri); + return retval; + } + + private List ids(SpreadsheetStore sst, ModelCopyManager cm, String type, String documentUri) + throws InvalidSPDXAnalysisException { + List retval = new ArrayList<>(); + try (Stream elements = SpdxModelFactory.getSpdxObjects(sst, cm, type, documentUri, documentUri + "#")) { + elements.forEach(e -> retval.add(((SpdxElement)e).getId())); + } + Collections.sort(retval); + return retval; + } + + private void assertSameContents(Contents expected, Contents actual) { + assertEquals(expected.files, actual.files); + assertEquals(expected.snippets, actual.snippets); + assertEquals(expected.packages, actual.packages); + assertEquals(expected.relationships, actual.relationships); + assertEquals(expected.annotations, actual.annotations); + } + + private void assertNoAnonymousSnippets(Contents contents) { + for (String id : contents.snippets) { + assertFalse("Phantom snippet from an empty row: " + id, id.contains("__anon__")); + } + assertEquals(contents.snippets.size(), new TreeSet<>(contents.snippets).size()); + } + + @Test + public void libreOfficeConvertedOdsMatchesXlsx() throws InvalidSPDXAnalysisException, IOException { + for (String example : EXAMPLES) { + Contents expected = load(TEST_FILES + File.separator + example + ".xlsx"); + Contents ods = load(ODS_DIR + File.separator + example + ".ods"); + assertNoAnonymousSnippets(ods); + assertSameContents(expected, ods); + } + } + + @Test + public void emptyRowsAreIgnored() throws InvalidSPDXAnalysisException, IOException { + Contents expected = load(TEST_FILES + File.separator + V2_3 + ".xlsx"); + assertFalse(expected.snippets.isEmpty()); + for (String variant : EMPTY_ROW_VARIANTS) { + Contents ods = load(ODS_DIR + File.separator + variant + ".ods"); + assertNoAnonymousSnippets(ods); + assertSameContents(expected, ods); + } + } + + @Test + public void headerOnlySnippetSheetHasNoSnippets() throws InvalidSPDXAnalysisException, IOException { + Contents expected = load(TEST_FILES + File.separator + V2_3 + ".xlsx"); + Contents ods = load(ODS_DIR + File.separator + V2_3 + "-snippets-header-only.ods"); + assertTrue(ods.snippets.isEmpty()); + assertEquals(expected.files, ods.files); + assertEquals(expected.packages, ods.packages); + assertEquals(expected.annotations, ods.annotations); + } + + /** Row bounds of every sheet equal the XLSX's, shifted by the rows added before the data. */ + private void assertRowBounds(String odsPath, int rowOffset) throws IOException { + try (FileInputStream xlsxStream = new FileInputStream(TEST_FILES + File.separator + V2_3 + ".xlsx"); + FileInputStream odsStream = new FileInputStream(odsPath); + Workbook xlsx = WorkbookFactory.create(xlsxStream); + Workbook ods = new OdsWorkbook(odsStream)) { + assertEquals(xlsx.getNumberOfSheets(), ods.getNumberOfSheets()); + for (int i = 0; i < xlsx.getNumberOfSheets(); i++) { + Sheet expected = xlsx.getSheetAt(i); + Sheet actual = ods.getSheet(expected.getSheetName()); + String name = expected.getSheetName(); + assertEquals(name, expected.getFirstRowNum() + rowOffset, actual.getFirstRowNum()); + assertEquals(name, expected.getLastRowNum() + rowOffset, actual.getLastRowNum()); + if (rowOffset > 0) { + assertNull(name, actual.getRow(0)); + } + assertNull(name, actual.getRow(actual.getLastRowNum() + 1)); + Row header = actual.getRow(actual.getFirstRowNum()); + assertEquals(name, expected.getRow(expected.getFirstRowNum()).getFirstCellNum(), header.getFirstCellNum()); + assertEquals(name, expected.getRow(expected.getFirstRowNum()).getLastCellNum(), header.getLastCellNum()); + } + } + } + + @Test + public void trailingEmptyRowsAreNotRows() throws IOException { + assertRowBounds(ODS_DIR + File.separator + V2_3 + ".ods", 0); + } + + @Test + public void leadingEmptyRowIsNotARow() throws IOException { + assertRowBounds(ODS_DIR + File.separator + V2_3 + "-leading-empty-row.ods", 1); + } +} diff --git a/src/test/java/org/spdx/spreadsheetstore/ods/OdsWorkbookTest.java b/src/test/java/org/spdx/spreadsheetstore/ods/OdsWorkbookTest.java index d08337e..4f50e68 100644 --- a/src/test/java/org/spdx/spreadsheetstore/ods/OdsWorkbookTest.java +++ b/src/test/java/org/spdx/spreadsheetstore/ods/OdsWorkbookTest.java @@ -7,6 +7,10 @@ */ package org.spdx.spreadsheetstore.ods; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellType; import org.apache.poi.ss.usermodel.Row; @@ -17,6 +21,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; /** @@ -165,7 +170,7 @@ public void testCellComments() { assertEquals("This is a comment", cell.getCellComment().getString().getString()); cell.removeCellComment(); - org.junit.Assert.assertNull(cell.getCellComment()); + assertNull(cell.getCellComment()); } @Test @@ -204,7 +209,7 @@ public void testNullBorderHandling() { OdsWorkbook workbook = new OdsWorkbook(); OdsCellStyle style = (OdsCellStyle) workbook.createCellStyle(); style.setBorderBottom(null); - org.junit.Assert.assertNull(style.getBorderBottom()); + assertNull(style.getBorderBottom()); } @Test @@ -258,8 +263,8 @@ public void testInvalidNumericDateCellReturnsNullWithoutNpe() { Row row = sheet.createRow(0); Cell cell = row.createCell(0); cell.setCellValue(-5.0); - org.junit.Assert.assertNull(cell.getLocalDateTimeCellValue()); - org.junit.Assert.assertNull(cell.getDateCellValue()); + assertNull(cell.getLocalDateTimeCellValue()); + assertNull(cell.getDateCellValue()); } // Regression test: real POI throws IllegalStateException reading a date from a @@ -343,4 +348,151 @@ public void testFindFontNullName() { org.apache.poi.ss.usermodel.Font font = workbook.findFont(false, (short) 0, (short) 200, null, false, false, (short) 0, (byte) 0); // Should return without NPE } + + /** Empty rows after the last content row are not rows (as in POI). */ + @Test + public void testTrailingEmptyRowsAreNotRows() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Trailing"); + sheet.createRow(0).createCell(0).setCellValue("header"); + sheet.createRow(1).createCell(0).setCellValue("data"); + ((OdsSheet) sheet).getSodsSheet().appendRows(3); + + assertEquals(0, sheet.getFirstRowNum()); + assertEquals(1, sheet.getLastRowNum()); + assertNotNull(sheet.getRow(1)); + assertNull(sheet.getRow(2)); + assertNull(sheet.getRow(4)); + int rowCount = 0; + for (Row row : sheet) { + assertNotNull(row); + rowCount++; + } + assertEquals(2, rowCount); + } + + @Test + public void testStyledEmptyCellsDoNotCountAsContent() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Styled"); + sheet.createRow(0).createCell(0).setCellValue("header"); + com.github.miachm.sods.Sheet sodsSheet = ((OdsSheet) sheet).getSodsSheet(); + sodsSheet.appendRows(2); + com.github.miachm.sods.Style bold = new com.github.miachm.sods.Style(); + bold.setBold(true); + sodsSheet.getRange(1, 0, 2, 1).setStyle(bold); // styled blank cells after the data + assertTrue(sodsSheet.getRange(2, 0).getStyle().isBold()); + assertEquals(0, sheet.getLastRowNum()); + assertNull(sheet.getRow(1)); + assertNull(sheet.getRow(2)); + } + + @Test + public void testEmptyRowBetweenDataIsKept() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Interior"); + sheet.createRow(0).createCell(0).setCellValue("first"); + // Row 3 inside the appended block: SODS writes to the last row of a trailing block hit the whole block + ((OdsSheet) sheet).getSodsSheet().appendRows(4); + sheet.createRow(3).createCell(0).setCellValue("last"); + assertEquals(3, sheet.getLastRowNum()); + for (int i = 1; i <= 2; i++) { + assertNotNull("row " + i, sheet.getRow(i)); + assertNull("row " + i, sheet.getRow(i).getCell(0)); + } + assertNull(sheet.getRow(4)); + } + + @Test + public void testSheetWithOnlyEmptyRows() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("OnlyEmpty"); + ((OdsSheet) sheet).getSodsSheet().appendRows(5); + assertEquals(-1, sheet.getFirstRowNum()); + assertEquals(-1, sheet.getLastRowNum()); + assertNull(sheet.getRow(0)); + assertFalse(sheet.iterator().hasNext()); + } + + @Test + public void testCreateRowAfterTrailingEmptyRows() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Append"); + sheet.createRow(0).createCell(0).setCellValue("header"); + ((OdsSheet) sheet).getSodsSheet().appendRows(3); + assertNull(sheet.getRow(2)); + Row row = sheet.createRow(2); + row.createCell(0).setCellValue("new"); + assertNotNull(sheet.getRow(2)); + assertEquals(2, sheet.getLastRowNum()); + assertEquals("new", sheet.getRow(2).getCell(0).getStringCellValue()); + } + + @Test + public void testLeadingEmptyRowsAreNotRows() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Leading"); + for (int i = 0; i < 4; i++) { + sheet.createRow(i); + } + sheet.getRow(2).createCell(0).setCellValue("header"); + sheet.getRow(3).createCell(0).setCellValue("data"); + sheet.removeRow(sheet.getRow(0)); + sheet.removeRow(sheet.getRow(1)); + assertEquals(2, sheet.getFirstRowNum()); + assertEquals(3, sheet.getLastRowNum()); + assertNull(sheet.getRow(0)); + assertNull(sheet.getRow(1)); + sheet.removeRow(sheet.getRow(2)); + assertEquals(3, sheet.getFirstRowNum()); + } + + @Test + public void testRowBoundsAfterRemovingRows() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Removed"); + sheet.createRow(0).createCell(0).setCellValue("header"); + sheet.createRow(1).createCell(0).setCellValue("data"); + assertEquals(1, sheet.getLastRowNum()); + sheet.removeRow(sheet.getRow(1)); + assertEquals(0, sheet.getLastRowNum()); + sheet.removeRow(sheet.getRow(0)); + assertEquals(-1, sheet.getFirstRowNum()); + assertEquals(-1, sheet.getLastRowNum()); + } + + @Test + public void testRowsBetweenContentAndCreatedRowExist() { + OdsWorkbook workbook = new OdsWorkbook(); + Sheet sheet = workbook.createSheet("Gap"); + sheet.createRow(0).createCell(0).setCellValue("header"); + ((OdsSheet) sheet).getSodsSheet().appendRows(4); + sheet.createRow(4).createCell(0).setCellValue("data"); + assertEquals(4, sheet.getLastRowNum()); + for (int i = 0; i <= 4; i++) { + assertNotNull("row " + i, sheet.getRow(i)); + } + for (int i = 1; i <= 3; i++) { + assertNull("row " + i, sheet.getRow(i).getCell(0)); + } + } + + /** A removed row between data rows is still returned; it has default height and is visible. */ + @Test + public void testRemovedRowResetsHeightAndVisibility() throws IOException { + OdsWorkbook written = new OdsWorkbook(); + Sheet writtenSheet = written.createSheet("S"); + for (int i = 0; i < 4; i++) { + writtenSheet.createRow(i).createCell(0).setCellValue("row " + i); + } + writtenSheet.getRow(2).setZeroHeight(true); + writtenSheet.getRow(2).setHeightInPoints(40f); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + written.write(out); + Sheet sheet = new OdsWorkbook(new ByteArrayInputStream(out.toByteArray())).getSheet("S"); + sheet.removeRow(sheet.getRow(2)); + Row removed = sheet.getRow(2); + assertFalse(removed.getZeroHeight()); + assertEquals(15.0f, removed.getHeightInPoints(), 0.001f); + } } diff --git a/src/test/java/org/spdx/spreadsheetstore/ods/RowApiParityTest.java b/src/test/java/org/spdx/spreadsheetstore/ods/RowApiParityTest.java new file mode 100644 index 0000000..eb052f2 --- /dev/null +++ b/src/test/java/org/spdx/spreadsheetstore/ods/RowApiParityTest.java @@ -0,0 +1,217 @@ +/* + * SPDX-FileContributor: Arthit Suriyawongkul + * SPDX-FileCopyrightText: 2026 SPDX Contributors + * SPDX-FileType: SOURCE + * SPDX-License-Identifier: Apache-2.0 + */ +package org.spdx.spreadsheetstore.ods; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.function.Supplier; + +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +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.ss.usermodel.WorkbookFactory; +import org.junit.Test; +import org.spdx.spreadsheetstore.SpreadsheetTestUtils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +/** + * Row and cell bounds of ODS match XLS and XLSX for the same sequence of calls. + */ +public class RowApiParityTest { + + private static final int PROBE_ROWS = 7; + + private interface Layout { + void build(Workbook workbook, Sheet sheet); + } + + /** Bounds and existence of rows 0..PROBE_ROWS-1 and their cells */ + private static String snapshot(Sheet sheet) { + StringBuilder sb = new StringBuilder(); + sb.append("first=").append(sheet.getFirstRowNum()).append(" last=").append(sheet.getLastRowNum()); + for (int i = 0; i < PROBE_ROWS; i++) { + Row row = sheet.getRow(i); + sb.append(" | row").append(i).append('='); + if (row == null) { + sb.append("null"); + } else { + sb.append(row.getFirstCellNum()).append("..").append(row.getLastCellNum()); + } + } + return sb.toString(); + } + + /** Builds the layout on a new sheet in XLS, XLSX and ODS, and asserts all snapshots are equal. */ + private static void assertParity(Layout layout) { + String expected = null; + for (Supplier factory : SpreadsheetTestUtils.WORKBOOK_FACTORIES) { + try (Workbook workbook = factory.get()) { + Sheet sheet = workbook.createSheet("S"); + layout.build(workbook, sheet); + String actual = snapshot(sheet); + if (expected == null) { + expected = actual; + } + assertEquals(workbook.getClass().getSimpleName(), expected, actual); + } catch (java.io.IOException e) { + throw new AssertionError(e); + } + } + } + + /** Builds the layout, writes and reloads the workbook, applies the change, then asserts as {@link #assertParity}. */ + private static void assertParityAfterReload(Layout layout, Layout change) { + String expected = null; + for (Supplier factory : SpreadsheetTestUtils.WORKBOOK_FACTORIES) { + try (Workbook written = factory.get()) { + layout.build(written, written.createSheet("S")); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + written.write(out); + InputStream in = new ByteArrayInputStream(out.toByteArray()); + try (Workbook workbook = written instanceof OdsWorkbook ? new OdsWorkbook(in) : WorkbookFactory.create(in)) { + Sheet sheet = workbook.getSheet("S"); + change.build(workbook, sheet); + String actual = snapshot(sheet); + if (expected == null) { + expected = actual; + } + assertEquals(workbook.getClass().getSimpleName(), expected, actual); + } + } catch (java.io.IOException e) { + throw new AssertionError(e); + } + } + } + + private static void content(Sheet sheet, int row, int col) { + Row r = sheet.getRow(row) != null ? sheet.getRow(row) : sheet.createRow(row); + r.createCell(col).setCellValue(row + ":" + col); // Distinct values: SODS stores identical trailing rows as one repeated row, and writing to the last one changes all of them + } + + @Test + public void contiguousRows() { + assertParity((wb, sheet) -> { + for (int i = 0; i < 3; i++) { + content(sheet, i, 0); + } + }); + } + + @Test + public void emptySheet() { + assertParity((wb, sheet) -> { }); + } + + @Test + public void emptyRowsAfterDataRemoved() { + assertParity((wb, sheet) -> { + for (int i = 0; i < 5; i++) { + sheet.createRow(i); + } + content(sheet, 0, 0); + content(sheet, 1, 0); + for (int i = 2; i < 5; i++) { + sheet.removeRow(sheet.getRow(i)); + } + }); + } + + @Test + public void emptyRowsBeforeDataRemoved() { + assertParity((wb, sheet) -> { + for (int i = 0; i < 4; i++) { + sheet.createRow(i); + } + content(sheet, 2, 0); + content(sheet, 3, 0); + sheet.removeRow(sheet.getRow(0)); + sheet.removeRow(sheet.getRow(1)); + }); + } + + @Test + public void firstAndLastRowRemoved() { + assertParity((wb, sheet) -> { + for (int i = 0; i < 4; i++) { + content(sheet, i, 0); + } + sheet.removeRow(sheet.getRow(0)); + sheet.removeRow(sheet.getRow(3)); + }); + } + + @Test + public void allRowsRemoved() { + assertParity((wb, sheet) -> { + content(sheet, 0, 0); + content(sheet, 1, 0); + sheet.removeRow(sheet.getRow(1)); + sheet.removeRow(sheet.getRow(0)); + }); + } + + @Test + public void loadedLastRowRemoved() { + assertParityAfterReload( + (wb, sheet) -> { + for (int i = 0; i < 4; i++) { + content(sheet, i, 0); + } + }, + (wb, sheet) -> { + sheet.removeRow(sheet.getRow(3)); + sheet.getRow(3); // probe before bounds are read + }); + } + + @Test + public void loadedStyledCellRemoved() { + assertParityAfterReload( + (wb, sheet) -> { + content(sheet, 0, 0); + content(sheet, 0, 1); + CellStyle style = wb.createCellStyle(); + style.setAlignment(HorizontalAlignment.CENTER); + sheet.getRow(0).getCell(1).setCellStyle(style); + }, + (wb, sheet) -> { + Row row = sheet.getRow(0); + row.removeCell(row.getCell(1)); + }); + } + + @Test + public void loadedRowRecreated() { + assertParityAfterReload( + (wb, sheet) -> { + content(sheet, 0, 0); + content(sheet, 1, 0); + content(sheet, 1, 2); + sheet.getRow(1).setZeroHeight(true); + }, + (wb, sheet) -> assertFalse(sheet.createRow(1).getZeroHeight())); + } + + @Test + public void cellBounds() { + assertParity((wb, sheet) -> { + content(sheet, 0, 1); + content(sheet, 0, 2); + content(sheet, 1, 5); + sheet.createRow(2); + sheet.getRow(2).createCell(3); // blank cell + CellStyle style = wb.createCellStyle(); + style.setAlignment(HorizontalAlignment.CENTER); + sheet.createRow(3).createCell(4).setCellStyle(style); // styled blank cell + }); + } +}