Repository: ontodev/robot (java)

Issue:
Export to Excel
This is one part of #629. I would like revisions in #636 to use this feature, and there's code on the `validate` branch as a starting point.

Excel output should be similar to HTML table output. It should support comments and background colours on each Cell.

Pull request:
Add Excel format to export
Resolves #637 

- [x] `docs/` have been added/updated
- [x] tests have been added/updated
- [x] `mvn verify` says all tests pass
- [x] `mvn site` says all JavaDocs correct
- [x] `CHANGELOG.md` has been updated

Add excel/`xlsx` format to export. Support for cell styling for when we rework `validate` to use the Table object. This builds on #645 to prevent merge conflicts.

I was going to have the columns automatically autosize, but that seems to take up more time than it's worth ([see here](https://github.com/ontodev/robot/blob/ec4a99d498972a8d92f6c785416b5704b397fa3f/robot-core/src/main/java/org/obolibrary/robot/export/Table.java#L94-L98)). Thoughts?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 259d9759..4028a548 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- Add [`export`] command [#481]
+- Add JSON format to [`export`] in [#645]
+- Add Excel format to [`export`] in [#646]
+
 ### Fixed
 - Fix filtering axioms with multiple axiom selectors [#644]
 
@@ -158,6 +163,7 @@ First official release of ROBOT!
 [`convert`]: http://robot.obolibrary.org/convert
 [`diff`]: http://robot.obolibrary.org/diff
 [`explain`]: http://robot.obolibrary.org/explain
+[`export`]: http://robot.obolibrary.org/export
 [`extract`]: http://robot.obolibrary.org/extract
 [`filter`]: http://robot.obolibrary.org/filter
 [`merge`]: http://robot.obolibrary.org/merge
@@ -171,6 +177,8 @@ First official release of ROBOT!
 [`report`]: http://robot.obolibrary.org/report
 [`template`]: http://robot.obolibrary.org/template
 
+[#646]: https://github.com/ontodev/robot/issues/646
+[#645]: https://github.com/ontodev/robot/issues/645
 [#644]: https://github.com/ontodev/robot/issues/644
 [#630]: https://github.com/ontodev/robot/issues/630
 [#625]: https://github.com/ontodev/robot/issues/625
@@ -199,6 +207,7 @@ First official release of ROBOT!
 [#507]: https://github.com/ontodev/robot/issues/507
 [#488]: https://github.com/ontodev/robot/issues/488
 [#484]: https://github.com/ontodev/robot/issues/484
+[#481]: https://github.com/ontodev/robot/issues/481
 [#475]: https://github.com/ontodev/robot/issues/475
 [#471]: https://github.com/ontodev/robot/issues/471
 [#461]: https://github.com/ontodev/robot/issues/461
diff --git a/docs/examples/nucleus.json b/docs/examples/nucleus.json
new file mode 100644
index 00000000..fc5760a2
--- /dev/null
+++ b/docs/examples/nucleus.json
@@ -0,0 +1,169 @@
+[
+  {
+    "ID": "GO:0005575",
+    "LABEL": [
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0005622",
+    "LABEL": [
+      "intracellular"
+    ],
+    "SubClass Of": [
+      "cell part"
+    ]
+  },
+  {
+    "ID": "GO:0005623",
+    "LABEL": [
+      "cell"
+    ],
+    "SubClass Of": [
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0005634",
+    "LABEL": [
+      "nucleus"
+    ],
+    "SubClass Of": [
+      "intracellular membrane-bounded organelle"
+    ]
+  },
+  {
+    "ID": "GO:0031974",
+    "LABEL": [
+      "membrane-enclosed lumen"
+    ],
+    "SubClass Of": [
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0031981",
+    "LABEL": [
+      "nuclear lumen"
+    ],
+    "SubClass Of": [
+      "'part of' some nucleus",
+      "intracellular organelle lumen",
+      "nuclear part"
+    ]
+  },
+  {
+    "ID": "GO:0043226",
+    "LABEL": [
+      "organelle"
+    ],
+    "SubClass Of": [
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0043227",
+    "LABEL": [
+      "membrane-bounded organelle"
+    ],
+    "SubClass Of": [
+      "organelle"
+    ]
+  },
+  {
+    "ID": "GO:0043229",
+    "LABEL": [
+      "intracellular organelle"
+    ],
+    "SubClass Of": [
+      "'part of' some intracellular",
+      "intracellular part",
+      "organelle"
+    ]
+  },
+  {
+    "ID": "GO:0043231",
+    "LABEL": [
+      "intracellular membrane-bounded organelle"
+    ],
+    "SubClass Of": [
+      "intracellular organelle",
+      "membrane-bounded organelle"
+    ]
+  },
+  {
+    "ID": "GO:0043233",
+    "LABEL": [
+      "organelle lumen"
+    ],
+    "SubClass Of": [
+      "'part of' some organelle",
+      "membrane-enclosed lumen",
+      "organelle part"
+    ]
+  },
+  {
+    "ID": "GO:0044422",
+    "LABEL": [
+      "organelle part"
+    ],
+    "SubClass Of": [
+      "'part of' some organelle",
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0044424",
+    "LABEL": [
+      "intracellular part"
+    ],
+    "SubClass Of": [
+      "'part of' some intracellular",
+      "cell part"
+    ]
+  },
+  {
+    "ID": "GO:0044428",
+    "LABEL": [
+      "nuclear part"
+    ],
+    "SubClass Of": [
+      "'part of' some nucleus",
+      "intracellular organelle part"
+    ]
+  },
+  {
+    "ID": "GO:0044446",
+    "LABEL": [
+      "intracellular organelle part"
+    ],
+    "SubClass Of": [
+      "'part of' some 'intracellular organelle'",
+      "'part of' some intracellular",
+      "'part of' some organelle",
+      "intracellular part",
+      "organelle part"
+    ]
+  },
+  {
+    "ID": "GO:0044464",
+    "LABEL": [
+      "cell part"
+    ],
+    "SubClass Of": [
+      "'part of' some cell",
+      "cellular_component"
+    ]
+  },
+  {
+    "ID": "GO:0070013",
+    "LABEL": [
+      "intracellular organelle lumen"
+    ],
+    "SubClass Of": [
+      "'part of' some 'intracellular organelle'",
+      "intracellular organelle part",
+      "organelle lumen"
+    ]
+  }
+]
\ No newline at end of file
diff --git a/docs/export.md b/docs/export.md
index 36134483..997a1b0f 100644
--- a/docs/export.md
+++ b/docs/export.md
@@ -20,9 +20,11 @@ robot export --input nucleus_part_of.owl \
 
 The following formats are currently supported:
 
-* `tsv`
-* `csv`
-* `html`
+* `tsv` - tab-separated file, default format for unknown extensions
+* `csv` - comma-separated file
+* `html` - HTML table with with [Bootstrap](https://getbootstrap.com/) styling
+* `json` - JSON file with values as arrays (except for ID/CURIE and IRI, which are single strings)
+* `xlsx` - Excel workbook with contents on first sheet
 
 These can be specified with the `--format` option:
 
@@ -30,9 +32,11 @@ These can be specified with the `--format` option:
       --header "LABEL|SubClass Of" \
       --format html --export results/nucleus.html
  
-If this option is not included, `export` will predict the format based on the file extension. If the extension does not match with an existing format, it will default to `tsv`.
-
-The `html` format will output an HTML table with [Bootstrap](https://getbootstrap.com/) styling. All entities referenced will be rendered as clickable links.
+If this option is not included, `export` will predict the format based on the file extension:
+ 
+    robot export --input nucleus_part_of.owl \
+      --header "ID|LABEL|SubClass Of" \
+      --export results/nucleus.json
 
 ### Columns
 
@@ -160,7 +164,7 @@ A property cannot be resolved, usually meaning that the label cannot be resolved
 
 ### Unknown Format Error
 
-The following formats are currently supported: `tsv`, `csv`, and `html`. Please make sure you are using one of these formats.
+The following formats are currently supported: `tsv`, `csv`, `html`, `json`, and `xlsx`. Please make sure you are using one of these formats.
 
 ### Unknown Tag Error
 
diff --git a/robot-command/src/main/java/org/obolibrary/robot/ExportCommand.java b/robot-command/src/main/java/org/obolibrary/robot/ExportCommand.java
index 5f9d6965..5a38541b 100644
--- a/robot-command/src/main/java/org/obolibrary/robot/ExportCommand.java
+++ b/robot-command/src/main/java/org/obolibrary/robot/ExportCommand.java
@@ -1,11 +1,12 @@
 package org.obolibrary.robot;
 
-import java.io.File;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import org.apache.commons.cli.CommandLine;
 import org.apache.commons.cli.Options;
+import org.apache.commons.io.FilenameUtils;
+import org.obolibrary.robot.export.Table;
 import org.semanticweb.owlapi.model.OWLOntology;
 
 public class ExportCommand implements Command {
@@ -13,6 +14,15 @@ public class ExportCommand implements Command {
   /** Store the command-line options for the command. */
   private Options options;
 
+  /** Namespace for error messages. */
+  private static final String NS = "export#";
+
+  private static final String unknownFormatError =
+      NS + "UNKNOWN FORMAT ERROR --format %s must be one of: csv, html, json, tsv, or xlsx";
+
+  /** Supported output formats. If the output format is not one of these, it will default to TSV. */
+  private final List<String> supportedFormats = Arrays.asList("csv", "html", "json", "tsv", "xlsx");
+
   /** Initialize the command. */
   public ExportCommand() {
     Options o = CommandLineHelper.getCommonOptions();
@@ -24,7 +34,7 @@ public class ExportCommand implements Command {
     o.addOption("A", "exclude-anonymous", true, "if true, exclude anonymous entities");
     o.addOption("s", "sort", true, "column to sort on (default: first column)");
     o.addOption("n", "include", true, "groups of terms to include");
-    o.addOption("f", "format", true, "output file format (TSV, CSV, or HTML)");
+    o.addOption("f", "format", true, "output file format");
     o.addOption("S", "split", true, "character to split multiple values on (default: |)");
     o.addOption(
         "E",
@@ -131,16 +141,23 @@ public class ExportCommand implements Command {
     String format = CommandLineHelper.getOptionalValue(line, "format");
     if (format == null) {
       // Use the path to determine the format (default is TSV)
-      if (exportPath.endsWith(".csv")) {
-        exportOptions.put("format", "csv");
-      } else if (exportPath.endsWith(".html")) {
-        exportOptions.put("format", "html");
+      String ext = FilenameUtils.getExtension(exportPath).toLowerCase();
+      if (supportedFormats.contains(ext)) {
+        exportOptions.put("format", ext);
+      } else {
+        // Unknown extension, use tab-separated
+        exportOptions.put("format", "tsv");
       }
+    } else if (!supportedFormats.contains(format)) {
+      // If a format WAS provided and it's not in the supported formats, throw an error
+      throw new Exception(String.format(unknownFormatError, format));
     }
+
     // Get the split columns
     List<String> columns = Arrays.asList(headerString.split("\\|"));
 
-    ExportOperation.export(ontology, ioHelper, columns, new File(exportPath), exportOptions);
+    Table t = ExportOperation.createExportTable(ontology, ioHelper, columns, exportOptions);
+    ExportOperation.saveTable(t, exportPath, exportOptions);
     return state;
   }
 }
diff --git a/robot-core/pom.xml b/robot-core/pom.xml
index f4a133f5..9e43b530 100644
--- a/robot-core/pom.xml
+++ b/robot-core/pom.xml
@@ -79,6 +79,16 @@
       <artifactId>jena-tdb</artifactId>
       <version>3.8.0</version>
     </dependency>
+    <dependency>
+      <groupId>org.apache.poi</groupId>
+      <artifactId>poi</artifactId>
+      <version>3.15</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.poi</groupId>
+      <artifactId>poi-ooxml</artifactId>
+      <version>3.15</version>
+    </dependency>
     <dependency>
       <groupId>com.github.jsonld-java</groupId>
       <artifactId>jsonld-java</artifactId>
@@ -153,5 +163,10 @@
       <artifactId>owl-diff_2.12</artifactId>
       <version>1.1.2</version>
     </dependency>
+    <dependency>
+      <groupId>com.google.code.gson</groupId>
+      <artifactId>gson</artifactId>
+      <version>2.8.0</version>
+    </dependency>
   </dependencies>
 </project>
diff --git a/robot-core/src/main/java/org/obolibrary/robot/ExportOperation.java b/robot-core/src/main/java/org/obolibrary/robot/ExportOperation.java
index be626097..e870a090 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/ExportOperation.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/ExportOperation.java
@@ -5,6 +5,7 @@ import java.util.*;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
+import org.apache.poi.ss.usermodel.Workbook;
 import org.obolibrary.robot.export.*;
 import org.obolibrary.robot.providers.*;
 import org.semanticweb.owlapi.apibinding.OWLManager;
@@ -69,15 +70,13 @@ public class ExportOperation {
    * @param ontology OWLOntology to export to table
    * @param ioHelper IOHelper to handle labels
    * @param columnNames List of column names, in order
-   * @param exportFile File to export to
    * @param options Map of Export options
    * @throws Exception if file does not exist to write to or a column is not a valid property
    */
-  public static void export(
+  public static Table createExportTable(
       OWLOntology ontology,
       IOHelper ioHelper,
       List<String> columnNames,
-      File exportFile,
       Map<String, String> options)
       throws Exception {
     boolean excludeAnonymous = OptionsHelper.optionIsTrue(options, "exclude-anonymous");
@@ -217,8 +216,22 @@ public class ExportOperation {
 
     // Sort the rows by sort column or columns
     table.sortRows();
+    return table;
+  }
 
+  /**
+   * Save the Table object as: csv, tsv, html, json, or xlsx.
+   *
+   * @param table Table object to save
+   * @param exportPath path to export file
+   * @param options map of export options
+   * @throws Exception on writing file, or if format is unknown
+   */
+  public static void saveTable(Table table, String exportPath, Map<String, String> options)
+      throws Exception {
+    String format = OptionsHelper.getOption(options, "format", "tsv").toLowerCase();
     String split = OptionsHelper.getOption(options, "split", "|");
+    File exportFile = new File(exportPath);
     switch (format) {
       case "tsv":
         IOHelper.writeTable(table.toList(split), exportFile, '\t');
@@ -231,6 +244,17 @@ public class ExportOperation {
           out.print(table.toHTML(split));
         }
         break;
+      case "json":
+        try (PrintWriter out = new PrintWriter(exportFile)) {
+          out.print(table.toJSON());
+        }
+        break;
+      case "xlsx":
+        try (Workbook wb = table.asWorkbook(split);
+            FileOutputStream fos = new FileOutputStream(exportFile)) {
+          wb.write(fos);
+        }
+        break;
       default:
         throw new Exception(String.format(unknownFormatError, format));
     }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/export/Cell.java b/robot-core/src/main/java/org/obolibrary/robot/export/Cell.java
index 1b6aca0d..60f52c38 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/export/Cell.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/export/Cell.java
@@ -4,6 +4,8 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import org.apache.poi.ss.usermodel.FillPatternType;
+import org.apache.poi.ss.usermodel.IndexedColors;
 
 /** @author <a href="mailto@rbca.jackson@gmail.com">Becky Jackson</a> */
 public class Cell {
@@ -17,6 +19,15 @@ public class Cell {
   private List<String> displayValues = new ArrayList<>();
   private String sortValueString;
 
+  // Styles for XLSX output
+  private IndexedColors cellColor = null;
+  private FillPatternType cellPattern = null;
+  private IndexedColors fontColor = null;
+
+  // Comment can appear as an XLSX Comment or an HTML tooltip
+  // This is not required and can be returned null
+  private String comment = null;
+
   /**
    * Init a new Cell for a Column with multiple display values and no sort values.
    *
@@ -78,6 +89,24 @@ public class Cell {
     sortValueString = String.join("|", sortValues);
   }
 
+  /**
+   * Get the cell background color for this cell in an XLSX workbook.
+   *
+   * @return IndexedColors value for cell
+   */
+  public IndexedColors getCellColor() {
+    return cellColor;
+  }
+
+  /**
+   * Get the cell background pattern for this cell in an XLSX workbook.
+   *
+   * @return FillPatternType value for cell
+   */
+  public FillPatternType getCellPattern() {
+    return cellPattern;
+  }
+
   /**
    * Get the column name for a cell. This returns the display name, including any rendering tags.
    *
@@ -87,6 +116,15 @@ public class Cell {
     return column.getDisplayName();
   }
 
+  /**
+   * Get the comment for this Cell.
+   *
+   * @return String comment, or null
+   */
+  public String getComment() {
+    return comment;
+  }
+
   /**
    * Get the value of a cell.
    *
@@ -96,6 +134,15 @@ public class Cell {
     return displayValues;
   }
 
+  /**
+   * Get the font color for this cell in an XLSX workbook.
+   *
+   * @return IndexedColors value for font
+   */
+  public IndexedColors getFontColor() {
+    return fontColor;
+  }
+
   /**
    * Get the sort value of a cell.
    *
@@ -105,19 +152,40 @@ public class Cell {
     return sortValueString;
   }
 
-  /** Sort cell values alphabetically. */
-  private void sortCellValues() {
-    Comparator<CellValue> cvComparator =
-        (cv1, cv2) -> {
-          String sv1 = cv1.getSortValue();
-          String sv2 = cv2.getSortValue();
+  /**
+   * Set the cell background color for this cell in an XLSX workbook.
+   *
+   * @param cellColor IndexedColors value for cell
+   */
+  public void setCellColor(IndexedColors cellColor) {
+    this.cellColor = cellColor;
+  }
 
-          // Make sure empty strings end up last
-          if (sv1.isEmpty()) return Integer.MAX_VALUE;
-          else if (sv2.isEmpty()) return Integer.MIN_VALUE;
-          else return sv1.compareTo(sv2);
-        };
-    values.sort(cvComparator);
+  /**
+   * Set the cell background pattern for this cell in an XLSX workbook.
+   *
+   * @param cellPattern FillPatternType value for this cell
+   */
+  public void setCellPattern(FillPatternType cellPattern) {
+    this.cellPattern = cellPattern;
+  }
+
+  /**
+   * Add a comment to this Cell.
+   *
+   * @param comment String comment
+   */
+  public void setComment(String comment) {
+    this.comment = comment;
+  }
+
+  /**
+   * Set the font color for this cell in an XLSX workbook.
+   *
+   * @param fontColor IndexedColors value for font
+   */
+  public void setFontColor(IndexedColors fontColor) {
+    this.fontColor = fontColor;
   }
 
   /** Set a Cell's display and sort values with the sort value becoming one string to sort on. */
@@ -132,6 +200,21 @@ public class Cell {
     sortValueString = String.join("|", sortValues);
   }
 
+  /** Sort cell values alphabetically. */
+  private void sortCellValues() {
+    Comparator<CellValue> cvComparator =
+        (cv1, cv2) -> {
+          String sv1 = cv1.getSortValue();
+          String sv2 = cv2.getSortValue();
+
+          // Make sure empty strings end up last
+          if (sv1.isEmpty()) return Integer.MAX_VALUE;
+          else if (sv2.isEmpty()) return Integer.MIN_VALUE;
+          else return sv1.compareTo(sv2);
+        };
+    values.sort(cvComparator);
+  }
+
   /**
    * Private class to link display and sort values. This allows us to sort within a cell by the sort
    * value and still output the display value.
diff --git a/robot-core/src/main/java/org/obolibrary/robot/export/Row.java b/robot-core/src/main/java/org/obolibrary/robot/export/Row.java
index 8210bc25..8ad0f1cb 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/export/Row.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/export/Row.java
@@ -1,7 +1,10 @@
 package org.obolibrary.robot.export;
 
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
 import java.util.*;
 import java.util.stream.Collectors;
+import org.apache.poi.ss.usermodel.*;
 import org.semanticweb.owlapi.model.IRI;
 
 /** @author <a href="mailto@rbca.jackson@gmail.com">Becky Jackson</a> */
@@ -13,6 +16,9 @@ public class Row {
   // List of cells in this row
   private Map<String, Cell> cells = new HashMap<>();
 
+  // For JSON rendering, these should never be arrays
+  private static final List<String> singles = Arrays.asList("CURIE", "ID", "IRI");
+
   /** Init a new Row. */
   public Row(IRI subject) {
     this.subject = subject;
@@ -27,6 +33,84 @@ public class Row {
     cells.put(cell.getColumnName(), cell);
   }
 
+  /**
+   * Add this Row to a Workbook.
+   *
+   * @param wb Workbook to add a row to
+   * @param columns list of Columns
+   * @param split character to split multiple cell values on
+   */
+  public void addToWorkbook(Workbook wb, List<Column> columns, String split) {
+    // Create a new XLSX Row
+    Sheet sheet = wb.getSheetAt(0);
+    int rowIdx = sheet.getLastRowNum() + 1;
+    org.apache.poi.ss.usermodel.Row xlsxRow = sheet.createRow(rowIdx);
+
+    // Objects used to add Comments to cells
+    CreationHelper factory = wb.getCreationHelper();
+    ClientAnchor anchor = factory.createClientAnchor();
+    Drawing drawing = sheet.createDrawingPatriarch();
+
+    int cellIdx = 0;
+    for (Column c : columns) {
+      // Create a new XLSX Cell
+      org.apache.poi.ss.usermodel.Cell xlsxCell = xlsxRow.createCell(cellIdx);
+
+      String columnName = c.getDisplayName();
+      Cell cell = cells.getOrDefault(columnName, null);
+
+      String value;
+      String comment = null;
+      CellStyle style = wb.createCellStyle();
+      Font font = wb.createFont();
+      if (cell != null) {
+        List<String> values = cell.getDisplayValues();
+        if (values.size() > 1) {
+          // If size is greater than 1, escape any split characters with a backslash
+          values =
+              values.stream().map(x -> x.replace(split, "\\" + split)).collect(Collectors.toList());
+        }
+        value = String.join(split, values);
+
+        // Maybe set styles
+        IndexedColors cellColor = cell.getCellColor();
+        if (cellColor != null) {
+          style.setFillBackgroundColor(cellColor.getIndex());
+        }
+        FillPatternType cellPattern = cell.getCellPattern();
+        if (cellPattern != null) {
+          style.setFillPattern(cellPattern);
+        }
+        IndexedColors fontColor = cell.getFontColor();
+        if (fontColor != null) {
+          font.setColor(fontColor.getIndex());
+        }
+
+        // Maybe get a comment or null
+        comment = cell.getComment();
+      } else {
+        // Empty value, no styles to set
+        value = "";
+      }
+
+      // Add value to cell
+      xlsxCell.setCellValue(value);
+
+      // Add style to cell
+      style.setFont(font);
+      xlsxCell.setCellStyle(style);
+
+      // Maybe add a comment
+      if (comment != null) {
+        Comment xlsxComment = drawing.createCellComment(anchor);
+        xlsxComment.setString(factory.createRichTextString(comment));
+        xlsxCell.setCellComment(xlsxComment);
+      }
+
+      cellIdx++;
+    }
+  }
+
   /**
    * Get the Cell sort value of a column.
    *
@@ -108,4 +192,36 @@ public class Row {
     sb.append("\t</tr>\n");
     return sb.toString();
   }
+
+  /**
+   * Render the Row as a JsonObject
+   *
+   * @param columns list of Columns
+   * @return JsonObject representation of the row
+   */
+  public JsonObject toJSON(List<Column> columns) {
+    JsonObject row = new JsonObject();
+    for (Column c : columns) {
+      String columnName = c.getDisplayName();
+      Cell cell = cells.getOrDefault(columnName, null);
+      if (cell != null) {
+        List<String> values = cell.getDisplayValues();
+        if (singles.contains(columnName.toUpperCase())) {
+          if (values.size() == 1) {
+            // Add single value
+            row.addProperty(columnName, values.get(0));
+          }
+        } else {
+          if (!values.isEmpty()) {
+            // Render everything else as an array
+            JsonArray valArray = new JsonArray();
+            // Add each value to array
+            values.forEach(valArray::add);
+            row.add(columnName, valArray);
+          }
+        }
+      }
+    }
+    return row;
+  }
 }
diff --git a/robot-core/src/main/java/org/obolibrary/robot/export/Table.java b/robot-core/src/main/java/org/obolibrary/robot/export/Table.java
index 11a40de9..96595430 100644
--- a/robot-core/src/main/java/org/obolibrary/robot/export/Table.java
+++ b/robot-core/src/main/java/org/obolibrary/robot/export/Table.java
@@ -1,6 +1,13 @@
 package org.obolibrary.robot.export;
 
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
 import java.util.*;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 
 /** @author <a href="mailto@rbca.jackson@gmail.com">Becky Jackson</a> */
 public class Table {
@@ -58,6 +65,41 @@ public class Table {
     rows.add(row);
   }
 
+  /**
+   * Render the Table as a Workbook.
+   *
+   * @param split character to split multiple cell values on
+   * @return Workbook
+   */
+  public Workbook asWorkbook(String split) {
+    Workbook wb = new XSSFWorkbook();
+    wb.createSheet();
+
+    // Add headers
+    Sheet sheet = wb.getSheetAt(0);
+    org.apache.poi.ss.usermodel.Row headerRow = sheet.createRow(0);
+    int colIdx = 0;
+    for (Column c : columns) {
+      String name = c.getDisplayName();
+      Cell xlsxCell = headerRow.createCell(colIdx);
+      xlsxCell.setCellValue(name);
+      colIdx++;
+    }
+
+    // Add rows
+    for (Row row : rows) {
+      row.addToWorkbook(wb, columns, split);
+    }
+
+    // Set auto sizing
+    // TODO - this takes up, relatively, a lot of time.
+    /* for (int idx = 0; idx < columns.size(); idx++) {
+      sheet.autoSizeColumn(idx);
+    } */
+
+    return wb;
+  }
+
   /**
    * Get the type of display renderer
    *
@@ -191,4 +233,19 @@ public class Table {
     sb.append("</body>");
     return sb.toString();
   }
+
+  /**
+   * Render the Table as a JSON string.
+   *
+   * @return JSON string
+   */
+  public String toJSON() {
+    JsonArray table = new JsonArray();
+    for (Row row : rows) {
+      table.add(row.toJSON(columns));
+    }
+
+    Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
+    return gson.toJson(table);
+  }
 }


Test patch:
diff --git a/robot-core/src/test/java/org/obolibrary/robot/ExportOperationTest.java b/robot-core/src/test/java/org/obolibrary/robot/ExportOperationTest.java
new file mode 100644
index 00000000..a4c42442
--- /dev/null
+++ b/robot-core/src/test/java/org/obolibrary/robot/ExportOperationTest.java
@@ -0,0 +1,54 @@
+package org.obolibrary.robot;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.junit.Test;
+import org.obolibrary.robot.export.Table;
+import org.semanticweb.owlapi.model.OWLOntology;
+
+/**
+ * Tests ExportOperation.
+ *
+ * @author <a href="mailto:rbca.jackson@gmail.com">Becky Jackson</a>
+ */
+public class ExportOperationTest extends CoreTest {
+
+  /**
+   * Test exporting simple.owl to XLSX.
+   *
+   * @throws Exception on any problem
+   */
+  @Test
+  public void testExportToXLSX() throws Exception {
+    OWLOntology ontology = loadOntology("/simple.owl");
+    IOHelper ioHelper = new IOHelper();
+    List<String> columns = Arrays.asList("ID", "SubClass Of [ID]");
+    Map<String, String> options = ExportOperation.getDefaultOptions();
+
+    // Create the table and render it as a Workbook
+    Table t = ExportOperation.createExportTable(ontology, ioHelper, columns, options);
+    Workbook wb = t.asWorkbook("|");
+
+    Sheet s = wb.getSheetAt(0);
+    // There should be three rows (0, 1, 2)
+    assert (s.getLastRowNum() == 2);
+
+    // Validate header
+    org.apache.poi.ss.usermodel.Row header = s.getRow(0);
+    String v1 = header.getCell(0).getStringCellValue();
+    assert (v1.equals("ID"));
+    String v2 = header.getCell(1).getStringCellValue();
+    assert (v2.equals("SubClass Of [ID]"));
+
+    // Validate last row
+    Row r2 = s.getRow(2);
+    v1 = r2.getCell(0).getStringCellValue();
+    assert (v1.equals("simple:test2"));
+    v2 = r2.getCell(1).getStringCellValue();
+    assert (v2.equals("simple:test1"));
+  }
+}