Files
office-tools/tests/office/odf/models.test.ts
T
zemion 08b8f84f7b
Verify / verify (push) Canceled after 0s
Release Office Tools 0.2.0
2026-09-02 07:32:31 +02:00

308 lines
12 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
collectOdfTransferables,
parseOdp,
parseOds,
parseOdt,
parseOpenDocument,
} from "../../../src/office/odf";
import { makeOdf, odpContent, odsContent, odtContent, XMLNS } from "./fixtures";
const PIXEL = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
describe("OpenDocument normalized models", () => {
it("parses ODT metadata, styles, rich text, lists, tables, images, and notes", () => {
const content = odtContent(`
<text:h text:outline-level="2">A heading</text:h>
<text:p text:style-name="Body">Hello<text:s text:c="2"/><text:span text:style-name="InlineBold">world</text:span><text:tab/>!
<text:note text:id="note-1" text:note-class="footnote">
<text:note-citation>1</text:note-citation>
<text:note-body><text:p>Footnote body</text:p></text:note-body>
</text:note>
</text:p>
<text:list text:style-name="Bullets">
<text:list-item><text:p>First</text:p></text:list-item>
<text:list-item><text:p>Second</text:p></text:list-item>
</text:list>
<table:table table:name="Data"><table:table-row table:number-rows-repeated="2">
<table:table-cell table:number-columns-spanned="2"><text:p>Cell</text:p></table:table-cell>
<table:covered-table-cell/>
</table:table-row></table:table>
<draw:frame draw:style-name="Graphic" svg:x="1cm" svg:y="2cm" svg:width="3cm" svg:height="4cm">
<draw:image xlink:href="Pictures/pixel.png"/>
<svg:title>Pixel</svg:title><svg:desc>A tiny pixel</svg:desc>
</draw:frame>`);
const meta = `<?xml version="1.0"?><office:document-meta ${XMLNS}>
<office:meta><dc:title>Test document</dc:title><dc:creator>Ada</dc:creator>
<meta:keyword>local</meta:keyword><meta:keyword>office</meta:keyword>
<meta:document-statistic meta:word-count="8" meta:page-count="1"/>
</office:meta></office:document-meta>`;
const styles = `<?xml version="1.0"?><office:document-styles ${XMLNS}><office:styles>
<style:style style:name="Body" style:family="paragraph" style:display-name="Body text">
<style:paragraph-properties fo:text-align="start"/>
</style:style></office:styles></office:document-styles>`;
const result = parseOdt(
makeOdf("odt", content, {
extra: { "Pictures/pixel.png": PIXEL },
meta,
styles,
}),
{ fileName: "example.odt" },
);
expect(result.format).toBe("odt");
expect(result.metadata).toMatchObject({
title: "Test document",
creator: "Ada",
keywords: ["local", "office"],
statistics: { wordCount: 8, pageCount: 1 },
});
expect(result.styles).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "Body",
family: "paragraph",
properties: { "paragraph-properties.fo:text-align": "start" },
}),
expect.objectContaining({ name: "InlineBold", family: "text" }),
]),
);
expect(result.blocks[0]).toMatchObject({
kind: "heading",
level: 2,
text: "A heading",
});
expect(result.blocks[1]).toMatchObject({
kind: "paragraph",
text: expect.stringContaining("Hello world\t!"),
runs: expect.arrayContaining([
expect.objectContaining({ text: "world", styleName: "InlineBold" }),
]),
});
expect(result.blocks[2]).toMatchObject({ kind: "list", items: [{}, {}] });
expect(result.blocks[3]).toMatchObject({
kind: "table",
name: "Data",
rows: [{ rowRepeat: 2, cells: [{ columnSpan: 2 }, { covered: true }] }],
});
expect(result.blocks[4]).toMatchObject({
kind: "image",
assetId: "asset-1",
alt: "A tiny pixel",
title: "Pixel",
width: { value: 3, unit: "cm" },
});
expect(result.notes).toHaveLength(1);
expect(result.notes[0]).toMatchObject({ id: "note-1", citation: "1" });
expect(result.assets[0]).toMatchObject({
id: "asset-1",
path: "Pictures/pixel.png",
mediaType: "image/png",
byteLength: PIXEL.byteLength,
});
expect(new Uint8Array(result.assets[0]!.data)).toEqual(PIXEL);
expect(collectOdfTransferables(result)).toEqual([result.assets[0]!.data]);
});
it("parses ODS sheets, typed values, formulas, repeats, merges, and annotations", () => {
const content = odsContent(`
<table:table table:name="Budget" table:style-name="SheetStyle" table:protected="true">
<table:table-row table:number-rows-repeated="2" table:style-name="RowStyle">
<table:table-cell office:value-type="string" office:string-value="Name"><text:p>Name</text:p></table:table-cell>
<table:table-cell table:number-columns-repeated="2" office:value-type="float" office:value="42.5"
table:formula="of:=SUM([.B1:.C1])"><text:p>42.50</text:p>
<office:annotation><text:p>Cached value only</text:p></office:annotation>
</table:table-cell>
<table:table-cell table:number-columns-spanned="2" table:number-rows-spanned="2" office:value-type="boolean" office:boolean-value="true"><text:p>TRUE</text:p></table:table-cell>
<table:covered-table-cell/>
</table:table-row>
</table:table>`);
const result = parseOds(makeOdf("ods", content));
const sheet = result.sheets[0]!;
expect(sheet).toMatchObject({
name: "Budget",
styleName: "SheetStyle",
protected: true,
expandedRowCount: 2,
expandedCellCount: 10,
});
expect(sheet.rows[0]).toMatchObject({
index: 0,
rowRepeat: 2,
styleName: "RowStyle",
});
expect(sheet.rows[0]!.cells).toEqual(
expect.arrayContaining([
expect.objectContaining({ column: 0, value: "Name", display: "Name" }),
expect.objectContaining({
column: 1,
columnRepeat: 2,
value: 42.5,
formula: "of:=SUM([.B1:.C1])",
display: "42.50",
annotation: [
expect.objectContaining({
kind: "paragraph",
text: "Cached value only",
}),
],
}),
expect.objectContaining({
column: 3,
columnSpan: 2,
rowSpan: 2,
value: true,
}),
expect.objectContaining({ column: 4, kind: "covered" }),
]),
);
});
it("trims office-suite full-grid tail repeats without expanding them", () => {
const content = odsContent(`
<table:table table:name="Used range">
<table:table-row>
<table:table-cell office:value-type="string"><text:p>Only value</text:p></table:table-cell>
<table:table-cell table:number-columns-repeated="16383"/>
</table:table-row>
<table:table-row table:number-rows-repeated="1048575">
<table:table-cell table:number-columns-repeated="16384"/>
</table:table-row>
</table:table>`);
const sheet = parseOds(makeOdf("ods", content)).sheets[0]!;
expect(sheet.expandedRowCount).toBe(1);
expect(sheet.expandedCellCount).toBe(1);
expect(sheet.rows).toHaveLength(1);
expect(sheet.rows[0]!.cells).toHaveLength(1);
});
it("parses ODP slides, coordinates, text, shapes, images, tables, groups, and notes", () => {
const content = odpContent(`
<draw:page draw:name="Opening" draw:style-name="SlideStyle" draw:master-page-name="Master"
presentation:presentation-page-layout-name="TitleLayout">
<draw:frame draw:id="title" draw:name="Title" svg:x="1cm" svg:y="2cm" svg:width="20cm" svg:height="3cm">
<draw:text-box><text:p>Local Office</text:p></draw:text-box>
</draw:frame>
<draw:rect draw:name="Accent" svg:x="2cm" svg:y="6cm" svg:width="5cm" svg:height="2cm"><text:p>Box</text:p></draw:rect>
<draw:frame draw:id="picture" svg:x="8cm" svg:y="6cm" svg:width="4cm" svg:height="4cm">
<draw:image xlink:href="Pictures/pixel.png"/>
</draw:frame>
<draw:frame draw:id="grid" svg:x="1cm" svg:y="11cm" svg:width="10cm" svg:height="4cm">
<table:table table:name="Slide table"><table:table-row><table:table-cell><text:p>A1</text:p></table:table-cell></table:table-row></table:table>
</draw:frame>
<draw:g draw:id="group"><draw:ellipse draw:id="circle" svg:x="1cm" svg:y="1cm" svg:width="1cm" svg:height="1cm"/></draw:g>
<presentation:notes><text:p>Speaker note</text:p></presentation:notes>
</draw:page>`);
const result = parseOdp(
makeOdf("odp", content, { extra: { "Pictures/pixel.png": PIXEL } }),
);
const slide = result.slides[0]!;
expect(slide).toMatchObject({
name: "Opening",
styleName: "SlideStyle",
masterPageName: "Master",
layoutName: "TitleLayout",
notes: [
expect.objectContaining({ kind: "paragraph", text: "Speaker note" }),
],
});
expect(slide.shapes).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "title",
type: "text-box",
x: { value: 1, unit: "cm", raw: "1cm" },
blocks: [
expect.objectContaining({
kind: "paragraph",
text: "Local Office",
}),
],
}),
expect.objectContaining({ type: "rectangle", name: "Accent" }),
expect.objectContaining({
id: "picture",
type: "image",
image: expect.objectContaining({ assetId: "asset-1" }),
}),
expect.objectContaining({
id: "grid",
type: "table",
table: expect.objectContaining({ name: "Slide table" }),
}),
expect.objectContaining({
id: "group",
type: "group",
children: [
expect.objectContaining({ id: "circle", type: "ellipse" }),
],
}),
]),
);
});
it("auto-detects formats without parsing the package twice and warns on a missing manifest", () => {
const document = parseOpenDocument(
makeOdf("odt", odtContent("<text:p>Minimal</text:p>"), {
manifest: false,
}),
);
expect(document.format).toBe("odt");
expect(document.warnings).toContain("Package has no META-INF/manifest.xml");
});
it("retains page geometry, default styles, comments and spreadsheet column metadata", () => {
const styles = `<?xml version="1.0"?><office:document-styles ${XMLNS}>
<office:styles><style:default-style style:family="paragraph"><style:text-properties fo:color="#123456"/></style:default-style></office:styles>
<office:automatic-styles><style:page-layout style:name="pm1"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm"/></style:page-layout></office:automatic-styles>
</office:document-styles>`;
const odt = parseOdt(
makeOdf(
"odt",
odtContent(
`<text:p>Reviewed<office:annotation office:name="c1"><dc:creator>Ada</dc:creator><dc:date>2026-09-01</dc:date><text:p>Looks good</text:p></office:annotation></text:p>`,
),
{ styles },
),
);
expect(odt.pageWidth).toMatchObject({ value: 21, unit: "cm" });
expect(odt.styles).toEqual(
expect.arrayContaining([
expect.objectContaining({ isDefault: true, family: "paragraph" }),
]),
);
expect(odt.annotations[0]).toMatchObject({
id: "c1",
creator: "Ada",
createdAt: "2026-09-01",
blocks: [expect.objectContaining({ text: "Looks good" })],
});
const ods = parseOds(
makeOdf(
"ods",
odsContent(`<table:table table:name="Columns" table:visibility="collapse">
<table:table-column table:number-columns-repeated="3" table:style-name="Wide" table:default-cell-style-name="Money"/>
<table:table-row table:default-cell-style-name="RowDefault"><table:table-cell office:value-type="string"><text:p>x</text:p></table:table-cell></table:table-row>
</table:table>`),
),
);
expect(ods.sheets[0]).toMatchObject({
visibility: "collapse",
columns: [
{
index: 0,
repeat: 3,
styleName: "Wide",
defaultCellStyleName: "Money",
},
],
rows: [expect.objectContaining({ defaultCellStyleName: "RowDefault" })],
});
});
});