feat(export): build ODS XML documents

Numeric cells carry office:value so the export can be summed and pivoted
in a spreadsheet rather than arriving as text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-09-01 20:05:40 +02:00
co-authored by Claude Opus 5
parent 932b88b189
commit c627051e66
2 changed files with 171 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { test, expect, describe } from "bun:test";
import {
buildContentXml,
buildManifestXml,
buildStylesXml,
escapeXml,
text,
num,
empty,
ODS_MIMETYPE,
} from "./odsXml.ts";
test("the ODS mimetype is the spreadsheet one", () => {
expect(ODS_MIMETYPE).toBe("application/vnd.oasis.opendocument.spreadsheet");
});
describe("escapeXml", () => {
test("escapes the five XML entities", () => {
expect(escapeXml(`<a & b> "c" 'd'`)).toBe(
"&lt;a &amp; b&gt; &quot;c&quot; &apos;d&apos;",
);
});
test("leaves ordinary text alone", () => {
expect(escapeXml("Events and Social")).toBe("Events and Social");
});
});
describe("buildContentXml", () => {
const xml = buildContentXml([
{
name: "Summary",
rows: [
[text("Post"), text("Spent")],
[text("Food"), num(1234.56)],
[text("Empty"), empty()],
],
},
]);
test("declares the spreadsheet document namespaces", () => {
expect(xml).toStartWith('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("office:document-content");
expect(xml).toContain("urn:oasis:names:tc:opendocument:xmlns:office:1.0");
});
test("names the table", () => {
expect(xml).toContain('table:name="Summary"');
});
test("writes numbers as float cells with office:value, not text", () => {
expect(xml).toContain('office:value-type="float" office:value="1234.56"');
expect(xml).not.toContain('office:value-type="string"><text:p>1234.56');
});
test("writes text cells as strings", () => {
expect(xml).toContain('office:value-type="string"><text:p>Food</text:p>');
});
test("writes empty cells with no value type", () => {
expect(xml).toContain("<table:table-cell/>");
});
test("escapes sheet names and cell text", () => {
const nasty = buildContentXml([
{ name: 'Food & "drink"', rows: [[text("<script>")]] },
]);
expect(nasty).toContain('table:name="Food &amp; &quot;drink&quot;"');
expect(nasty).toContain("&lt;script&gt;");
});
test("emits one table per sheet", () => {
const two = buildContentXml([
{ name: "A", rows: [[text("x")]] },
{ name: "B", rows: [[text("y")]] },
]);
expect(two.match(/<table:table /g)).toHaveLength(2);
});
test("handles a sheet with no rows", () => {
expect(buildContentXml([{ name: "Blank", rows: [] }])).toContain('table:name="Blank"');
});
});
test("buildManifestXml lists all four parts", () => {
const manifest = buildManifestXml();
for (const path of ["/", "content.xml", "styles.xml"]) {
expect(manifest).toContain(path);
}
expect(manifest).toContain(ODS_MIMETYPE);
});
test("buildStylesXml is a well-formed styles document", () => {
expect(buildStylesXml()).toContain("office:document-styles");
});
+76
View File
@@ -0,0 +1,76 @@
export const ODS_MIMETYPE = "application/vnd.oasis.opendocument.spreadsheet";
export type Cell =
| { kind: "text"; value: string }
| { kind: "number"; value: number }
| { kind: "empty" };
export function text(value: string): Cell {
return { kind: "text", value };
}
export function num(value: number): Cell {
return { kind: "number", value };
}
export function empty(): Cell {
return { kind: "empty" };
}
export interface Sheet {
name: string;
rows: Cell[][];
}
export function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function renderCell(cell: Cell): string {
switch (cell.kind) {
case "empty":
return "<table:table-cell/>";
case "number":
// office:value is what makes spreadsheets treat this as a number, so the
// export can be summed and pivoted rather than just read.
return `<table:table-cell office:value-type="float" office:value="${cell.value}"><text:p>${cell.value}</text:p></table:table-cell>`;
case "text":
return `<table:table-cell office:value-type="string"><text:p>${escapeXml(cell.value)}</text:p></table:table-cell>`;
}
}
function renderRow(row: Cell[]): string {
return `<table:table-row>${row.map(renderCell).join("")}</table:table-row>`;
}
function renderSheet(sheet: Sheet): string {
return `<table:table table:name="${escapeXml(sheet.name)}">${sheet.rows
.map(renderRow)
.join("")}</table:table>`;
}
const CONTENT_NS = [
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"',
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"',
'office:version="1.3"',
].join(" ");
export function buildContentXml(sheets: Sheet[]): string {
return `<?xml version="1.0" encoding="UTF-8"?><office:document-content ${CONTENT_NS}><office:body><office:spreadsheet>${sheets
.map(renderSheet)
.join("")}</office:spreadsheet></office:body></office:document-content>`;
}
export function buildStylesXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?><office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" office:version="1.3"><office:styles/></office:document-styles>`;
}
export function buildManifestXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3"><manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="${ODS_MIMETYPE}"/><manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/><manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/></manifest:manifest>`;
}