SPARQLResultsODSWriter.java
/*******************************************************************************
* Copyright (c) 2025 Eclipse RDF4J contributors.
*
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Distribution License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/org/documents/edl-v10.php.
*
* SPDX-License-Identifier: BSD-3-Clause
*******************************************************************************/
package org.eclipse.rdf4j.query.resultio.sparqlods;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.eclipse.rdf4j.common.xml.XMLWriter;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.base.CoreDatatype;
import org.eclipse.rdf4j.model.base.CoreDatatype.XSD;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.query.Binding;
import org.eclipse.rdf4j.query.BindingSet;
import org.eclipse.rdf4j.query.QueryResultHandlerException;
import org.eclipse.rdf4j.query.TupleQueryResultHandlerException;
import org.eclipse.rdf4j.query.impl.MapBindingSet;
import org.eclipse.rdf4j.query.resultio.QueryResultFormat;
import org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat;
import org.eclipse.rdf4j.query.resultio.TupleQueryResultWriter;
import org.eclipse.rdf4j.rio.RioSetting;
import org.eclipse.rdf4j.rio.WriterConfig;
// Assume TupleQueryResultFormat.ODS exists or is defined elsewhere
// import static org.eclipse.rdf4j.query.resultio.TupleQueryResultFormat.ODS;
/**
* Render a SPARQL result set into an ODF spreadsheet file (.ods) by manually generating the XML content and ZIP
* structure.
*
* NOTE: This implementation manually creates XML and does not use any ODF library. It is more complex and potentially
* less robust than using a dedicated library. Auto-sizing columns is not implemented as it's typically handled by the
* viewing application.
*
* @author Adapted from SPARQLResultsXLSXWriter by Jerven Bolleman
*/
public class SPARQLResultsODSWriter implements TupleQueryResultWriter {
private final ZipOutputStream zos;
// Replace PrintWriter with XMLWriter
private XMLWriter contentXmlWriter;
private final Map<String, Integer> columnIndexes = new HashMap<>();
private final Map<String, String> prefixes = new HashMap<>();
private int columnCount = 0;
private boolean headerWritten = false;
// ODF requires specific date/time format
private static final DateTimeFormatter ODF_DATETIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final DateTimeFormatter ODF_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
// Style names (must match definitions in styles.xml)
private static final String STYLE_DEFAULT = "DefaultStyle";
private static final String STYLE_HEADER = "HeaderStyle";
private static final String STYLE_IRI = "IriStyle";
private static final String STYLE_ANY_IRI = "AnyIriStyle"; // Differentiate if needed
private static final String STYLE_NUMERIC = "NumericStyle";
private static final String STYLE_DATE = "DateStyle";
private static final String STYLE_DATETIME = "DateTimeStyle";
private static final String STYLE_BOOLEAN = "BooleanStyle";
// ODS Namespaces
private static final String OFFICE_NS = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
private static final String TABLE_NS = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
private static final String TEXT_NS = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
private static final String STYLE_NS = "urn:oasis:names:tc:opendocument:xmlns:style:1.0";
private static final String FO_NS = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0";
private static final String XLINK_NS = "http://www.w3.org/1999/xlink";
private static final String DC_NS = "http://purl.org/dc/elements/1.1/";
private static final String META_NS = "urn:oasis:names:tc:opendocument:xmlns:meta:1.0";
private static final String NUMBER_NS = "urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0";
private static final String SVG_NS = "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0";
private static final String MANIFEST_NS = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
// ODS Prefixes
private static final String OFFICE_PRE = "office";
private static final String TABLE_PRE = "table";
private static final String TEXT_PRE = "text";
private static final String STYLE_PRE = "style";
private static final String FO_PRE = "fo";
private static final String XLINK_PRE = "xlink";
private static final String DC_PRE = "dc";
private static final String META_PRE = "meta";
private static final String NUMBER_PRE = "datastyle";
private static final String SVG_PRE = "svg";
private static final String MANIFEST_PRE = "manifest";
public SPARQLResultsODSWriter(OutputStream out) {
this.zos = new ZipOutputStream(out);
}
// --- Core TupleQueryResultWriter Methods ---
@Override
public void startDocument() throws QueryResultHandlerException {
try {
// 1. Write mimetype (must be first and uncompressed)
ZipEntry mimetypeEntry = new ZipEntry("mimetype");
mimetypeEntry.setMethod(ZipEntry.STORED);
byte[] mimetype = "application/vnd.oasis.opendocument.spreadsheet".getBytes(StandardCharsets.US_ASCII);
mimetypeEntry.setSize(mimetype.length); // Length of the mimetype string
mimetypeEntry.setCompressedSize(mimetype.length);
// // CRC-32 for "application/vnd.oasis.opendocument.spreadsheet" is 0xadc46ac
// mimetypeEntry.setCrc(0xadc46acL);
mimetypeEntry.setCrc(0x8a396c85L);
zos.putNextEntry(mimetypeEntry);
zos.write(mimetype);
zos.closeEntry();
zos.setMethod(ZipEntry.DEFLATED); // Use compression for subsequent entries
// 2. Prepare XMLWriters for main XML files
// styles.xml
zos.putNextEntry(new ZipEntry("styles.xml"));
// Instantiate XMLWriter
XMLWriter stylesXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
stylesXmlWriter.setPrettyPrint(true); // Enable indentation for readability
writeStylesXml(stylesXmlWriter); // Write boilerplate and style definitions using XMLWriter
stylesXmlWriter.endDocument();
// stylesXmlWriter.close(); // Don't close underlying stream
zos.closeEntry(); // Close the styles.xml entry
// --- Write meta.xml ---
zos.putNextEntry(new ZipEntry("meta.xml"));
// Use try-with-resources for the intermediate XMLWriter
XMLWriter metaXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
metaXmlWriter.setPrettyPrint(true);
writeMetaXml(metaXmlWriter); // Use XMLWriter methods
metaXmlWriter.endDocument();
// Don't close the underlying stream
zos.closeEntry(); // close meta
// --- Write META-INF/manifest.xml ---
zos.putNextEntry(new ZipEntry("META-INF/manifest.xml"));
XMLWriter manifestXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
manifestXmlWriter.setPrettyPrint(true);
writeManifestXml(manifestXmlWriter); // Use XMLWriter methods
manifestXmlWriter.endDocument();
// Don't close the underlying stream
zos.closeEntry();
// content.xml
zos.putNextEntry(new ZipEntry("content.xml"));
// Instantiate XMLWriter
contentXmlWriter = new XMLWriter(new OutputStreamWriter(zos, StandardCharsets.UTF_8));
contentXmlWriter.setPrettyPrint(true); // Enable indentation
writeContentXmlStart(); // Write boilerplate up to <office:spreadsheet> using XMLWriter
} catch (IOException e) {
throw new QueryResultHandlerException("Failed to initialize ODF document structure", e);
}
}
@Override
public void handleNamespace(String prefix, String uri) throws QueryResultHandlerException {
prefixes.put(uri, prefix);
}
@Override
public void startQueryResult(List<String> bindingNames) throws TupleQueryResultHandlerException {
this.columnCount = bindingNames.size();
int columnIndex = 0;
columnIndexes.clear(); // Reset for potential multiple results
for (String bindingName : bindingNames) {
columnIndexes.put(bindingName, columnIndex++);
}
if (contentXmlWriter == null) {
throw new TupleQueryResultHandlerException("startQueryResult called before startDocument");
}
// Write table structures only once
if (!headerWritten) {
try {
// Write Table Definitions in content.xml for "nice" sheet
// Note: Skipping the separate "raw" sheet for simplicity in this refactor
writeTableStart(contentXmlWriter, "QueryResult", columnCount); // Use a descriptive name
writeHeaderRow(contentXmlWriter, bindingNames, STYLE_HEADER);
// Keep the table open for data rows
headerWritten = true;
} catch (IOException e) {
throw new TupleQueryResultHandlerException("Failed to write table start/header", e);
}
} else {
throw new TupleQueryResultHandlerException(
"startQueryResult called more than once. ODF writer handles only one result set.");
}
}
@Override
public void handleSolution(BindingSet bindingSet) throws TupleQueryResultHandlerException {
if (!headerWritten || contentXmlWriter == null) {
throw new TupleQueryResultHandlerException(
"handleSolution called before startQueryResult or after endDocument");
}
try {
startTag(contentXmlWriter, TABLE_PRE, "table-row");
// contentXmlWriter.attribute(TABLE_NS, "style-name", "ro1"); // Optional:
// Assuming default row style
Value[] values = new Value[columnCount]; // To hold values in correct column order
for (Binding binding : bindingSet) {
int colIdx = columnIndexes.getOrDefault(binding.getName(), -1);
if (colIdx != -1) {
values[colIdx] = binding.getValue();
}
}
// Iterate through columns to ensure correct order and handle unbound variables
for (int i = 0; i < columnCount; i++) {
Value v = values[i];
if (v == null) {
// Write empty cell for unbound variable
emptyTag(contentXmlWriter, TABLE_PRE, "table-cell");
} else {
// Write formatted cell based on value type
writeCell(contentXmlWriter, v);
}
}
endTag(contentXmlWriter, TABLE_PRE, "table-row");
} catch (IOException e) {
throw new TupleQueryResultHandlerException("Failed to write data row", e);
}
}
private void emptyTag(XMLWriter writer, String prefix, String element) throws IOException {
writer.startTag(prefix + ':' + element);
writer.endTag(prefix + ':' + element);
}
@Override
public void endQueryResult() throws TupleQueryResultHandlerException {
if (contentXmlWriter == null)
return; // Nothing was started
// Close the table if it was opened
if (headerWritten) {
try {
writeTableEnd(contentXmlWriter); // Close the last opened table
} catch (IOException e) {
throw new TupleQueryResultHandlerException("Failed to write table end", e);
}
}
endDocument();
}
private void endDocument() throws QueryResultHandlerException {
try {
// --- Finish content.xml ---
if (contentXmlWriter != null) {
writeContentXmlEnd(contentXmlWriter); // Write closing tags
contentXmlWriter.endDocument(); // Finalize XML document
// XMLWriter wraps an OutputStreamWriter which shouldn't be closed directly
// here,
// as closing the ZipEntry handles the underlying stream flushing.
// contentXmlWriter.close(); // Don't close underlying stream
zos.closeEntry(); // Close the content.xml entry
contentXmlWriter = null; // Mark as finished
}
// --- Finish the ZIP archive ---
zos.finish();
zos.close(); // Close the main ZipOutputStream
} catch (IOException e) {
throw new QueryResultHandlerException("Failed to finalize or write ODF document components", e);
}
}
// --- Helper Methods for ODS XML Generation using XMLWriter ---
private void declareNamespaces(XMLWriter writer, String... prefixesAndUris) throws IOException {
for (int i = 0; i < prefixesAndUris.length; i += 2) {
writer.setAttribute("xmlns:" + prefixesAndUris[i], prefixesAndUris[i + 1]);
}
}
private void writeContentXmlStart() throws IOException {
contentXmlWriter.startDocument();
declareNamespaces(contentXmlWriter, OFFICE_PRE, OFFICE_NS, TABLE_PRE, TABLE_NS, TEXT_PRE, TEXT_NS, FO_PRE,
FO_NS, XLINK_PRE, XLINK_NS, DC_PRE, DC_NS, META_PRE, META_NS, NUMBER_PRE, NUMBER_NS, STYLE_PRE,
STYLE_NS, SVG_PRE, SVG_NS);
startTag(contentXmlWriter, OFFICE_PRE, "document-content");
setAttribute(contentXmlWriter, OFFICE_PRE, "version", "1.2");
emptyTag(contentXmlWriter, OFFICE_PRE, "scripts"); // Required
startTag(contentXmlWriter, OFFICE_PRE, "font-face-decls");
{ // font
setAttribute(contentXmlWriter, STYLE_PRE, "name", "Liberation Sans");
setAttribute(contentXmlWriter, SVG_PRE, "font-family", "Liberation Sans");
setAttribute(contentXmlWriter, STYLE_PRE, "font-family-generic", "swiss");
setAttribute(contentXmlWriter, STYLE_PRE, "font-pitch", "variable");
emptyTag(contentXmlWriter, STYLE_PRE, "font-face");
}
endTag(contentXmlWriter, OFFICE_PRE, "font-face-decls"); // office:font-face-decls
startTag(contentXmlWriter, OFFICE_PRE, "automatic-styles");
{
// Define basic column style (co1) and row style (ro1)
setAttribute(contentXmlWriter, STYLE_PRE, "name", "co1");
setAttribute(contentXmlWriter, STYLE_PRE, "family", "table-column");
startTag(contentXmlWriter, STYLE_PRE, "style");
{
setAttribute(contentXmlWriter, FO_PRE, "break-before", "auto");
setAttribute(contentXmlWriter, STYLE_PRE, "column-width", "2.257cm"); // Default width
emptyTag(contentXmlWriter, STYLE_PRE, "table-column-properties");
}
endTag(contentXmlWriter, STYLE_PRE, "style"); // style:style co1
setAttribute(contentXmlWriter, STYLE_PRE, "name", "ro1");
setAttribute(contentXmlWriter, STYLE_PRE, "family", "table-row");
startTag(contentXmlWriter, STYLE_PRE, "style");
{
setAttribute(contentXmlWriter, STYLE_PRE, "row-height", "0.453cm");
setAttribute(contentXmlWriter, FO_PRE, "break-before", "auto");
setAttribute(contentXmlWriter, STYLE_PRE, "use-optimal-row-height", "true");
emptyTag(contentXmlWriter, STYLE_PRE, "table-row-properties");
}
endTag(contentXmlWriter, STYLE_PRE, "style"); // style:style ro1
// Add automatic data styles if needed (e.g., N1, N2 for specific number
// formats)
}
endTag(contentXmlWriter, OFFICE_PRE, "automatic-styles");
startTag(contentXmlWriter, OFFICE_PRE, "body");
startTag(contentXmlWriter, OFFICE_PRE, "spreadsheet");
// Tables will be added here by startQueryResult/handleSolution
}
private void emptyElement(XMLWriter contentXmlWriter, String prefix, String element, String content)
throws IOException {
contentXmlWriter.startTag(prefix + ':' + element);
contentXmlWriter.text(content);
contentXmlWriter.endTag(prefix + ':' + element);
}
private void startTag(XMLWriter writer, String prefix, String element) throws IOException {
writer.startTag(prefix + ':' + element);
}
private void writeContentXmlEnd(XMLWriter writer) throws IOException {
endTag(writer, OFFICE_PRE, "spreadsheet"); // office:spreadsheet
endTag(writer, OFFICE_PRE, "body"); // office:body
endTag(writer, OFFICE_PRE, "document-content"); // office:
}
private void endTag(XMLWriter writer, String officePre, String element) throws IOException {
writer.endTag(officePre + ":" + element);
}
private void writeStylesXml(XMLWriter stylesXmlWriter) throws IOException {
stylesXmlWriter.startDocument();
declareNamespaces(stylesXmlWriter, "office", OFFICE_NS, "style", STYLE_NS, "text", TEXT_NS, "table", TABLE_NS,
// "draw", "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0",
"fo", FO_NS, "xlink", XLINK_NS, "dc", DC_NS, "meta", META_NS, "number", NUMBER_NS, "svg", SVG_NS);
setAttribute(stylesXmlWriter, OFFICE_PRE, "version", "1.2");
startTag(stylesXmlWriter, OFFICE_PRE, "document-styles");
{
{
startTag(stylesXmlWriter, OFFICE_PRE, "font-face-decls");
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Liberation Sans");
setAttribute(stylesXmlWriter, SVG_PRE, "font-family", "'Liberation Sans'");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-family-generic", "swiss");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-pitch", "variable");
emptyTag(stylesXmlWriter, STYLE_PRE, "font-face");
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Liberation Mono");
setAttribute(stylesXmlWriter, SVG_PRE, "font-family", "'Liberation Mono'");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-family-generic", "modern");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-pitch", "fixed");
endTag(stylesXmlWriter, OFFICE_PRE, "font-face-decls"); // office:font-face-decls
}
startTag(stylesXmlWriter, OFFICE_PRE, "styles");
{
// --- Default Cell Style ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DEFAULT);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", "Default"); // Assumes "Default" is
// built-in
// or defined
startTag(stylesXmlWriter, STYLE_PRE, "style");
{
setAttribute(stylesXmlWriter, FO_PRE, "padding", "0.097cm");
setAttribute(stylesXmlWriter, FO_PRE, "border", "0.002cm solid #000000"); // Basic border
emptyTag(stylesXmlWriter, STYLE_PRE, "table-cell-properties");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-name", "Liberation Sans");
setAttribute(stylesXmlWriter, FO_PRE, "font-size", "10pt");
emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
}
endTag(stylesXmlWriter, STYLE_PRE, "style");
// --- Header Style ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_HEADER);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
startTag(stylesXmlWriter, STYLE_PRE, "style");
{
setAttribute(stylesXmlWriter, FO_PRE, "background-color", "#cccccc");
setAttribute(stylesXmlWriter, FO_PRE, "text-align", "center");
setAttribute(stylesXmlWriter, STYLE_PRE, "vertical-align", "middle");
setAttribute(stylesXmlWriter, FO_PRE, "border", "0.002cm solid #000000");
emptyTag(stylesXmlWriter, STYLE_PRE, "table-cell-properties");
setAttribute(stylesXmlWriter, FO_PRE, "font-weight", "bold");
setAttribute(stylesXmlWriter, STYLE_PRE, "font-name", "Liberation Sans");
setAttribute(stylesXmlWriter, FO_PRE, "font-size", "10pt");
emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
}
endTag(stylesXmlWriter, STYLE_PRE, "style"); // style:style HeaderStyle
// --- IRI Hyperlink Style ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_IRI);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
startTag(stylesXmlWriter, STYLE_PRE, "style");
{
setAttribute(stylesXmlWriter, FO_PRE, "color", "#0000ff");
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-style", "solid");
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-width", "auto");
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-color", "font-color"); // Blue, underlined
emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
}
endTag(stylesXmlWriter, STYLE_PRE, "style"); // style:style IriStyle
// --- Any IRI Hyperlink Style ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_ANY_IRI);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
startTag(stylesXmlWriter, STYLE_PRE, "style");
{
setAttribute(stylesXmlWriter, FO_PRE, "color", "#ff00ff"); // Magenta
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-style", "solid");
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-width", "auto");
setAttribute(stylesXmlWriter, STYLE_PRE, "text-underline-color", "font-color");
emptyTag(stylesXmlWriter, STYLE_PRE, "text-properties");
}
endTag(stylesXmlWriter, STYLE_PRE, "style");
// --- Define Number/Date/Bool Data Styles First (referenced by cell styles) ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "N0");
startTag(stylesXmlWriter, NUMBER_PRE, "number-style");
{
setAttribute(stylesXmlWriter, NUMBER_PRE, "min-integer-digits", "1");
setAttribute(stylesXmlWriter, NUMBER_PRE, "decimal-places", "2");// 2 decimal places
setAttribute(stylesXmlWriter, NUMBER_PRE, "grouping", "false");
emptyTag(stylesXmlWriter, NUMBER_PRE, "number");
}
endTag(stylesXmlWriter, NUMBER_PRE, "number-style");
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Ndate");
setAttribute(stylesXmlWriter, NUMBER_PRE, "automatic-order", "true");
startTag(stylesXmlWriter, NUMBER_PRE, "date-style");
{
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "year");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "month");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "day");
}
endTag(stylesXmlWriter, NUMBER_PRE, "date-style");
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Ndatetime");
setAttribute(stylesXmlWriter, NUMBER_PRE, "automatic-order", "true");
startTag(stylesXmlWriter, NUMBER_PRE, "date-style");
{
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "year");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "month");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "day");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", " "); // Separator
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "hours");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "minutes");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyElement(stylesXmlWriter, NUMBER_PRE, "text", "-");
setAttribute(stylesXmlWriter, NUMBER_PRE, "style", "long");
emptyTag(stylesXmlWriter, NUMBER_PRE, "seconds");
}
endTag(stylesXmlWriter, NUMBER_PRE, "date-style");
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Nbool");
emptyTag(stylesXmlWriter, NUMBER_PRE, "boolean-style"); // Displays TRUE/FALSE
// --- Cell styles referencing data styles ---
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_NUMERIC);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "N0");
// Reference N0 number format
emptyTag(stylesXmlWriter, STYLE_PRE, "style");
// Reference Ndate
// date
// format
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DATE);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Ndate");
emptyTag(stylesXmlWriter, STYLE_PRE, "style");
// Reference
// Ndatetime
// date
// format
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_DATETIME);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Ndatetime");
emptyTag(stylesXmlWriter, STYLE_PRE, "style");
// Reference
// Nbool
// boolean
// format
setAttribute(stylesXmlWriter, STYLE_PRE, "name", STYLE_BOOLEAN);
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table-cell");
setAttribute(stylesXmlWriter, STYLE_PRE, "parent-style-name", STYLE_DEFAULT);
setAttribute(stylesXmlWriter, STYLE_PRE, "data-style-name", "Nbool");
emptyTag(stylesXmlWriter, STYLE_PRE, "style");
}
endTag(stylesXmlWriter, OFFICE_PRE, "styles"); // office:styles
startTag(stylesXmlWriter, OFFICE_PRE, "automatic-styles");
// Could define column/row styles here too if needed (ta1 example)
{
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "ta1");
setAttribute(stylesXmlWriter, STYLE_PRE, "family", "table");
setAttribute(stylesXmlWriter, STYLE_PRE, "master-page-name", "Default"); // Link to master page
startTag(stylesXmlWriter, STYLE_PRE, "style");
{
setAttribute(stylesXmlWriter, TABLE_PRE, "display", "true");
setAttribute(stylesXmlWriter, STYLE_PRE, "writing-mode", "lr-tb");
emptyTag(stylesXmlWriter, STYLE_PRE, "table-properties");
}
endTag(stylesXmlWriter, STYLE_PRE, "style");
}
endTag(stylesXmlWriter, OFFICE_PRE, "automatic-styles");
startTag(stylesXmlWriter, OFFICE_PRE, "master-styles"); // Required structure
{
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "Default");
setAttribute(stylesXmlWriter, STYLE_PRE, "page-layout-name", "pm1"); // Needs corresponding page-layout
emptyTag(stylesXmlWriter, STYLE_PRE, "master-page");
// Define the page layout referenced above
setAttribute(stylesXmlWriter, STYLE_PRE, "name", "pm1");
startTag(stylesXmlWriter, STYLE_PRE, "page-layout");
setAttribute(stylesXmlWriter, FO_PRE, "margin", "0.7874in"); // Example margins
setAttribute(stylesXmlWriter, FO_PRE, "page-width", "8.5in");
setAttribute(stylesXmlWriter, FO_PRE, "page-height", "11in");
setAttribute(stylesXmlWriter, STYLE_PRE, "print-orientation", "portrait");
emptyTag(stylesXmlWriter, STYLE_PRE, "page-layout-properties");
// Header/Footer styles would go inside page-layout if used
endTag(stylesXmlWriter, STYLE_PRE, "page-layout");
}
endTag(stylesXmlWriter, OFFICE_PRE, "master-styles"); // Required structure
}
endTag(stylesXmlWriter, OFFICE_PRE, "document-styles"); // office:document-styles
}
private void writeMetaXml(XMLWriter writer) throws IOException {
setAttribute(writer, OFFICE_PRE, "version", "1.2");
writer.startDocument();
declareNamespaces(writer, "office", OFFICE_NS, "meta", META_NS, "dc", DC_NS, "xlink", XLINK_NS);
startTag(writer, OFFICE_PRE, "document-meta");
{
startTag(writer, OFFICE_PRE, "meta");
{
startTag(writer, META_PRE, "generator");
writer.text("Eclipse RDF4J SPARQLResultsODSWriter (ODSWriter)");
endTag(writer, META_PRE, "generator");
}
{
startTag(writer, META_PRE, "creation-date");
writer.text(ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME));
endTag(writer, META_PRE, "creation-date");
}
endTag(writer, OFFICE_PRE, "meta");
}
endTag(writer, OFFICE_PRE, "document-meta");
}
private void writeManifestXml(XMLWriter writer) throws IOException {
writer.startDocument();
writer.setAttribute("xmlns:manifest", MANIFEST_NS);
setAttribute(writer, MANIFEST_PRE, "version", "1.2");
startTag(writer, MANIFEST_PRE, "manifest");
{
setAttribute(writer, MANIFEST_PRE, "full-path", "/");
// Version of the ODF standard for this entry
setAttribute(writer, MANIFEST_PRE, "version", "1.2");
setAttribute(writer, MANIFEST_PRE, "media-type", "application/vnd.oasis.opendocument.spreadsheet");
emptyTag(writer, MANIFEST_PRE, "file-entry");
addFile(writer, "content.xml");
addFile(writer, "styles.xml");
addFile(writer, "meta.xml");
}
endTag(writer, MANIFEST_PRE, "manifest"); // manifest:manifest
}
private void addFile(XMLWriter writer, String f) throws IOException {
setAttribute(writer, MANIFEST_PRE, "full-path", f);
setAttribute(writer, MANIFEST_PRE, "media-type", "text/xml");
emptyTag(writer, MANIFEST_PRE, "file-entry");
}
private void writeTableStart(XMLWriter writer, String name, int columnCount) throws IOException {
setAttribute(writer, TABLE_PRE, "name", name); // Use name provided
setAttribute(writer, TABLE_PRE, "style-name", "ta1"); // Use defined table style
startTag(writer, TABLE_PRE, "table");
// Define columns - using the automatic style 'co1' defined earlier
for (int i = 0; i < columnCount; i++) {
setAttribute(writer, TABLE_PRE, "style-name", "co1");
emptyElement(writer, TABLE_PRE, "table-column", name);
}
}
private void writeTableEnd(XMLWriter writer) throws IOException {
endTag(writer, TABLE_PRE, "table"); // table:table
}
private void writeHeaderRow(XMLWriter writer, List<String> bindingNames, String headerStyleName)
throws IOException {
startTag(writer, TABLE_PRE, "table-row");
{
// writer.attribute(TABLE_NS, "style-name", "ro1"); // Optional: Assume default
// row style
for (String name : bindingNames) {
setAttribute(writer, OFFICE_PRE, "value-type", "string");
setAttribute(writer, TABLE_PRE, "style-name", headerStyleName); // Apply header style
startTag(writer, TABLE_PRE, "table-cell");
{
startTag(writer, TEXT_PRE, "p");
writer.text(name); // XMLWriter handles escaping
endTag(writer, TEXT_PRE, "p");
}
endTag(writer, TABLE_PRE, "table-cell");
}
}
endTag(writer, TABLE_PRE, "table-row"); // table:table-row
}
private void setAttribute(XMLWriter writer, String prefix, String element, String value) {
writer.setAttribute(prefix + ":" + element, value);
}
// Main cell writing logic using XMLWriter
private void writeCell(XMLWriter writer, Value value) throws IOException {
if (value.isLiteral()) {
handleLiteralCell(writer, (Literal) value);
} else if (value.isIRI()) {
handleIriCell(writer, (IRI) value, STYLE_IRI); // Default IRI style
} else if (value.isBNode()) {
writeStringCell(writer, value.stringValue(), STYLE_DEFAULT);
} else if (value.isTriple()) {
writeStringCell(writer, value.stringValue(), STYLE_DEFAULT); // Or a dedicated style?
} else {
writeStringCell(writer, value.stringValue(), STYLE_DEFAULT);
}
}
// --- Cell Type Handling using XMLWriter ---
private void handleLiteralCell(XMLWriter writer, Literal l) throws IOException {
CoreDatatype cd = l.getCoreDatatype();
Optional<String> lang = l.getLanguage();
if (cd != null && cd.isXSDDatatype()) {
handleXsdLiteral(writer, l, cd.asXSDDatatypeOrNull());
} else if (lang.isPresent()) {
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT); // Add lang info in comment? maybe later
} else if (cd != null && (cd.isRDFDatatype() || cd.isGEODatatype())) {
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
} else {
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
}
}
private void handleXsdLiteral(XMLWriter writer, Literal l, XSD xsdType) throws IOException {
if (xsdType == null) {
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
return;
}
try {
switch (xsdType) {
case BOOLEAN:
writeBooleanCell(writer, l.booleanValue(), STYLE_BOOLEAN);
break;
case DECIMAL:
case INTEGER:
case NEGATIVE_INTEGER:
case NON_NEGATIVE_INTEGER:
case POSITIVE_INTEGER:
case NON_POSITIVE_INTEGER:
case LONG:
case INT:
case SHORT:
case BYTE:
case UNSIGNED_LONG:
case UNSIGNED_INT:
case UNSIGNED_SHORT:
case UNSIGNED_BYTE:
try {
BigDecimal decVal = l.decimalValue();
// Pass original label for display, double value for storage
writeNumericCell(writer, decVal.doubleValue(), l.getLabel(), STYLE_NUMERIC);
} catch (NumberFormatException nfe) {
writeStringCell(writer, l.getLabel(), STYLE_NUMERIC); // Fallback
}
break;
case DOUBLE:
writeNumericCell(writer, l.doubleValue(), l.getLabel(), STYLE_NUMERIC);
break;
case FLOAT:
writeNumericCell(writer, l.floatValue(), l.getLabel(), STYLE_NUMERIC);
break;
case DATETIME:
case DATETIMESTAMP:
// Use LocalDateTime (no timezone info in ODF value attribute)
writeDateTimeCell(writer, l.calendarValue().toGregorianCalendar().toZonedDateTime().toLocalDateTime(),
STYLE_DATETIME);
break;
case DATE:
writeDateCell(writer, l.calendarValue().toGregorianCalendar().toZonedDateTime().toLocalDate(),
STYLE_DATE);
break;
case TIME:
case GYEAR:
case GMONTH:
case GDAY:
case GYEARMONTH:
case GMONTHDAY:
case DURATION:
case YEARMONTHDURATION:
case DAYTIMEDURATION:
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
break;
case ANYURI:
try {
handleIriCell(writer, SimpleValueFactory.getInstance().createIRI(l.getLabel()), STYLE_ANY_IRI);
} catch (IllegalArgumentException e) {
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
}
break;
default:
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT);
break;
}
} catch (Exception e) {
System.err.println("Warn: Error converting literal '" + l.stringValue() + "' type " + xsdType
+ ". Writing as string. Error: " + e.getMessage());
writeStringCell(writer, l.getLabel(), STYLE_DEFAULT); // Fallback
}
}
private void handleIriCell(XMLWriter writer, IRI iri, String styleName) throws IOException {
String displayString = formatIri(iri);
String url = iri.stringValue();
setAttribute(writer, OFFICE_PRE, "value-type", "string"); // Hyperlinks are fundamentally text cells
setAttribute(writer, TABLE_PRE, "style-name", styleName);
startTag(writer, TABLE_PRE, "table-cell");
{
startTag(writer, TEXT_PRE, "p");
{
// Add hyperlink using text:a
setAttribute(writer, XLINK_PRE, "type", "simple");
setAttribute(writer, XLINK_PRE, "href", url); // XMLWriter handles attribute escaping
startTag(writer, TEXT_PRE, "a");
writer.text(displayString); // XMLWriter handles text escaping
endTag(writer, TEXT_PRE, "a");
}
endTag(writer, TEXT_PRE, "p");
}
endTag(writer, TABLE_PRE, "table-cell");
}
private void writeStringCell(XMLWriter writer, String value, String styleName) throws IOException {
setAttribute(writer, OFFICE_PRE, "value-type", "string");
setAttribute(writer, TABLE_PRE, "style-name", styleName);
startTag(writer, TABLE_PRE, "table-cell");
startTag(writer, TEXT_NS, "p");
writer.text(value); // XMLWriter handles escaping
endTag(writer, TEXT_PRE, "p");
endTag(writer, TABLE_PRE, "table-cell");
}
private void writeNumericCell(XMLWriter writer, double value, String displayValue, String styleName)
throws IOException {
setAttribute(writer, OFFICE_PRE, "value-type", "float"); // Use float for numbers
setAttribute(writer, OFFICE_PRE, "value", String.valueOf(value)); // ODF requires string representation of
// number
setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style defines display format
startTag(writer, TABLE_PRE, "table-cell");
{
startTag(writer, TEXT_PRE, "p");
writer.text(displayValue); // Text content shows original or formatted string
endTag(writer, TEXT_PRE, "p");
}
endTag(writer, TABLE_PRE, "table-cell");
}
private void writeBooleanCell(XMLWriter writer, boolean value, String styleName) throws IOException {
setAttribute(writer, OFFICE_PRE, "value-type", "boolean");
setAttribute(writer, OFFICE_PRE, "boolean-value", String.valueOf(value)); // "true" or "false"
setAttribute(writer, TABLE_PRE, "style-name", styleName);
startTag(writer, TABLE_PRE, "table-cell");
{
startTag(writer, TEXT_PRE, "p");
writer.text(value ? "TRUE" : "FALSE"); // Text content for display
endTag(writer, TEXT_PRE, "p");
}
endTag(writer, TABLE_PRE, "table-cell");
}
private void writeDateTimeCell(XMLWriter writer, java.time.LocalDateTime dateTime, String styleName)
throws IOException {
// ODF requires ISO 8601 format YYYY-MM-DDTHH:MM:SS
String isoValue = dateTime.format(ODF_DATETIME_FORMATTER);
setAttribute(writer, OFFICE_PRE, "value-type", "date"); // Type is "date" for both date and datetime
setAttribute(writer, OFFICE_PRE, "date-value", isoValue); // Stores full date+time
setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style controls display
startTag(writer, TABLE_PRE, "table-cell");
startTag(writer, TEXT_PRE, "p");
// Displayed text can be formatted differently by the style,
// but putting the ISO value here ensures something is shown if style fails.
writer.text(isoValue);
endTag(writer, TEXT_PRE, "p");
endTag(writer, TABLE_PRE, "table-cell");
}
private void writeDateCell(XMLWriter writer, java.time.LocalDate date, String styleName) throws IOException {
// ODF requires ISO 8601 format YYYY-MM-DD
String isoDateValue = date.format(ODF_DATE_FORMATTER);
// ODF stores dates internally as datetime at midnight
String isoDateTimeValue = date.atStartOfDay().format(ODF_DATETIME_FORMATTER);
startTag(writer, TABLE_PRE, "table-cell");
setAttribute(writer, OFFICE_PRE, "value-type", "date");
setAttribute(writer, OFFICE_PRE, "date-value", isoDateTimeValue); // Store as full date+time
setAttribute(writer, TABLE_PRE, "style-name", styleName); // Style controls display (shows date part)
startTag(writer, TEXT_PRE, "p");
writer.text(isoDateValue); // Display the date part
endTag(writer, TEXT_PRE, "p");
endTag(writer, TABLE_PRE, "table-cell");
}
// --- Formatting Helpers ---
private String formatIri(IRI iri) {
String iriStr = iri.stringValue();
String namespace = iri.getNamespace();
String localName = iri.getLocalName();
if (prefixes.containsKey(namespace)) {
String prefix = prefixes.get(namespace);
if (!localName.isEmpty() && iriStr.equals(namespace + localName)) { // Check for clean split
return prefix + ":" + localName;
}
}
// If no prefix or local name is weird, return full IRI (or just local name if
// sensible)
// Let's prefer the full IRI for clarity in the spreadsheet unless prefixed
// if (localName != null && !localName.isEmpty() && iriStr.endsWith(localName))
// {
// return localName; // Could use this, but full IRI might be less ambiguous
// }
return iriStr; // Fallback to full IRI string
}
// Remove escapeXml and escapeXmlAttribute methods as XMLWriter handles
// escaping.
// private String escapeXml(String s) { ... }
// private String escapeXmlAttribute(String s) { ... }
// --- Unimplemented/Simplified Methods from Interface ---
@Override
public void handleBoolean(boolean value) throws QueryResultHandlerException {
System.err.println("Warning: handleBoolean (SPARQL ASK result) not implemented for ODF writer.");
startDocument();
MapBindingSet result = new MapBindingSet();
startQueryResult(List.of("result"));
result.setBinding("result", SimpleValueFactory.getInstance().createLiteral(value));
handleSolution(result);
endQueryResult();
endDocument();
}
@Override
public void handleLinks(List<String> linkUrls) throws QueryResultHandlerException {
// Could store these in meta.xml meta:user-defined fields if needed
System.err.println("Warning: handleLinks (document-level links) not implemented for ODF writer.");
}
@Override
public QueryResultFormat getQueryResultFormat() {
return TupleQueryResultFormat.ODS;
}
@Override
public TupleQueryResultFormat getTupleQueryResultFormat() {
return TupleQueryResultFormat.ODS;
}
// Keep other interface methods (setWriterConfig, getWriterConfig, etc.) as they
// were
@Override
public void setWriterConfig(WriterConfig config) {
// Configuration options could be added (e.g., date formats, default styles)
}
@Override
public WriterConfig getWriterConfig() {
return new WriterConfig(); // Return default/empty config
}
@Override
public Collection<RioSetting<?>> getSupportedSettings() {
return Collections.emptyList(); // No specific settings supported yet
}
@Override
public void handleStylesheet(String stylesheetUrl) throws QueryResultHandlerException {
// Not applicable/supported for direct ODF generation
}
@Override
public void startHeader() throws QueryResultHandlerException {
// Handled within startQueryResult for ODF table structure
}
@Override
public void endHeader() throws QueryResultHandlerException {
// Handled within startQueryResult/endQueryResult
}
}