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:
co-authored by
Claude Opus 5
parent
932b88b189
commit
c627051e66
@@ -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(
|
||||
"<a & b> "c" 'd'",
|
||||
);
|
||||
});
|
||||
|
||||
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 & "drink""');
|
||||
expect(nasty).toContain("<script>");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
Reference in New Issue
Block a user