From 579fabc6c10694c5585b9dd547ce113fabbe12ff Mon Sep 17 00:00:00 2001
From: Arthit Suriyawongkul
Date: Fri, 17 Jul 2026 12:04:00 +0100
Subject: [PATCH 1/2] Add OpenDocument Spreadsheet (ODS) support
Using SODS library https://github.com/miachm/SODS
Signed-off-by: Arthit Suriyawongkul
---
README.md | 3 +-
pom.xml | 5 +
.../spreadsheetstore/SpdxSpreadsheet.java | 36 +-
.../spreadsheetstore/SpreadsheetStore.java | 2 +-
.../spdx/spreadsheetstore/ods/OdsCell.java | 248 +++++++++++
.../spreadsheetstore/ods/OdsCellStyle.java | 354 +++++++++++++++
.../ods/OdsCreationHelper.java | 66 +++
.../spreadsheetstore/ods/OdsDataFormat.java | 44 ++
.../spdx/spreadsheetstore/ods/OdsFont.java | 143 ++++++
.../org/spdx/spreadsheetstore/ods/OdsRow.java | 175 ++++++++
.../spdx/spreadsheetstore/ods/OdsSheet.java | 409 ++++++++++++++++++
.../spreadsheetstore/ods/OdsWorkbook.java | 354 +++++++++++++++
.../spreadsheetstore/ods/package-info.java | 32 ++
.../SpreadsheetStoreTest.java | 99 +++++
14 files changed, 1967 insertions(+), 3 deletions(-)
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsCell.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsCreationHelper.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsDataFormat.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsFont.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/OdsWorkbook.java
create mode 100644 src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
diff --git a/README.md b/README.md
index e10ec6d..5eee350 100644
--- a/README.md
+++ b/README.md
@@ -17,8 +17,9 @@ Create an instance of a store which implements the [SPDX Java Library Storage In
Create an instance of `SpreadsheetStore(IModelStore baseStore, SpreadsheetFormatType spreadsheetFormat)` passing in the instance of a store created above along with the format. The format is one of the following:
+- `ODS` - OpenDocument Spreadsheet format
- `XLS` - Microsoft Excel 97 to 2003 Workbook format
-- `XLSX` - Microsoft Excel workbook format
+- `XLSX` - Microsoft Excel Workbook format
## Serializing and Deserializing
diff --git a/pom.xml b/pom.xml
index cb6c364..82af6e8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -161,6 +161,11 @@
poi-ooxml
5.4.1
+
+ com.github.miachm.sods
+ SODS
+ 1.10.1
+
diff --git a/src/main/java/org/spdx/spreadsheetstore/SpdxSpreadsheet.java b/src/main/java/org/spdx/spreadsheetstore/SpdxSpreadsheet.java
index a5436c2..685a73f 100644
--- a/src/main/java/org/spdx/spreadsheetstore/SpdxSpreadsheet.java
+++ b/src/main/java/org/spdx/spreadsheetstore/SpdxSpreadsheet.java
@@ -91,6 +91,33 @@ public class SpdxSpreadsheet {
private ModelCopyManager copyManager;
+ // Detects if the input stream contains an OpenDocument Spreadsheet file.
+ private static boolean isOdsStream(InputStream stream) {
+ try {
+ stream.mark(512);
+ byte[] header = new byte[256];
+ int bytesRead = 0;
+ while (bytesRead < header.length) {
+ int r = stream.read(header, bytesRead, header.length - bytesRead);
+ if (r < 0) {
+ break;
+ }
+ bytesRead += r;
+ }
+ stream.reset();
+ if (bytesRead < 4) {
+ return false;
+ }
+ if (header[0] != 0x50 || header[1] != 0x4B || header[2] != 0x03 || header[3] != 0x04) {
+ return false;
+ }
+ String s = new String(header, 0, bytesRead, java.nio.charset.StandardCharsets.US_ASCII);
+ return s.contains("application/vnd.oasis.opendocument.spreadsheet");
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
/**
* Open an existing SPDX spreadsheet from an input stream
* @param stream
@@ -103,8 +130,13 @@ public SpdxSpreadsheet(InputStream stream, IModelStore modelStore, ModelCopyMana
Objects.requireNonNull(copyManager, "Missing required model copy manager");
this.modelStore = modelStore;
this.copyManager = copyManager;
+ InputStream bis = stream.markSupported() ? stream : new java.io.BufferedInputStream(stream);
try {
- workbook = WorkbookFactory.create(stream);
+ if (isOdsStream(bis)) {
+ workbook = new org.spdx.spreadsheetstore.ods.OdsWorkbook(bis);
+ } else {
+ workbook = WorkbookFactory.create(bis);
+ }
} catch (EncryptedDocumentException e) {
logger.error("Unable to read encrypted SPDX Spreadsheet", e);
throw new SpreadsheetException("Unable to read encrypted SPDX Spreadsheet", e);
@@ -156,6 +188,8 @@ public SpdxSpreadsheet(IModelStore modelStore, ModelCopyManager copyManager, Str
workbook = new XSSFWorkbook();
} else if (SpreadsheetFormatType.XLS.equals(spreadsheetFormat)) {
workbook = new HSSFWorkbook();
+ } else if (SpreadsheetFormatType.ODS.equals(spreadsheetFormat)) {
+ workbook = new org.spdx.spreadsheetstore.ods.OdsWorkbook();
} else {
throw new SpreadsheetException("Unsupported spreadsheet format: "+spreadsheetFormat);
}
diff --git a/src/main/java/org/spdx/spreadsheetstore/SpreadsheetStore.java b/src/main/java/org/spdx/spreadsheetstore/SpreadsheetStore.java
index cbc8cf4..ceeafc5 100644
--- a/src/main/java/org/spdx/spreadsheetstore/SpreadsheetStore.java
+++ b/src/main/java/org/spdx/spreadsheetstore/SpreadsheetStore.java
@@ -81,7 +81,7 @@ public class SpreadsheetStore extends ExtendedSpdxStore implements ISerializable
/**
* Enum for the spreadsheet format type
*/
- public enum SpreadsheetFormatType {XLS, XLSX};
+ public enum SpreadsheetFormatType {ODS, XLS, XLSX};
private SpreadsheetFormatType spreadsheetFormat;
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsCell.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCell.java
new file mode 100644
index 0000000..dc67fc3
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCell.java
@@ -0,0 +1,248 @@
+/*
+ * 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.util.Date;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellAddress;
+import org.apache.poi.ss.util.CellRangeAddress;
+
+/**
+ * Adapter for Apache POI {@link Cell} over the SODS {@link com.github.miachm.sods.Range}.
+ * Provides mechanisms to read and write cell data, respecting SODS limitations.
+ */
+public class OdsCell implements Cell {
+ private final OdsRow row;
+ private final int columnIndex;
+ private final com.github.miachm.sods.Range range;
+ private CellStyle cellStyle;
+
+ public OdsCell(OdsRow row, int columnIndex, com.github.miachm.sods.Range range) {
+ this.row = row;
+ this.columnIndex = columnIndex;
+ this.range = range;
+ }
+
+ public com.github.miachm.sods.Range getSodsRange() {
+ return range;
+ }
+
+ @Override
+ public void setCellValue(String value) {
+ range.setValue(value);
+ }
+
+ @Override
+ public void setCellValue(double value) {
+ range.setValue(value);
+ }
+
+ @Override
+ public void setCellValue(Date value) {
+ if (value == null) {
+ range.setValue(null);
+ } else {
+ java.time.LocalDateTime ldt = java.time.LocalDateTime.ofInstant(value.toInstant(), java.time.ZoneId.systemDefault());
+ range.setValue(ldt);
+ }
+ }
+
+ @Override
+ public void setCellType(CellType cellType) {
+ }
+
+ @Override
+ public String getStringCellValue() {
+ Object val = range.getValue();
+ return val == null ? "" : val.toString();
+ }
+
+ @Override
+ public double getNumericCellValue() {
+ Object val = range.getValue();
+ if (val instanceof Number) {
+ return ((Number) val).doubleValue();
+ }
+ return 0.0;
+ }
+
+ @Override
+ public Date getDateCellValue() {
+ Object value = range.getValue();
+ if (value == null) return null;
+ if (value instanceof java.time.LocalDateTime) {
+ java.time.LocalDateTime ldt = (java.time.LocalDateTime) value;
+ return java.util.Date.from(ldt.atZone(java.time.ZoneId.systemDefault()).toInstant());
+ }
+ if (value instanceof java.time.LocalDate) {
+ java.time.LocalDate ld = (java.time.LocalDate) value;
+ return java.util.Date.from(ld.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant());
+ }
+ if (value instanceof java.util.Date) {
+ return (Date) value;
+ }
+ if (value instanceof Number) {
+ return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(((Number) value).doubleValue());
+ }
+ return null;
+ }
+
+ @Override
+ public CellType getCellType() {
+ Object val = range.getValue();
+ if (val == null) return CellType.BLANK;
+ if (val instanceof String) return CellType.STRING;
+ if (val instanceof Number) return CellType.NUMERIC;
+ if (val instanceof Boolean) return CellType.BOOLEAN;
+ if (val instanceof java.time.LocalDateTime || val instanceof java.time.LocalDate || val instanceof java.util.Date) {
+ return CellType.NUMERIC;
+ }
+ return CellType.STRING;
+ }
+
+ @Override
+ public void setCellStyle(CellStyle style) {
+ this.cellStyle = style;
+ if (style instanceof OdsCellStyle) {
+ range.setStyle(((OdsCellStyle) style).getSodsStyle());
+ }
+ }
+
+ @Override
+ public CellStyle getCellStyle() {
+ return cellStyle;
+ }
+
+ @Override
+ public int getColumnIndex() {
+ return columnIndex;
+ }
+
+ @Override
+ public int getRowIndex() {
+ return row.getRowNum();
+ }
+
+ @Override
+ public Sheet getSheet() {
+ return row.getSheet();
+ }
+
+ @Override
+ public Row getRow() {
+ return row;
+ }
+
+ @Override
+ public void setBlank() {
+ range.setValue(null);
+ }
+
+ @Override
+ public void setCellFormula(String formula) {
+ }
+
+ @Override
+ public String getCellFormula() {
+ return "";
+ }
+
+ @Override
+ public boolean getBooleanCellValue() {
+ Object val = range.getValue();
+ if (val instanceof Boolean) {
+ return (Boolean) val;
+ }
+ return false;
+ }
+
+ @Override
+ public byte getErrorCellValue() {
+ return 0;
+ }
+
+ @Override
+ public void setCellErrorValue(byte value) {}
+ @Override
+ public void setAsActiveCell() {}
+ @Override
+ public CellAddress getAddress() {
+ return new CellAddress(getRowIndex(), getColumnIndex());
+ }
+ @Override
+ public void setCellValue(java.util.Calendar value) {
+ if (value != null) {
+ setCellValue(value.getTime());
+ }
+ }
+ @Override
+ public void setCellValue(RichTextString value) {
+ if (value != null) {
+ setCellValue(value.getString());
+ }
+ }
+ @Override
+ public void setCellValue(boolean value) {
+ range.setValue(value);
+ }
+ @Override
+ public RichTextString getRichStringCellValue() {
+ return new org.apache.poi.xssf.usermodel.XSSFRichTextString(getStringCellValue());
+ }
+ @Override
+ public void removeCellComment() {}
+ @Override
+ public Comment getCellComment() { return null; }
+ @Override
+ public void setCellComment(Comment comment) {}
+ @Override
+ public Hyperlink getHyperlink() { return null; }
+ @Override
+ public void setHyperlink(Hyperlink hyperlink) {}
+ @Override
+ public CellRangeAddress getArrayFormulaRange() { return null; }
+ @Override
+ public boolean isPartOfArrayFormulaGroup() { return false; }
+
+ @Override
+ public void removeHyperlink() {}
+
+ @Override
+ public java.time.LocalDateTime getLocalDateTimeCellValue() {
+ Object value = range.getValue();
+ if (value instanceof java.time.LocalDateTime) {
+ return (java.time.LocalDateTime) value;
+ }
+ if (value instanceof java.time.LocalDate) {
+ return ((java.time.LocalDate) value).atStartOfDay();
+ }
+ if (value instanceof Date) {
+ return java.time.LocalDateTime.ofInstant(((Date) value).toInstant(), java.time.ZoneId.systemDefault());
+ }
+ if (value instanceof Number) {
+ Date date = org.apache.poi.ss.usermodel.DateUtil.getJavaDate(((Number) value).doubleValue());
+ return java.time.LocalDateTime.ofInstant(date.toInstant(), java.time.ZoneId.systemDefault());
+ }
+ return null;
+ }
+
+ @Override
+ public void setCellValue(java.time.LocalDateTime value) {
+ range.setValue(value);
+ }
+
+ @Override
+ public void setCellValue(java.time.LocalDate value) {
+ range.setValue(value);
+ }
+
+ @Override
+ public void removeFormula() {}
+
+ @Override
+ public CellType getCachedFormulaResultType() { return CellType.BLANK; }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
new file mode 100644
index 0000000..6009ce2
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
@@ -0,0 +1,354 @@
+/*
+ * SPDX-FileContributor: Arthit Suriyawongkul
+ * SPDX-FileCopyrightText: 2026 SPDX Contributors
+ * SPDX-FileType: SOURCE
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package org.spdx.spreadsheetstore.ods;
+
+import org.apache.poi.ss.usermodel.*;
+
+/**
+ * Adapter for Apache POI {@link CellStyle} over the SODS {@link com.github.miachm.sods.Style}.
+ * Manages borders, fonts, alignments, and backgrounds mapping for ODS cells.
+ */
+public class OdsCellStyle implements CellStyle {
+
+ private final com.github.miachm.sods.Style style = new com.github.miachm.sods.Style();
+ private boolean wrapText = false;
+ private int fontIndex = -1;
+ private short fillForegroundColor = 0;
+ private FillPatternType fillPattern = FillPatternType.NO_FILL;
+ private HorizontalAlignment horizontalAlignment = HorizontalAlignment.GENERAL;
+ private VerticalAlignment verticalAlignment = VerticalAlignment.BOTTOM;
+ private short dataFormat = 0;
+ private short index = 0;
+
+ private BorderStyle borderBottom = BorderStyle.NONE;
+ private BorderStyle borderLeft = BorderStyle.NONE;
+ private BorderStyle borderRight = BorderStyle.NONE;
+ private BorderStyle borderTop = BorderStyle.NONE;
+
+ private final OdsWorkbook workbook;
+
+ public OdsCellStyle(OdsWorkbook workbook) {
+ this.workbook = workbook;
+ }
+
+ public com.github.miachm.sods.Style getSodsStyle() {
+ return style;
+ }
+
+ @Override
+ public void setAlignment(HorizontalAlignment align) {
+ this.horizontalAlignment = align;
+ if (align == HorizontalAlignment.CENTER || align == HorizontalAlignment.CENTER_SELECTION) {
+ style.setTextAligment(com.github.miachm.sods.Style.TEXT_ALIGMENT.Center);
+ } else if (align == HorizontalAlignment.RIGHT) {
+ style.setTextAligment(com.github.miachm.sods.Style.TEXT_ALIGMENT.Right);
+ } else if (align == HorizontalAlignment.LEFT) {
+ style.setTextAligment(com.github.miachm.sods.Style.TEXT_ALIGMENT.Left);
+ }
+ }
+
+ @Override
+ public HorizontalAlignment getAlignment() {
+ return horizontalAlignment;
+ }
+
+ @Override
+ public void setVerticalAlignment(VerticalAlignment align) {
+ this.verticalAlignment = align;
+ if (align == VerticalAlignment.TOP) {
+ style.setVerticalTextAligment(com.github.miachm.sods.Style.VERTICAL_TEXT_ALIGMENT.Top);
+ } else if (align == VerticalAlignment.CENTER) {
+ style.setVerticalTextAligment(com.github.miachm.sods.Style.VERTICAL_TEXT_ALIGMENT.Middle);
+ } else if (align == VerticalAlignment.BOTTOM) {
+ style.setVerticalTextAligment(com.github.miachm.sods.Style.VERTICAL_TEXT_ALIGMENT.Bottom);
+ }
+ }
+
+ @Override
+ public VerticalAlignment getVerticalAlignment() {
+ return verticalAlignment;
+ }
+
+ @Override
+ public void setBorderBottom(BorderStyle border) {
+ this.borderBottom = border;
+ updateBorders();
+ }
+
+ @Override
+ public BorderStyle getBorderBottom() {
+ return borderBottom;
+ }
+
+ @Override
+ public void setBorderLeft(BorderStyle border) {
+ this.borderLeft = border;
+ updateBorders();
+ }
+
+ @Override
+ public BorderStyle getBorderLeft() {
+ return borderLeft;
+ }
+
+ @Override
+ public void setBorderRight(BorderStyle border) {
+ this.borderRight = border;
+ updateBorders();
+ }
+
+ @Override
+ public BorderStyle getBorderRight() {
+ return borderRight;
+ }
+
+ @Override
+ public void setBorderTop(BorderStyle border) {
+ this.borderTop = border;
+ updateBorders();
+ }
+
+ @Override
+ public BorderStyle getBorderTop() {
+ return borderTop;
+ }
+
+ private void updateBorders() {
+ com.github.miachm.sods.Borders borders = new com.github.miachm.sods.Borders();
+
+ boolean hasBottom = borderBottom != BorderStyle.NONE;
+ borders.setBorderBottom(hasBottom);
+ if (hasBottom) {
+ borders.setBorderBottomProperties(getBorderProperties(borderBottom));
+ }
+
+ boolean hasLeft = borderLeft != BorderStyle.NONE;
+ borders.setBorderLeft(hasLeft);
+ if (hasLeft) {
+ borders.setBorderLeftProperties(getBorderProperties(borderLeft));
+ }
+
+ boolean hasRight = borderRight != BorderStyle.NONE;
+ borders.setBorderRight(hasRight);
+ if (hasRight) {
+ borders.setBorderRightProperties(getBorderProperties(borderRight));
+ }
+
+ boolean hasTop = borderTop != BorderStyle.NONE;
+ borders.setBorderTop(hasTop);
+ if (hasTop) {
+ borders.setBorderTopProperties(getBorderProperties(borderTop));
+ }
+
+ style.setBorders(borders);
+ }
+
+ private String getBorderProperties(BorderStyle borderStyle) {
+ if (borderStyle == BorderStyle.NONE) {
+ return null;
+ }
+ switch (borderStyle) {
+ case THICK:
+ return "0.07cm solid #000000";
+ case MEDIUM:
+ return "0.05cm solid #000000";
+ case DOUBLE:
+ return "0.05cm double #000000";
+ case DASHED:
+ case MEDIUM_DASHED:
+ return "0.035cm dashed #000000";
+ case DOTTED:
+ return "0.035cm dotted #000000";
+ default:
+ return "0.035cm solid #000000";
+ }
+ }
+
+ @Override
+ public void setFillForegroundColor(short bg) {
+ this.fillForegroundColor = bg;
+ com.github.miachm.sods.Color color = getSodsColor(bg);
+ if (color != null) {
+ style.setBackgroundColor(color);
+ }
+ }
+
+ private com.github.miachm.sods.Color getSodsColor(short indexedColor) {
+ // Map IndexedColors to standard RGB
+ if (indexedColor == 42 || indexedColor == 57) { // LIGHT_GREEN
+ return new com.github.miachm.sods.Color(204, 255, 204);
+ }
+ if (indexedColor == 43 || indexedColor == 34) { // LIGHT_YELLOW
+ return new com.github.miachm.sods.Color(255, 255, 153);
+ }
+ if (indexedColor == 10) { // RED
+ return new com.github.miachm.sods.Color(255, 199, 206);
+ }
+ if (indexedColor == 22) { // GREY_25_PERCENT
+ return new com.github.miachm.sods.Color(224, 224, 224);
+ }
+ return null;
+ }
+
+ @Override
+ public short getFillForegroundColor() {
+ return fillForegroundColor;
+ }
+
+ @Override
+ public void setFillPattern(FillPatternType fp) {
+ this.fillPattern = fp;
+ }
+
+ @Override
+ public FillPatternType getFillPattern() {
+ return fillPattern;
+ }
+
+ @Override
+ public void setFont(Font font) {
+ if (font instanceof OdsFont) {
+ OdsFont odsFont = (OdsFont) font;
+ this.fontIndex = odsFont.getIndex();
+ style.setFontFamily(odsFont.getFontName());
+ style.setFontSize((int) odsFont.getFontHeightInPoints());
+ style.setBold(odsFont.getBold());
+ style.setItalic(odsFont.getItalic());
+ style.setUnderline(odsFont.getUnderline() != org.apache.poi.ss.usermodel.Font.U_NONE);
+ style.setLineThrough(odsFont.getStrikeout());
+ // Map font color if we want
+ short fontColorIdx = odsFont.getColor();
+ com.github.miachm.sods.Color fontColor = getSodsColor(fontColorIdx);
+ if (fontColor != null) {
+ style.setFontColor(fontColor);
+ }
+ }
+ }
+
+ /**
+ * Sets the data format for the cell.
+ *
+ * Due to limitations in the underlying SODS library, exact POI data format patterns
+ * are not fully supported. If the provided format index corresponds to a recognized
+ * Date format, it will be mapped to the ISO date style ("YYYY-MM-DD") to ensure
+ * dates are properly displayed. Other formats are currently stored but may not
+ * affect the final ODS cell rendering.
+ *
+ *
+ * @param fmt the data format index
+ */
+ @Override
+ public void setDataFormat(short fmt) {
+ this.dataFormat = fmt;
+ if (workbook != null) {
+ String formatStr = workbook.createDataFormat().getFormat(fmt);
+ if (DateUtil.isADateFormat(fmt, formatStr)) {
+ style.setDataStyle("YYYY-MM-DD");
+ }
+ }
+ }
+
+ @Override
+ public short getDataFormat() {
+ return dataFormat;
+ }
+
+ @Override
+ public void setWrapText(boolean wrapped) {
+ this.wrapText = wrapped;
+ style.setWrap(wrapped);
+ }
+
+ @Override
+ public boolean getWrapText() {
+ return wrapText;
+ }
+
+ @Override
+ public int getFontIndex() {
+ return fontIndex;
+ }
+
+ @Override
+ public int getFontIndexAsInt() {
+ return fontIndex;
+ }
+
+ @Override
+ public short getIndex() {
+ return index;
+ }
+
+ @Override
+ public void cloneStyleFrom(CellStyle source) {}
+ @Override
+ public void setBottomBorderColor(short color) {}
+ @Override
+ public short getBottomBorderColor() { return 0; }
+ @Override
+ public void setLeftBorderColor(short color) {}
+ @Override
+ public short getLeftBorderColor() { return 0; }
+ @Override
+ public void setRightBorderColor(short color) {}
+ @Override
+ public short getRightBorderColor() { return 0; }
+ @Override
+ public void setTopBorderColor(short color) {}
+ @Override
+ public short getTopBorderColor() { return 0; }
+ @Override
+ public void setFillBackgroundColor(short bg) {}
+ @Override
+ public short getFillBackgroundColor() { return 0; }
+ @Override
+ public void setHidden(boolean hidden) {}
+ @Override
+ public boolean getHidden() { return false; }
+ @Override
+ public void setLocked(boolean locked) {}
+ @Override
+ public boolean getLocked() { return false; }
+ @Override
+ public void setQuotePrefixed(boolean quotePrefix) {}
+ @Override
+ public boolean getQuotePrefixed() { return false; }
+ @Override
+ public void setIndention(short indent) {}
+ @Override
+ public short getIndention() { return 0; }
+ @Override
+ public void setRotation(short rotation) {}
+ @Override
+ public short getRotation() { return 0; }
+ @Override
+ public String getDataFormatString() { return ""; }
+ @Override
+ public void setShrinkToFit(boolean shrinkToFit) {}
+ @Override
+ public boolean getShrinkToFit() { return false; }
+
+ @Override
+ public void invalidateCachedProperties() {}
+
+ @Override
+ public java.util.EnumMap getFormatProperties() {
+ return new java.util.EnumMap<>(org.apache.poi.ss.usermodel.CellPropertyType.class);
+ }
+
+ @Override
+ public Color getFillForegroundColorColor() { return null; }
+
+ @Override
+ public Color getFillBackgroundColorColor() { return null; }
+
+ @Override
+ public void setFillForegroundColor(Color color) {}
+
+ @Override
+ public void setFillBackgroundColor(Color color) {}
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsCreationHelper.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCreationHelper.java
new file mode 100644
index 0000000..20bcd39
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCreationHelper.java
@@ -0,0 +1,66 @@
+/*
+ * SPDX-FileContributor: Arthit Suriyawongkul
+ * SPDX-FileCopyrightText: 2026 SPDX Contributors
+ * SPDX-FileType: SOURCE
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package org.spdx.spreadsheetstore.ods;
+
+import org.apache.poi.common.usermodel.HyperlinkType;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.AreaReference;
+import org.apache.poi.ss.util.CellReference;
+
+/**
+ * Adapter for Apache POI {@link CreationHelper} for ODS documents.
+ * Provides factories for formula evaluators, formats, and hyperlinks.
+ */
+public class OdsCreationHelper implements CreationHelper {
+
+ private final OdsWorkbook workbook;
+ private final OdsDataFormat dataFormat = new OdsDataFormat();
+
+ public OdsCreationHelper(OdsWorkbook workbook) {
+ this.workbook = workbook;
+ }
+
+ @Override
+ public FormulaEvaluator createFormulaEvaluator() {
+ return null;
+ }
+
+ @Override
+ public OdsDataFormat createDataFormat() {
+ return dataFormat;
+ }
+
+ @Override
+ public Hyperlink createHyperlink(HyperlinkType type) {
+ return null;
+ }
+
+ @Override
+ public ExtendedColor createExtendedColor() {
+ return null;
+ }
+
+ @Override
+ public ClientAnchor createClientAnchor() {
+ return null;
+ }
+
+ @Override
+ public AreaReference createAreaReference(String reference) {
+ return new AreaReference(reference, workbook.getSpreadsheetVersion());
+ }
+
+ @Override
+ public AreaReference createAreaReference(CellReference topLeft, CellReference bottomRight) {
+ return new AreaReference(topLeft, bottomRight, workbook.getSpreadsheetVersion());
+ }
+
+ @Override
+ public RichTextString createRichTextString(String text) {
+ return new org.apache.poi.xssf.usermodel.XSSFRichTextString(text);
+ }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsDataFormat.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsDataFormat.java
new file mode 100644
index 0000000..d902cd3
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsDataFormat.java
@@ -0,0 +1,44 @@
+/*
+ * SPDX-FileContributor: Arthit Suriyawongkul
+ * SPDX-FileCopyrightText: 2026 SPDX Contributors
+ * SPDX-FileType: SOURCE
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package org.spdx.spreadsheetstore.ods;
+
+import org.apache.poi.ss.usermodel.DataFormat;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Adapter for Apache POI {@link DataFormat} for ODS documents.
+ *
+ * Note: The SODS library has limited support for custom data formats.
+ * It natively only supports plain text ("@") and an ISO date format
+ * ("YYYY-MM-DD"). As a result, exact POI format patterns are not matched in
+ * the ODS output. Any date formats will map to "YYYY-MM-DD" internally,
+ * while other formats remain unstyled.
+ *
+ */
+public class OdsDataFormat implements DataFormat {
+
+ private final List formats = new ArrayList<>();
+
+ @Override
+ public short getFormat(String format) {
+ int index = formats.indexOf(format);
+ if (index == -1) {
+ index = formats.size();
+ formats.add(format);
+ }
+ return (short) index;
+ }
+
+ @Override
+ public String getFormat(short index) {
+ if (index >= 0 && index < formats.size()) {
+ return formats.get(index);
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsFont.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsFont.java
new file mode 100644
index 0000000..f55e24f
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsFont.java
@@ -0,0 +1,143 @@
+/*
+ * SPDX-FileContributor: Arthit Suriyawongkul
+ * SPDX-FileCopyrightText: 2026 SPDX Contributors
+ * SPDX-FileType: SOURCE
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package org.spdx.spreadsheetstore.ods;
+
+import org.apache.poi.ss.usermodel.Font;
+
+/**
+ * Adapter for Apache POI {@link Font} over SODS styles.
+ * Holds font configurations (size, bold, italic, underline, strikeout) for an ODS cell.
+ */
+public class OdsFont implements Font {
+
+ private final short index;
+ private String name = "Arial";
+ private short height = 200;
+ private boolean bold = false;
+ private short color = 0;
+ private boolean italic = false;
+ private boolean strikeout = false;
+ private short typeOffset = 0;
+ private byte underline = 0;
+
+ public OdsFont(short index) {
+ this.index = index;
+ }
+
+ @Override
+ public void setFontName(String name) {
+ this.name = name;
+ }
+
+ @Override
+ public String getFontName() {
+ return this.name;
+ }
+
+ @Override
+ public void setFontHeight(short height) {
+ this.height = height;
+ }
+
+ @Override
+ public void setFontHeightInPoints(short height) {
+ this.height = (short) (height * 20);
+ }
+
+ @Override
+ public short getFontHeight() {
+ return this.height;
+ }
+
+ @Override
+ public short getFontHeightInPoints() {
+ return (short) (this.height / 20);
+ }
+
+ @Override
+ public void setItalic(boolean italic) {
+ this.italic = italic;
+ }
+
+ @Override
+ public boolean getItalic() {
+ return this.italic;
+ }
+
+ @Override
+ public void setStrikeout(boolean strikeout) {
+ this.strikeout = strikeout;
+ }
+
+ @Override
+ public boolean getStrikeout() {
+ return this.strikeout;
+ }
+
+ @Override
+ public void setColor(short color) {
+ this.color = color;
+ }
+
+ @Override
+ public short getColor() {
+ return this.color;
+ }
+
+ @Override
+ public void setTypeOffset(short offset) {
+ this.typeOffset = offset;
+ }
+
+ @Override
+ public short getTypeOffset() {
+ return this.typeOffset;
+ }
+
+ @Override
+ public void setUnderline(byte underline) {
+ this.underline = underline;
+ }
+
+ @Override
+ public byte getUnderline() {
+ return this.underline;
+ }
+
+ @Override
+ public int getCharSet() {
+ return 0;
+ }
+
+ @Override
+ public void setCharSet(byte charset) {
+ }
+
+ @Override
+ public void setCharSet(int charset) {
+ }
+
+ @Override
+ public void setBold(boolean bold) {
+ this.bold = bold;
+ }
+
+ @Override
+ public boolean getBold() {
+ return this.bold;
+ }
+
+ @Override
+ public int getIndex() {
+ return this.index;
+ }
+
+ @Override
+ public int getIndexAsInt() {
+ return this.index;
+ }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java
new file mode 100644
index 0000000..3a0bcbb
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsRow.java
@@ -0,0 +1,175 @@
+/*
+ * 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.util.*;
+import org.apache.poi.ss.usermodel.*;
+
+/**
+ * Adapter for Apache POI {@link Row} managing a specific row in a SODS {@link com.github.miachm.sods.Sheet}.
+ */
+public class OdsRow implements Row {
+ private final OdsSheet sheet;
+ private final int rowNum;
+ private final Map cells = new TreeMap<>();
+
+ public OdsRow(OdsSheet sheet, int rowNum) {
+ this.sheet = sheet;
+ this.rowNum = rowNum;
+ }
+
+ public void clear() {
+ for (OdsCell cell : cells.values()) {
+ cell.getSodsRange().setValue(null);
+ }
+ cells.clear();
+ }
+
+ @Override
+ public Cell createCell(int column) {
+ return createCell(column, CellType.BLANK);
+ }
+
+ @Override
+ public Cell createCell(int column, CellType type) {
+ com.github.miachm.sods.Sheet sodsSheet = sheet.getSodsSheet();
+ int currentCols = sodsSheet.getMaxColumns();
+ if (column >= currentCols) {
+ for (int i = currentCols; i <= column; i++) {
+ sodsSheet.appendColumn();
+ }
+ }
+ com.github.miachm.sods.Range range = sodsSheet.getRange(rowNum, column);
+ OdsCell cell = new OdsCell(this, column, range);
+ cells.put(column, cell);
+ return cell;
+ }
+
+ @Override
+ public Cell getCell(int cellnum) {
+ // If it exists in cells map, return it
+ if (cells.containsKey(cellnum)) {
+ return cells.get(cellnum);
+ }
+ // If it exists in the underlying SODS sheet but not yet wrapped, wrap and return
+ com.github.miachm.sods.Sheet sodsSheet = sheet.getSodsSheet();
+ if (cellnum >= 0 && cellnum < sodsSheet.getMaxColumns()) {
+ com.github.miachm.sods.Range range = sodsSheet.getRange(rowNum, cellnum);
+ Object val = range.getValue();
+ if (val != null) {
+ if (val instanceof String && ((String) val).trim().isEmpty()) {
+ return null;
+ }
+ OdsCell cell = new OdsCell(this, cellnum, range);
+ cells.put(cellnum, cell);
+ return cell;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public short getFirstCellNum() {
+ com.github.miachm.sods.Sheet sodsSheet = sheet.getSodsSheet();
+ int maxCols = sodsSheet.getMaxColumns();
+ for (int col = 0; col < maxCols; col++) {
+ if (cells.containsKey(col)) {
+ return (short) col;
+ }
+ Object val = sodsSheet.getRange(rowNum, col).getValue();
+ if (val != null) {
+ return (short) col;
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public short getLastCellNum() {
+ com.github.miachm.sods.Sheet sodsSheet = sheet.getSodsSheet();
+ int maxCols = sodsSheet.getMaxColumns();
+ for (int col = maxCols - 1; col >= 0; col--) {
+ if (cells.containsKey(col)) {
+ return (short) (col + 1);
+ }
+ Object val = sodsSheet.getRange(rowNum, col).getValue();
+ if (val != null) {
+ return (short) (col + 1);
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public int getRowNum() {
+ return rowNum;
+ }
+
+ @Override
+ public void setHeight(short height) {}
+ @Override
+ public void setHeightInPoints(float height) {}
+ @Override
+ public short getHeight() { return 255; }
+ @Override
+ public float getHeightInPoints() { return 15.0f; }
+ @Override
+ public Sheet getSheet() {
+ return sheet;
+ }
+ @Override
+ public Iterator cellIterator() {
+ List list = new ArrayList<>(cells.values());
+ return list.iterator();
+ }
+ @Override
+ public Iterator iterator() {
+ return cellIterator();
+ }
+ @Override
+ public void removeCell(Cell cell) {
+ if (cell instanceof OdsCell) {
+ int col = cell.getColumnIndex();
+ ((OdsCell) cell).getSodsRange().setValue(null);
+ cells.remove(col);
+ }
+ }
+ @Override
+ public void setRowNum(int rowNum) {}
+ @Override
+ public int getPhysicalNumberOfCells() { return cells.size(); }
+ @Override
+ public boolean isFormatted() { return false; }
+ @Override
+ public CellStyle getRowStyle() { return null; }
+ @Override
+ public void setRowStyle(CellStyle style) {}
+
+ @Override
+ public void shiftCellsLeft(int firstShiftColumnIndex, int lastShiftColumnIndex, int step) {}
+
+ @Override
+ public void shiftCellsRight(int firstShiftColumnIndex, int lastShiftColumnIndex, int step) {}
+
+ @Override
+ public int getOutlineLevel() { return 0; }
+
+ @Override
+ public boolean getZeroHeight() { return false; }
+
+ @Override
+ public void setZeroHeight(boolean zHeight) {}
+
+ @Override
+ public Cell getCell(int cellnum, MissingCellPolicy policy) {
+ Cell cell = getCell(cellnum);
+ if (cell == null && policy == MissingCellPolicy.CREATE_NULL_AS_BLANK) {
+ return createCell(cellnum);
+ }
+ return cell;
+ }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java
new file mode 100644
index 0000000..49e4f12
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsSheet.java
@@ -0,0 +1,409 @@
+/*
+ * 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.util.*;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellAddress;
+import org.apache.poi.ss.util.CellRangeAddress;
+
+/**
+ * Adapter for Apache POI {@link Sheet} over a SODS {@link com.github.miachm.sods.Sheet}.
+ * Manages rows, columns, and sheet-level properties for an ODS document.
+ */
+public class OdsSheet implements Sheet {
+
+ private final OdsWorkbook workbook;
+ private final com.github.miachm.sods.Sheet sodsSheet;
+ private final NavigableMap rows = new TreeMap<>();
+
+ public OdsSheet(OdsWorkbook workbook, com.github.miachm.sods.Sheet sodsSheet) {
+ this.workbook = workbook;
+ this.sodsSheet = sodsSheet;
+ for (int r = 0; r < sodsSheet.getMaxRows(); r++) {
+ rows.put(r, new OdsRow(this, r));
+ }
+ }
+
+ public com.github.miachm.sods.Sheet getSodsSheet() {
+ return this.sodsSheet;
+ }
+
+ @Override
+ public Row createRow(int rownum) {
+ int currentRows = sodsSheet.getMaxRows();
+ if (rownum >= currentRows) {
+ for (int i = currentRows; i <= rownum; i++) {
+ sodsSheet.appendRow();
+ }
+ }
+ OdsRow row = new OdsRow(this, rownum);
+ rows.put(rownum, row);
+ return row;
+ }
+
+ @Override
+ public Row getRow(int rownum) {
+ return rows.get(rownum);
+ }
+
+ @Override
+ public void removeRow(Row row) {
+ if (row instanceof OdsRow) {
+ int rowNum = row.getRowNum();
+ OdsRow odsRow = (OdsRow) row;
+ odsRow.clear();
+ rows.remove(rowNum);
+ }
+ }
+
+ @Override
+ public int getFirstRowNum() {
+ return rows.isEmpty() ? 0 : rows.keySet().iterator().next();
+ }
+
+ @Override
+ public int getLastRowNum() {
+ return rows.isEmpty() ? 0 : rows.lastKey();
+ }
+
+ @Override
+ public void setColumnWidth(int columnIndex, int width) {
+ }
+
+ @Override
+ public int getColumnWidth(int columnIndex) {
+ return 2048;
+ }
+
+ @Override
+ public void autoSizeColumn(int columnIndex) {
+ }
+
+ @Override
+ public Workbook getWorkbook() {
+ return workbook;
+ }
+
+ @Override
+ public String getSheetName() {
+ return sodsSheet.getName();
+ }
+
+ @Override
+ public double getMargin(PageMargin margin) { return 0; }
+ @Override
+ public void setMargin(PageMargin margin, double size) {}
+ @Override
+ public boolean isPrintGridlines() { return false; }
+ @Override
+ public void setPrintGridlines(boolean show) {}
+ @Override
+ public boolean isPrintRowAndColumnHeadings() { return false; }
+ @Override
+ public void setPrintRowAndColumnHeadings(boolean show) {}
+ @Override
+ public void setActiveCell(CellAddress address) {}
+ @Override
+ public void removeMergedRegion(int index) {}
+ @Override
+ public void removeMergedRegions(Collection indices) {}
+ @Override
+ public int getNumMergedRegions() { return 0; }
+ @Override
+ public CellRangeAddress getMergedRegion(int index) { return null; }
+ @Override
+ public List getMergedRegions() { return new ArrayList<>(); }
+ @Override
+ public Iterator rowIterator() {
+ List list = new ArrayList<>(rows.values());
+ return list.iterator();
+ }
+ @Override
+ public Iterator iterator() {
+ return rowIterator();
+ }
+ @Override
+ public void setForceFormulaRecalculation(boolean value) {}
+ @Override
+ public boolean getForceFormulaRecalculation() { return false; }
+ @Override
+ public void setAutobreaks(boolean value) {}
+ @Override
+ public boolean getAutobreaks() { return false; }
+ @Override
+ public void setDisplayGuts(boolean value) {}
+ @Override
+ public boolean getDisplayGuts() { return false; }
+ @Override
+ public void setDisplayRowColHeadings(boolean value) {}
+ @Override
+ public void setDisplayFormulas(boolean value) {}
+ @Override
+ public void setDisplayGridlines(boolean value) {}
+ @Override
+ public boolean isDisplayGridlines() { return false; }
+ @Override
+ public void setRowSumsBelow(boolean value) {}
+ @Override
+ public boolean getRowSumsBelow() { return false; }
+ @Override
+ public void setRowSumsRight(boolean value) {}
+ @Override
+ public boolean getRowSumsRight() { return false; }
+ @Override
+ public int getPhysicalNumberOfRows() { return rows.size(); }
+ @Override
+ public int addMergedRegion(CellRangeAddress region) { return 0; }
+ @Override
+ public int addMergedRegionUnsafe(CellRangeAddress region) { return 0; }
+
+ @Override
+ public CellAddress getActiveCell() { return null; }
+
+ @Override
+ public List extends Hyperlink> getHyperlinkList() { return new ArrayList<>(); }
+
+ @Override
+ public Hyperlink getHyperlink(CellAddress address) { return null; }
+
+ @Override
+ public Hyperlink getHyperlink(int row, int column) { return null; }
+
+ @Override
+ public int getColumnOutlineLevel(int columnIndex) { return 0; }
+
+ @Override
+ public void setRepeatingColumns(CellRangeAddress repeatingColumnsToIndex) {}
+
+ @Override
+ public void setRepeatingRows(CellRangeAddress repeatingRowsToIndex) {}
+
+ @Override
+ public CellRangeAddress getRepeatingColumns() { return null; }
+
+ @Override
+ public CellRangeAddress getRepeatingRows() { return null; }
+
+ @Override
+ public SheetConditionalFormatting getSheetConditionalFormatting() { return null; }
+
+ @Override
+ public AutoFilter setAutoFilter(CellRangeAddress range) { return null; }
+
+ @Override
+ public void addValidationData(DataValidation dataValidation) {}
+
+ @Override
+ public List extends DataValidation> getDataValidations() { return new ArrayList<>(); }
+
+ @Override
+ public DataValidationHelper getDataValidationHelper() { return null; }
+
+ @Override
+ public CellRange extends Cell> removeArrayFormula(Cell cell) { return null; }
+
+ @Override
+ public CellRange extends Cell> setArrayFormula(String formula, CellRangeAddress range) { return null; }
+
+ @Override
+ public boolean isSelected() { return false; }
+
+ @Override
+ public Drawing> createDrawingPatriarch() { return null; }
+
+ @Override
+ public Drawing> getDrawingPatriarch() { return null; }
+
+ @Override
+ public Map getCellComments() {
+ return new HashMap<>();
+ }
+
+ @Override
+ public Comment getCellComment(CellAddress address) { return null; }
+
+ @Override
+ public void autoSizeColumn(int columnIndex, boolean useMergedCells) {}
+
+ @Override
+ public void setDefaultColumnStyle(int column, CellStyle style) {}
+
+ @Override
+ public void setRowGroupCollapsed(int row, boolean collapse) {}
+
+ @Override
+ public void groupRow(int startRow, int endRow) {}
+
+ @Override
+ public void ungroupRow(int startRow, int endRow) {}
+
+ @Override
+ public void groupColumn(int startColumn, int endColumn) {}
+
+ @Override
+ public void ungroupColumn(int startColumn, int endColumn) {}
+
+ @Override
+ public void setColumnGroupCollapsed(int columnNumber, boolean collapsed) {}
+
+ @Override
+ public void setRowBreak(int row) {}
+
+ @Override
+ public void removeRowBreak(int row) {}
+
+ @Override
+ public boolean isRowBroken(int row) { return false; }
+
+ @Override
+ public int[] getRowBreaks() { return new int[0]; }
+
+ @Override
+ public void setColumnBreak(int column) {}
+
+ @Override
+ public void removeColumnBreak(int column) {}
+
+ @Override
+ public boolean isColumnBroken(int column) { return false; }
+
+ @Override
+ public int[] getColumnBreaks() { return new int[0]; }
+
+ @Override
+ public boolean isDisplayRowColHeadings() { return true; }
+
+ @Override
+ public boolean isDisplayFormulas() { return false; }
+
+ @Override
+ public org.apache.poi.ss.util.PaneInformation getPaneInformation() { return null; }
+
+ @Override
+ public void createFreezePane(int colSplit, int rowSplit, int leftmostColumn, int topRow) {}
+
+ @Override
+ public void createFreezePane(int colSplit, int rowSplit) {}
+
+ @Override
+ public void createSplitPane(int xSplit, int ySplit, int leftmostColumn, int topRow, PaneType activePane) {}
+
+ @Override
+ public void createSplitPane(int xSplit, int ySplit, int leftmostColumn, int topRow, int activePane) {}
+
+ @Override
+ public void shiftColumns(int startColumn, int endColumn, int n) {}
+
+ @Override
+ public void shiftRows(int startRow, int endRow, int n, boolean copyRowHeight, boolean resetOriginalRowHeight) {}
+
+ @Override
+ public void shiftRows(int startRow, int endRow, int n) {}
+
+ @Override
+ public void showInPane(int topRow, int leftmostColumn) {}
+
+ @Override
+ public short getLeftCol() { return 0; }
+
+ @Override
+ public short getTopRow() { return 0; }
+
+ @Override
+ public void setZoom(int scale) {}
+
+ @Override
+ public boolean getScenarioProtect() { return false; }
+
+ @Override
+ public void protectSheet(String password) {}
+
+ @Override
+ public boolean getProtect() { return false; }
+
+ @Override
+ public double getMargin(short margin) { return 0; }
+
+ @Override
+ public void setMargin(short margin, double size) {}
+
+ @Override
+ public void setSelected(boolean sel) {}
+
+ @Override
+ public Header getHeader() { return null; }
+
+ @Override
+ public Footer getFooter() { return null; }
+
+ @Override
+ public PrintSetup getPrintSetup() { return null; }
+
+ @Override
+ public boolean getFitToPage() { return false; }
+
+ @Override
+ public void setFitToPage(boolean value) {}
+
+ @Override
+ public boolean isDisplayZeros() { return true; }
+
+ @Override
+ public void setDisplayZeros(boolean value) {}
+
+ @Override
+ public boolean getHorizontallyCenter() { return false; }
+
+ @Override
+ public void setHorizontallyCenter(boolean value) {}
+
+ @Override
+ public boolean getVerticallyCenter() { return false; }
+
+ @Override
+ public void setVerticallyCenter(boolean value) {}
+
+ @Override
+ public void validateMergedRegions() {}
+
+ @Override
+ public CellStyle getColumnStyle(int column) { return null; }
+
+ @Override
+ public short getDefaultRowHeight() { return 300; }
+
+ @Override
+ public void setDefaultRowHeight(short height) {}
+
+ @Override
+ public float getDefaultRowHeightInPoints() { return 15.0f; }
+
+ @Override
+ public void setDefaultRowHeightInPoints(float height) {}
+
+ @Override
+ public int getDefaultColumnWidth() { return 8; }
+
+ @Override
+ public void setDefaultColumnWidth(int width) {}
+
+ @Override
+ public float getColumnWidthInPixels(int columnIndex) { return 8.0f * 8; }
+
+ @Override
+ public boolean isRightToLeft() { return false; }
+
+ @Override
+ public void setRightToLeft(boolean value) {}
+
+ @Override
+ public boolean isColumnHidden(int columnIndex) { return false; }
+
+ @Override
+ public void setColumnHidden(int columnIndex, boolean hidden) {}
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsWorkbook.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsWorkbook.java
new file mode 100644
index 0000000..df6caaf
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsWorkbook.java
@@ -0,0 +1,354 @@
+/*
+ * 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.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import com.github.miachm.sods.SpreadSheet;
+import org.apache.poi.ss.SpreadsheetVersion;
+import org.apache.poi.ss.usermodel.*;
+
+/**
+ * Adapter for Apache POI {@link Workbook} over a SODS {@link com.github.miachm.sods.SpreadSheet}.
+ * Serves as the primary entry point for ODS read/write operations within the POI interface layer.
+ */
+public class OdsWorkbook implements Workbook {
+
+ private final SpreadSheet spreadSheet;
+ private final List sheets = new ArrayList<>();
+ private final List fonts = new ArrayList<>();
+ private final List styles = new ArrayList<>();
+ private final OdsCreationHelper creationHelper = new OdsCreationHelper(this);
+
+ public OdsWorkbook() {
+ this.spreadSheet = new SpreadSheet();
+ }
+
+ public OdsWorkbook(InputStream is) throws IOException {
+ this.spreadSheet = new SpreadSheet(is);
+ for (com.github.miachm.sods.Sheet sodsSheet : spreadSheet.getSheets()) {
+ sheets.add(new OdsSheet(this, sodsSheet));
+ }
+ }
+
+ public SpreadSheet getSpreadSheet() {
+ return this.spreadSheet;
+ }
+
+ @Override
+ public int getActiveSheetIndex() {
+ return 0;
+ }
+
+ @Override
+ public void setActiveSheet(int sheetIndex) {
+ }
+
+ @Override
+ public int getFirstVisibleTab() {
+ return 0;
+ }
+
+ @Override
+ public void setFirstVisibleTab(int sheetIndex) {
+ }
+
+ @Override
+ public void setSheetOrder(String sheetname, int pos) {
+ }
+
+ @Override
+ public void setSelectedTab(int index) {
+ }
+
+ @Override
+ public void setSheetName(int sheet, String name) {
+ if (sheet >= 0 && sheet < sheets.size()) {
+ sheets.get(sheet).getSodsSheet().setName(name);
+ }
+ }
+
+ @Override
+ public String getSheetName(int sheet) {
+ if (sheet >= 0 && sheet < sheets.size()) {
+ return sheets.get(sheet).getSheetName();
+ }
+ return null;
+ }
+
+ @Override
+ public int getSheetIndex(String name) {
+ for (int i = 0; i < sheets.size(); i++) {
+ if (sheets.get(i).getSheetName().equalsIgnoreCase(name)) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ @Override
+ public int getSheetIndex(Sheet sheet) {
+ return sheets.indexOf(sheet);
+ }
+
+ @Override
+ public Sheet createSheet() {
+ return createSheet("Sheet" + sheets.size());
+ }
+
+ @Override
+ public Sheet createSheet(String sheetname) {
+ com.github.miachm.sods.Sheet sodsSheet = new com.github.miachm.sods.Sheet(sheetname);
+ spreadSheet.appendSheet(sodsSheet);
+ OdsSheet newSheet = new OdsSheet(this, sodsSheet);
+ sheets.add(newSheet);
+ return newSheet;
+ }
+
+ @Override
+ public Sheet cloneSheet(int sheetNum) {
+ throw new UnsupportedOperationException("OdsWorkbook: cloneSheet not implemented");
+ }
+
+ @Override
+ public Iterator sheetIterator() {
+ List list = new ArrayList<>(sheets);
+ return list.iterator();
+ }
+
+ @Override
+ public int getNumberOfSheets() {
+ return sheets.size();
+ }
+
+ @Override
+ public Sheet getSheetAt(int index) {
+ if (index >= 0 && index < sheets.size()) {
+ return sheets.get(index);
+ }
+ return null;
+ }
+
+ @Override
+ public Sheet getSheet(String name) {
+ int index = getSheetIndex(name);
+ return index != -1 ? getSheetAt(index) : null;
+ }
+
+ @Override
+ public void removeSheetAt(int index) {
+ if (index >= 0 && index < sheets.size()) {
+ spreadSheet.deleteSheet(index);
+ sheets.remove(index);
+ }
+ }
+
+ @Override
+ public Font createFont() {
+ OdsFont font = new OdsFont((short) fonts.size());
+ fonts.add(font);
+ return font;
+ }
+
+ @Override
+ public Font findFont(boolean bold, short color, short fontHeight, String name, boolean italic, boolean strikeout, short typeOffset, byte underline) {
+ for (OdsFont font : fonts) {
+ if (font.getBold() == bold && font.getColor() == color && font.getFontHeight() == fontHeight && font.getFontName().equals(name)) {
+ return font;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public int getNumberOfFonts() {
+ return fonts.size();
+ }
+
+ @Override
+ public Font getFontAt(int idx) {
+ if (idx >= 0 && idx < fonts.size()) {
+ return fonts.get(idx);
+ }
+ return null;
+ }
+
+ @Override
+ public DataFormat createDataFormat() {
+ return creationHelper.createDataFormat();
+ }
+
+ @Override
+ public CellStyle createCellStyle() {
+ OdsCellStyle style = new OdsCellStyle(this);
+ styles.add(style);
+ return style;
+ }
+
+ @Override
+ public int getNumCellStyles() {
+ return styles.size();
+ }
+
+ @Override
+ public CellStyle getCellStyleAt(int idx) {
+ if (idx >= 0 && idx < styles.size()) {
+ return styles.get(idx);
+ }
+ return null;
+ }
+
+ @Override
+ public void write(OutputStream stream) throws IOException {
+ spreadSheet.save(stream);
+ }
+
+ @Override
+ public void close() throws IOException {
+ }
+
+ @Override
+ public int addPicture(byte[] pictureData, int format) {
+ return 0;
+ }
+
+ @Override
+ public List extends PictureData> getAllPictures() {
+ return new ArrayList<>();
+ }
+
+ @Override
+ public CreationHelper getCreationHelper() {
+ return creationHelper;
+ }
+
+ @Override
+ public boolean isHidden() {
+ return false;
+ }
+
+ @Override
+ public void setHidden(boolean hidden) {
+ }
+
+ @Override
+ public boolean isSheetHidden(int sheetNum) {
+ return false;
+ }
+
+ @Override
+ public boolean isSheetVeryHidden(int sheetNum) {
+ return false;
+ }
+
+ @Override
+ public void setSheetHidden(int sheetNum, boolean hidden) {
+ }
+
+ @Override
+ public void setSheetVisibility(int sheetNum, SheetVisibility visibility) {
+ }
+
+ @Override
+ public SheetVisibility getSheetVisibility(int sheetNum) {
+ return SheetVisibility.VISIBLE;
+ }
+
+ @Override
+ public void addToolPack(org.apache.poi.ss.formula.udf.UDFFinder toopack) {
+ }
+
+ @Override
+ public void setForceFormulaRecalculation(boolean value) {
+ }
+
+ @Override
+ public boolean getForceFormulaRecalculation() {
+ return false;
+ }
+
+ @Override
+ public SpreadsheetVersion getSpreadsheetVersion() {
+ return SpreadsheetVersion.EXCEL2007;
+ }
+
+ @Override
+ public Iterator iterator() {
+ return sheetIterator();
+ }
+
+ @Override
+ public void setCellReferenceType(org.apache.poi.ss.usermodel.CellReferenceType cellReferenceType) {
+ }
+
+ @Override
+ public org.apache.poi.ss.usermodel.CellReferenceType getCellReferenceType() {
+ return org.apache.poi.ss.usermodel.CellReferenceType.A1;
+ }
+
+ @Override
+ public int addOlePackage(byte[] oleData, String label, String fileName, String command) throws IOException {
+ return 0;
+ }
+
+ @Override
+ public org.apache.poi.ss.formula.EvaluationWorkbook createEvaluationWorkbook() {
+ return null;
+ }
+
+ @Override
+ public void setMissingCellPolicy(Row.MissingCellPolicy missingCellPolicy) {}
+
+ @Override
+ public Row.MissingCellPolicy getMissingCellPolicy() {
+ return Row.MissingCellPolicy.RETURN_NULL_AND_BLANK;
+ }
+
+ @Override
+ public void removePrintArea(int sheetIndex) {}
+
+ @Override
+ public void setPrintArea(int sheetIndex, String reference) {}
+
+ @Override
+ public void setPrintArea(int sheetIndex, int startColumn, int endColumn, int startRow, int endRow) {}
+
+ @Override
+ public String getPrintArea(int sheetIndex) { return null; }
+
+ @Override
+ public int linkExternalWorkbook(String name, Workbook workbook) {
+ return 0;
+ }
+
+ @Override
+ public int getNumberOfNames() { return 0; }
+
+ @Override
+ public Name getName(String name) { return null; }
+
+ @Override
+ public List extends Name> getNames(String name) { return new ArrayList<>(); }
+
+ @Override
+ public List extends Name> getAllNames() { return new ArrayList<>(); }
+
+ @Override
+ public Name createName() { return null; }
+
+ @Override
+ public void removeName(Name name) {}
+
+ @Override
+ public int getNumberOfFontsAsInt() {
+ return fonts.size();
+ }
+}
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java b/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
new file mode 100644
index 0000000..92d86f4
--- /dev/null
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
@@ -0,0 +1,32 @@
+/*
+ * SPDX-FileContributor: Arthit Suriyawongkul
+ * SPDX-FileCopyrightText: 2026 SPDX Contributors
+ * SPDX-FileType: SOURCE
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Custom Apache POI adapter layer using the SODS library to provide
+ * OpenDocument Spreadsheet (ODS) support.
+ *
+ * This package maps the Apache POI {@code org.apache.poi.ss.usermodel}
+ * interfaces to the underlying {@code com.github.miachm.sods} library.
+ *
+ *
+ * Standard spreadsheet styling elements (colors, fonts, alignments, text
+ * wrapping, and cell borders) are fully supported.
+ *
+ *
+ * Limitations
+ *
+ * Custom cell data formatting (e.g., specific number formats and custom date
+ * patterns) has limited support due to the underlying SODS library. SODS only
+ * natively supports plain text ("@") or an ISO date format ("YYYY-MM-DD").
+ *
+ *
+ * Consequently, any target cell format representing a date is mapped to the
+ * standard {@code "YYYY-MM-DD"} format, while custom numerical and string
+ * format patterns remain unstyled.
+ *
+ */
+package org.spdx.spreadsheetstore.ods;
diff --git a/src/test/java/org/spdx/spreadsheetstore/SpreadsheetStoreTest.java b/src/test/java/org/spdx/spreadsheetstore/SpreadsheetStoreTest.java
index 45fcae3..ffe94bb 100644
--- a/src/test/java/org/spdx/spreadsheetstore/SpreadsheetStoreTest.java
+++ b/src/test/java/org/spdx/spreadsheetstore/SpreadsheetStoreTest.java
@@ -1,5 +1,6 @@
/*
* SPDX-FileContributor: Gary O'Neall
+ * SPDX-FileContributor: Arthit Suriyawongkul
* SPDX-FileCopyrightText: Copyright (c) 2020 Source Auditor Inc.
* SPDX-FileType: SOURCE
* SPDX-License-Identifier: Apache-2.0
@@ -67,6 +68,7 @@
import org.spdx.library.model.v2.license.SpdxNoAssertionLicense;
import org.spdx.library.model.v3_0_1.SpdxModelInfoV3_0;
import org.spdx.library.referencetype.ListedReferenceTypes;
+import org.spdx.spreadsheetstore.SpreadsheetStore.SpreadsheetFormatType;
import org.spdx.storage.compatv2.CompatibleModelStoreWrapper;
import org.spdx.storage.simple.InMemSpdxStore;
import org.spdx.utility.compare.SpdxCompareException;
@@ -495,6 +497,103 @@ public void testSerialize() throws InvalidSPDXAnalysisException, IOException, Sp
}
+ /**
+ * Test method for
+ * {@link org.spdx.spreadsheetstore.SpreadsheetStore#serialize(java.io.OutputStream)}
+ * using ODS format.
+ *
+ * @throws InvalidSPDXAnalysisException
+ * @throws IOException
+ * @throws SpdxCompareException
+ */
+ @SuppressWarnings("unchecked")
+ public void testSerializeOds() throws InvalidSPDXAnalysisException, IOException, SpdxCompareException {
+ SpreadsheetStore sst = new SpreadsheetStore(new InMemSpdxStore(), SpreadsheetFormatType.ODS);
+ String documentUri = "http://newdoc/uri";
+ ModelCopyManager copyManager = new ModelCopyManager();
+ compareStore.getAllItems(compareDocument.getDocumentUri(), SpdxConstantsCompatV2.CLASS_EXTERNAL_DOC_REF).forEach(tv -> {
+ try {
+ copyManager.copy(sst, compareStore, tv.getObjectUri(),
+ CompatibleModelStoreWrapper.LATEST_SPDX_2X_VERSION, documentUri + "#");
+ } catch (InvalidSPDXAnalysisException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ compareStore.getAllItems(compareDocument.getDocumentUri(), null).forEach(tv -> {
+ try {
+ if (!SpdxConstantsCompatV2.CLASS_EXTERNAL_DOC_REF.equals(tv.getType())) {
+ copyManager.copy(sst, compareStore, tv.getObjectUri(),
+ CompatibleModelStoreWrapper.LATEST_SPDX_2X_VERSION, documentUri + "#");
+ }
+ } catch (InvalidSPDXAnalysisException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ Path tempFilePath = Files.createTempFile("temp", ".ods");
+ try {
+ try (FileOutputStream out = new FileOutputStream(tempFilePath.toFile())) {
+ sst.serialize(out);
+ }
+ SpreadsheetStore resultStore = new SpreadsheetStore(new InMemSpdxStore());
+ try (FileInputStream stream = new FileInputStream(tempFilePath.toFile())) {
+ resultStore.deSerialize(stream, false);
+ }
+ List allDocs = (List)SpdxModelFactory.getSpdxObjects(resultStore, copyManager,
+ SpdxConstantsCompatV2.CLASS_SPDX_DOCUMENT, null, null).collect(Collectors.toList());
+ assertEquals(1, allDocs.size());
+ String resultDocUri = allDocs.get(0).getDocumentUri();
+ assertEquals(documentUri, resultDocUri);
+ ModelCopyManager cm = new ModelCopyManager();
+ SpdxDocument doc = new SpdxDocument(resultStore, resultDocUri, cm, false);
+ // Document fields and extracted license infos
+ assertDocFields(doc, "SPDX-2.3");
+ SpdxComparer comparer = new SpdxComparer();
+ SpdxPackage comparePkg = new SpdxPackage(compareDocument.getModelStore(), compareDocument.getDocumentUri(), "SPDXRef-Package", null, false);
+ SpdxPackage resultPkg = new SpdxPackage(resultStore, resultDocUri, "SPDXRef-Package", null, false);
+ assertTrue(resultPkg.equivalent(comparePkg));
+ comparer.compare(compareDocument, doc);
+ assertFalse(comparer.isDifferenceFound());
+ // Files
+ try(Stream elementStream = (Stream)SpdxModelFactory.getSpdxObjects(resultStore, copyManager,
+ SpdxConstantsCompatV2.CLASS_SPDX_FILE, documentUri, documentUri + "#")) {
+ elementStream.forEach(element -> {
+ try {
+ assertTrue(((SpdxElement)element).equivalent(compareFiles.get(((SpdxElement)element).getId())));
+ } catch (InvalidSPDXAnalysisException e) {
+ fail("Exception: "+e.getMessage());
+ }
+ });
+ }
+
+ // Packages
+ try(Stream elementStream = (Stream)SpdxModelFactory.getSpdxObjects(resultStore, copyManager,
+ SpdxConstantsCompatV2.CLASS_SPDX_PACKAGE, documentUri, documentUri + "#")) {
+ elementStream.forEach(element -> {
+ try {
+ assertTrue(((SpdxElement)element).equivalent(comparePackages.get(((SpdxElement)element).getId())));
+ } catch (InvalidSPDXAnalysisException e) {
+ fail("Exception: "+e.getMessage());
+ }
+ });
+ }
+
+ // Snippets
+ try(Stream elementStream = (Stream)SpdxModelFactory.getSpdxObjects(resultStore, copyManager,
+ SpdxConstantsCompatV2.CLASS_SPDX_SNIPPET, documentUri, documentUri + "#")) {
+ elementStream.forEach(element -> {
+ try {
+ assertTrue(((SpdxElement)element).equivalent(compareSnippets.get(((SpdxElement)element).getId())));
+ } catch (InvalidSPDXAnalysisException e) {
+ fail("Exception: "+e.getMessage());
+ }
+ });
+ }
+ } finally {
+ tempFilePath.toFile().delete();
+ }
+ }
+
/**
* Test method for {@link org.spdx.spreadsheetstore.SpreadsheetStore#deSerialize(java.io.InputStream, boolean)}.
* @throws IOException
From 4658f81d5e157e6ac40d90394fcedf02382191c4 Mon Sep 17 00:00:00 2001
From: Arthit Suriyawongkul
Date: Sat, 18 Jul 2026 13:24:27 +0100
Subject: [PATCH 2/2] Add comment on actual datatime precision being stored
Signed-off-by: Arthit Suriyawongkul
---
.../java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java | 6 ++++++
.../java/org/spdx/spreadsheetstore/ods/package-info.java | 7 +++++++
2 files changed, 13 insertions(+)
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
index 6009ce2..76f1517 100644
--- a/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/OdsCellStyle.java
@@ -238,6 +238,12 @@ public void setFont(Font font) {
* dates are properly displayed. Other formats are currently stored but may not
* affect the final ODS cell rendering.
* | | |
+ *
+ * This is display-only: the full {@code HH:mm:ss} timestamp is still stored
+ * in {@code office:date-value} and returned unchanged by
+ * {@link Cell#getDateCellValue()}, even though time-inclusive formats
+ * render as date-only in spreadsheet applications.
+ *
*
* @param fmt the data format index
*/
diff --git a/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java b/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
index 92d86f4..9ffd603 100644
--- a/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
+++ b/src/main/java/org/spdx/spreadsheetstore/ods/package-info.java
@@ -28,5 +28,12 @@
* standard {@code "YYYY-MM-DD"} format, while custom numerical and string
* format patterns remain unstyled.
*
+ *
+ * This is a display-only limitation. Date/time values are still stored with
+ * full {@code HH:mm:ss} precision in the cell's underlying
+ * {@code office:date-value} attribute; only the visual rendering in
+ * spreadsheet applications (e.g. LibreOffice, Excel) is truncated to the
+ * date portion.
+ *
*/
package org.spdx.spreadsheetstore.ods;