feat: release Office Tools 0.1.0

This commit is contained in:
2026-08-31 22:37:01 +02:00
commit a5524e27e3
90 changed files with 16328 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import { expect, test, type Page } from "@playwright/test";
import {
createMinimalOdp,
createMinimalOds,
createMinimalOdt,
type OdfFixture,
} from "../fixtures/odf";
const entry = "/deep/nested/office/";
async function openFixture(page: Page, fixture: OdfFixture) {
await page.goto(entry);
await page.getByTestId("office-file-input").setInputFiles(fixture);
await expect(page.getByText("Rendered locally", { exact: true })).toBeVisible(
{
timeout: 30_000,
},
);
}
async function find(page: Page, value: string) {
const search = page.getByRole("search");
await search.getByPlaceholder("Find in file").fill(value);
await search.getByRole("button", { name: "Find" }).click();
await expect(search.getByText("1 of 1", { exact: true })).toBeVisible();
}
test("renders and searches a project-authored ODT in an isolated worker", async ({
page,
}) => {
const externalHosts = new Set<string>();
const errors: string[] = [];
const workerScripts: URL[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (
(url.protocol === "http:" || url.protocol === "https:") &&
url.hostname !== "127.0.0.1"
)
externalHosts.add(url.hostname);
if (url.pathname.includes("odf.worker")) workerScripts.push(url);
});
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("pageerror", (error) => errors.push(error.message));
await openFixture(page, createMinimalOdt());
await expect(
page.getByRole("heading", { name: "OpenDocument Text Fixture" }),
).toBeVisible();
await expect(
page.getByRole("table", { name: "Fixture table" }),
).toBeVisible();
await expect(
page.getByRole("img", { name: "Project-authored fixture image" }),
).toBeVisible();
await find(page, "ODT Search Marker");
await page.getByRole("button", { name: "Zoom in" }).click();
await expect(page.getByText("110%", { exact: true })).toBeVisible();
expect(externalHosts).toEqual(new Set());
expect(errors).toEqual([]);
expect(workerScripts.length).toBeGreaterThan(0);
expect(
workerScripts.every(
(url) =>
url.origin === "http://127.0.0.1:4173" &&
url.pathname.startsWith("/deep/nested/office/assets/"),
),
).toBe(true);
});
test("renders, searches and switches sheets in a project-authored ODS", async ({
page,
}) => {
await openFixture(page, createMinimalOds());
await expect(page.getByText("sheet 1 of 2", { exact: true })).toBeVisible();
await expect(page.getByRole("grid")).toContainText("Office Tools Workbook");
await find(page, "ODS Search Marker");
await page.getByRole("button", { name: "Next sheet" }).click();
await expect(page.getByText("sheet 2 of 2", { exact: true })).toBeVisible();
await expect(page.getByRole("grid")).toContainText("Second sheet content");
});
test("renders, searches and navigates a project-authored ODP", async ({
page,
}) => {
await openFixture(page, createMinimalOdp());
await expect(page.getByText("slide 1 of 2", { exact: true })).toBeVisible();
await expect(
page.getByRole("heading", { name: "OpenDocument Presentation Fixture" }),
).toBeVisible();
await find(page, "ODP Search Marker");
await expect(page.getByText("slide 2 of 2", { exact: true })).toBeVisible();
await expect(
page.getByRole("heading", { name: "ODP Search Marker" }),
).toBeVisible();
await expect(
page.getByRole("complementary", { name: "Speaker notes" }),
).toContainText("Second speaker note");
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "@playwright/test";
const entry = "/deep/nested/office/";
test("loads from a nested path without external requests", async ({ page }) => {
const external: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.hostname !== "127.0.0.1") external.push(request.url());
});
await page.goto(entry);
await expect(
page.getByRole("heading", { name: "Open an office file" }),
).toBeVisible();
await expect(page.getByText("No upload")).toBeVisible();
expect(external).toEqual([]);
});
test("publishes valid relocatable metadata", async ({ request }) => {
const manifestResponse = await request.get(`${entry}toolbox-app.json`);
expect(manifestResponse.ok()).toBe(true);
await expect(manifestResponse.json()).resolves.toMatchObject({
schemaVersion: 1,
id: "de.add-ideas.office-tools",
version: "0.1.0",
entry: "./",
icon: "./favicon.svg",
privacy: { processing: "local", fileUploads: false, telemetry: false },
});
const webManifest = await request.get(`${entry}manifest.webmanifest`);
expect(webManifest.ok()).toBe(true);
expect(webManifest.headers()["content-type"]).toContain(
"application/manifest+json",
);
});
+164
View File
@@ -0,0 +1,164 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import {
createMalformedDocx,
createMinimalDocx,
createMinimalPptx,
createMinimalXlsx,
type OoxmlFixture,
} from "../fixtures/ooxml";
const entry = "/deep/nested/office/";
async function openFixture(page: Page, fixture: OoxmlFixture) {
await page.goto(entry);
await page
.getByTestId("office-file-input")
.setInputFiles(
fixture as unknown as Parameters<Locator["setInputFiles"]>[0],
);
}
async function expectRenderedPackage(
page: Page,
format: "docx" | "xlsx" | "pptx",
) {
await expect(page.getByTestId("opened-file")).toBeVisible();
await expect(page.getByText("Rendered locally", { exact: true })).toBeVisible(
{
timeout: 90_000,
},
);
await expect(
page.locator(`.office-render-stage--${format} canvas`).first(),
).toBeVisible();
const canvas = page.locator(`.office-render-stage--${format} canvas`).first();
const dimensions = await canvas.evaluate((element) => {
const rendered = element as HTMLCanvasElement;
const bounds = rendered.getBoundingClientRect();
return {
bitmapHeight: rendered.height,
bitmapWidth: rendered.width,
cssHeight: bounds.height,
cssWidth: bounds.width,
};
});
expect(dimensions.bitmapWidth).toBeGreaterThan(1);
expect(dimensions.bitmapHeight).toBeGreaterThan(1);
expect(dimensions.cssWidth).toBeGreaterThan(1);
expect(dimensions.cssHeight).toBeGreaterThan(1);
}
async function expectOneSearchMatch(page: Page, text: string) {
await page.getByRole("search").getByPlaceholder("Find in file").fill(text);
await page.getByRole("search").getByRole("button", { name: "Find" }).click();
await expect(page.getByText("1 match", { exact: true })).toBeVisible();
}
test("opens, renders, searches and navigates a project-authored DOCX", async ({
page,
}) => {
const externalHosts = new Set<string>();
const runtimeErrors: string[] = [];
const wasmResponses: Array<{
cacheControl: string;
contentType: string;
url: URL;
}> = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.hostname !== "127.0.0.1") externalHosts.add(url.hostname);
});
page.on("response", (response) => {
const url = new URL(response.url());
if (url.pathname.endsWith(".wasm")) {
wasmResponses.push({
cacheControl: response.headers()["cache-control"] ?? "",
contentType: response.headers()["content-type"] ?? "",
url,
});
}
});
page.on("console", (message) => {
if (message.type() === "error") runtimeErrors.push(message.text());
});
page.on("pageerror", (error) => runtimeErrors.push(error.message));
await openFixture(page, createMinimalDocx());
await expectRenderedPackage(page, "docx");
await expect(page.getByText(/page 1 of [2-9]\d*/i)).toBeVisible();
await expectOneSearchMatch(page, "Second Page Search Marker");
await page.getByRole("button", { name: "Next page" }).click();
await expect(page.getByText(/page 2 of [2-9]\d*/i)).toBeVisible();
expect(externalHosts).toEqual(new Set());
expect(wasmResponses.length).toBeGreaterThan(0);
expect(
wasmResponses.every(
({ cacheControl, contentType, url }) =>
url.origin === "http://127.0.0.1:4173" &&
url.pathname.startsWith("/deep/nested/office/assets/") &&
contentType.startsWith("application/wasm") &&
cacheControl.includes("max-age=31536000") &&
cacheControl.includes("immutable"),
),
).toBe(true);
expect(runtimeErrors).toEqual([]);
});
test("opens, renders, searches and switches sheets in a project-authored XLSX", async ({
page,
}) => {
await openFixture(page, createMinimalXlsx());
await expectRenderedPackage(page, "xlsx");
await expect(page.getByText("sheet 1 of 2", { exact: true })).toBeVisible();
await expectOneSearchMatch(page, "Office Tools Workbook Fixture");
await page.getByRole("button", { name: "Next sheet" }).click();
await expect(page.getByText("sheet 2 of 2", { exact: true })).toBeVisible();
});
test("opens, renders, searches and navigates a project-authored PPTX", async ({
page,
}) => {
await openFixture(page, createMinimalPptx());
await expectRenderedPackage(page, "pptx");
await expect(page.getByText("slide 1 of 2", { exact: true })).toBeVisible();
await expectOneSearchMatch(page, "Second Slide Search Marker");
await page.getByRole("button", { name: "Next slide" }).click();
await expect(page.getByText("slide 2 of 2", { exact: true })).toBeVisible();
});
test("fails closed with an inline error for a malformed OOXML package", async ({
page,
}) => {
await openFixture(page, createMalformedDocx());
const alert = page.getByRole("alert");
await expect(alert).toBeVisible({ timeout: 90_000 });
await expect(alert).not.toBeEmpty();
await expect(
page.locator(".file-heading__state .state-dot--error"),
).toBeVisible();
});
test("rejects a valid package whose contents do not match its extension", async ({
page,
}) => {
const workbook = createMinimalXlsx();
await openFixture(page, {
...workbook,
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
name: "workbook-disguised-as-document.docx",
});
const alert = page.getByRole("alert");
await expect(alert).toBeVisible({ timeout: 90_000 });
await expect(alert).not.toBeEmpty();
await expect(
page.locator(".file-heading__state .state-dot--error"),
).toBeVisible();
});
@@ -0,0 +1,37 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { UploadWorkbench } from "../../src/components/UploadWorkbench";
vi.mock("../../src/components/OoxmlViewer", () => ({
OoxmlViewer: () => <section aria-label="Test OOXML viewer" />,
}));
describe("UploadWorkbench", () => {
it("exposes the initial local format families", () => {
render(<UploadWorkbench />);
expect(
screen.getByRole("heading", { name: "Open an office file" }),
).toBeInTheDocument();
for (const format of ["DOCX", "XLSX", "PPTX", "ODT", "ODS", "ODP"])
expect(screen.getByText(format)).toBeInTheDocument();
expect(screen.getByText("No upload")).toBeInTheDocument();
});
it("shows the identity of a locally selected file", async () => {
const user = userEvent.setup();
render(<UploadWorkbench />);
const input = screen.getByLabelText("Open office file");
const file = new File(["local fixture"], "report.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
await user.upload(input, file);
const heading = screen.getByRole("region", { name: "report.docx" });
expect(heading).toHaveTextContent("report.docx");
expect(heading).toHaveTextContent("13 B");
expect(screen.getByLabelText("Test OOXML viewer")).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
import {
makeOdf,
odpContent,
odsContent,
odtContent,
XMLNS,
} from "../office/odf/fixtures";
export interface OdfFixture {
name: string;
mimeType: string;
buffer: NodeBufferLike;
}
interface NodeBufferLike extends Uint8Array {
toString(encoding?: string): string;
}
const nodeBuffer = (
globalThis as unknown as {
Buffer: { from(value: Uint8Array): NodeBufferLike };
}
).Buffer;
function fixture(
name: string,
mimeType: string,
buffer: ArrayBuffer,
): OdfFixture {
return { name, mimeType, buffer: nodeBuffer.from(new Uint8Array(buffer)) };
}
export function createMinimalOdt(): OdfFixture {
const svg = new TextEncoder().encode(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 50"><rect width="80" height="50" rx="6" fill="#24465f"/><circle cx="25" cy="25" r="13" fill="#85c8e8"/></svg>',
);
const metadata = `<?xml version="1.0"?><office:document-meta ${XMLNS}>
<office:meta><dc:title>Project-authored ODT fixture</dc:title><dc:creator>Office Tools</dc:creator></office:meta>
</office:document-meta>`;
const data = makeOdf(
"odt",
odtContent(`
<text:h text:outline-level="1">OpenDocument Text Fixture</text:h>
<text:p>This document is rendered entirely in the browser.</text:p>
<text:p>Find the <text:span text:style-name="InlineBold">ODT Search Marker</text:span>.</text:p>
<text:list><text:list-item><text:p>First local item</text:p></text:list-item><text:list-item><text:p>Second local item</text:p></text:list-item></text:list>
<table:table table:name="Fixture table"><table:table-row><table:table-cell><text:p>Cell A</text:p></table:table-cell><table:table-cell><text:p>Cell B</text:p></table:table-cell></table:table-row></table:table>
<draw:frame svg:width="4cm" svg:height="2.5cm"><draw:image xlink:href="Pictures/fixture.svg"/><svg:desc>Project-authored fixture image</svg:desc></draw:frame>`),
{ extra: { "Pictures/fixture.svg": svg }, meta: metadata },
);
return fixture(
"project-authored.odt",
"application/vnd.oasis.opendocument.text",
data,
);
}
export function createMinimalOds(): OdfFixture {
const data = makeOdf(
"ods",
odsContent(`
<table:table table:name="Overview">
<table:table-row><table:table-cell office:value-type="string"><text:p>Office Tools Workbook</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="42"><text:p>42</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell office:value-type="string"><text:p>ODS Search Marker</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="84" table:formula="of:=[.B1]*2"><text:p>84</text:p></table:table-cell></table:table-row>
</table:table>
<table:table table:name="Details"><table:table-row><table:table-cell office:value-type="string"><text:p>Second sheet content</text:p></table:table-cell></table:table-row></table:table>`),
);
return fixture(
"project-authored.ods",
"application/vnd.oasis.opendocument.spreadsheet",
data,
);
}
export function createMinimalOdp(): OdfFixture {
const data = makeOdf(
"odp",
odpContent(`
<draw:page draw:name="Opening">
<draw:frame draw:id="title-1" svg:x="1cm" svg:y="1cm" svg:width="20cm" svg:height="3cm"><draw:text-box><text:h text:outline-level="1">OpenDocument Presentation Fixture</text:h></draw:text-box></draw:frame>
<draw:rect draw:id="body-1" svg:x="2cm" svg:y="6cm" svg:width="15cm" svg:height="4cm"><text:p>First slide body</text:p></draw:rect>
<presentation:notes><text:p>Opening speaker note</text:p></presentation:notes>
</draw:page>
<draw:page draw:name="Search slide">
<draw:frame draw:id="title-2" svg:x="1cm" svg:y="1cm" svg:width="20cm" svg:height="3cm"><draw:text-box><text:h text:outline-level="1">ODP Search Marker</text:h></draw:text-box></draw:frame>
<draw:ellipse draw:id="circle-2" svg:x="4cm" svg:y="6cm" svg:width="5cm" svg:height="5cm"><text:p>Second slide shape</text:p></draw:ellipse>
<presentation:notes><text:p>Second speaker note</text:p></presentation:notes>
</draw:page>`),
);
return fixture(
"project-authored.odp",
"application/vnd.oasis.opendocument.presentation",
data,
);
}
+329
View File
@@ -0,0 +1,329 @@
import { strToU8, zipSync } from "fflate";
type XmlParts = Record<string, string>;
interface NodeBufferLike extends Uint8Array {
toString(encoding?: string): string;
}
const nodeBuffer = (
globalThis as unknown as {
Buffer: {
from(value: string | Uint8Array): NodeBufferLike;
};
}
).Buffer;
export interface OoxmlFixture {
buffer: NodeBufferLike;
mimeType: string;
name: string;
}
const XML_DECLARATION =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
function packageFixture(
name: string,
mimeType: string,
parts: XmlParts,
): OoxmlFixture {
const archive = Object.fromEntries(
Object.entries(parts)
.sort(([left], [right]) => left.localeCompare(right))
.map(([path, xml]) => [path, strToU8(xml)]),
);
return {
buffer: nodeBuffer.from(zipSync(archive, { level: 6 })),
mimeType,
name,
};
}
function contentTypes(overrides: string): string {
return `${XML_DECLARATION}
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
${overrides}
</Types>`;
}
function rootRelationships(target: string): string {
return `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="${target}"/>
</Relationships>`;
}
export function createMinimalDocx(): OoxmlFixture {
return packageFixture(
"three-page-document.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
{
"[Content_Types].xml": contentTypes(`
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>`),
"_rels/.rels": rootRelationships("word/document.xml"),
"word/_rels/document.xml.rels": `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,
"word/styles.xml": `${XML_DECLARATION}
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault><w:rPr><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault>
<w:pPrDefault><w:pPr/></w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/><w:qFormat/>
</w:style>
<w:style w:type="paragraph" w:styleId="Title">
<w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/>
<w:rPr><w:b/><w:sz w:val="36"/></w:rPr>
</w:style>
</w:styles>`,
"word/document.xml": `${XML_DECLARATION}
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p>
<w:pPr><w:pStyle w:val="Title"/></w:pPr>
<w:r><w:t>Office Tools Document Fixture</w:t></w:r>
</w:p>
<w:p><w:r><w:t>This text is rendered from a project-authored DOCX package.</w:t></w:r></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="3600"/><w:gridCol w:w="3600"/></w:tblGrid>
<w:tr>
<w:tc><w:p><w:r><w:t>Feature</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>Status</w:t></w:r></w:p></w:tc>
</w:tr>
<w:tr>
<w:tc><w:p><w:r><w:t>Local viewing</w:t></w:r></w:p></w:tc>
<w:tc><w:p><w:r><w:t>Ready</w:t></w:r></w:p></w:tc>
</w:tr>
</w:tbl>
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
<w:p><w:r><w:t>Second Page Search Marker</w:t></w:r></w:p>
<w:p><w:r><w:t>Navigation reaches this independently laid-out page.</w:t></w:r></w:p>
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
<w:p><w:r><w:t>Third page trailing marker</w:t></w:r></w:p>
<w:p><w:r><w:t>This trailing page makes the middle page a stable scroll target.</w:t></w:r></w:p>
<w:sectPr>
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>`,
},
);
}
export function createMinimalXlsx(): OoxmlFixture {
return packageFixture(
"two-sheet-workbook.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
{
"[Content_Types].xml": contentTypes(`
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>`),
"_rels/.rels": rootRelationships("xl/workbook.xml"),
"xl/workbook.xml": `${XML_DECLARATION}
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<bookViews><workbookView xWindow="0" yWindow="0" windowWidth="18000" windowHeight="10000"/></bookViews>
<sheets>
<sheet name="Summary" sheetId="1" state="visible" r:id="rId1"/>
<sheet name="Details" sheetId="2" state="visible" r:id="rId2"/>
</sheets>
<calcPr calcId="191029"/>
</workbook>`,
"xl/_rels/workbook.xml.rels": `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
</Relationships>`,
"xl/sharedStrings.xml": `${XML_DECLARATION}
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="6" uniqueCount="6">
<si><t>Office Tools Workbook Fixture</t></si>
<si><t>Amount</t></si>
<si><t>Total</t></si>
<si><t>Details Sheet Search Marker</t></si>
<si><t>Item</t></si>
<si><t>Quantity</t></si>
</sst>`,
"xl/styles.xml": `${XML_DECLARATION}
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2">
<font><sz val="11"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>
<font><b/><sz val="11"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font>
</fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2">
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>
</cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`,
"xl/worksheets/sheet1.xml": `${XML_DECLARATION}
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1:B4"/>
<sheetViews><sheetView workbookViewId="0"><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>
<sheetFormatPr defaultRowHeight="15"/>
<cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="2" width="14" customWidth="1"/></cols>
<sheetData>
<row r="1"><c r="A1" s="1" t="s"><v>0</v></c></row>
<row r="2"><c r="A2" s="1" t="s"><v>1</v></c></row>
<row r="3"><c r="A3" t="n"><v>19</v></c><c r="B3" t="n"><v>23</v></c></row>
<row r="4"><c r="A4" s="1" t="s"><v>2</v></c><c r="B4"><f>SUM(A3:B3)</f><v>42</v></c></row>
</sheetData>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>`,
"xl/worksheets/sheet2.xml": `${XML_DECLARATION}
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<dimension ref="A1:B3"/>
<sheetViews><sheetView workbookViewId="0"><selection activeCell="A1" sqref="A1"/></sheetView></sheetViews>
<sheetFormatPr defaultRowHeight="15"/>
<cols><col min="1" max="1" width="34" customWidth="1"/><col min="2" max="2" width="14" customWidth="1"/></cols>
<sheetData>
<row r="1"><c r="A1" s="1" t="s"><v>3</v></c></row>
<row r="2"><c r="A2" s="1" t="s"><v>4</v></c><c r="B2" s="1" t="s"><v>5</v></c></row>
<row r="3"><c r="A3" t="inlineStr"><is><t>Local parser</t></is></c><c r="B3" t="n"><v>2</v></c></row>
</sheetData>
<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/>
</worksheet>`,
},
);
}
const PRESENTATION_NS =
'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
function slideXml(title: string, body: string): string {
return `${XML_DECLARATION}
<p:sld ${PRESENTATION_NS}>
<p:cSld>
<p:spTree>
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Fixture title"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="685800" y="685800"/><a:ext cx="7772400" cy="914400"/></a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>
</p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="2800" b="1"/><a:t>${title}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="3" name="Fixture body"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>
<p:spPr>
<a:xfrm><a:off x="914400" y="2057400"/><a:ext cx="7315200" cy="2286000"/></a:xfrm>
<a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom>
<a:solidFill><a:srgbClr val="E8F0FE"/></a:solidFill><a:ln><a:solidFill><a:srgbClr val="356AC3"/></a:solidFill></a:ln>
</p:spPr>
<p:txBody><a:bodyPr lIns="228600" rIns="228600" tIns="228600" bIns="228600"/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="1800"/><a:t>${body}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sld>`;
}
function slideRelationships(): string {
return `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>`;
}
export function createMinimalPptx(): OoxmlFixture {
return packageFixture(
"two-slide-presentation.pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
{
"[Content_Types].xml": contentTypes(`
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/slides/slide2.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
<Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>
<Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/>
<Override PartName="/ppt/theme/theme1.xml" ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>`),
"_rels/.rels": rootRelationships("ppt/presentation.xml"),
"ppt/presentation.xml": `${XML_DECLARATION}
<p:presentation ${PRESENTATION_NS}>
<p:sldMasterIdLst><p:sldMasterId id="2147483648" r:id="rId1"/></p:sldMasterIdLst>
<p:sldIdLst><p:sldId id="256" r:id="rId2"/><p:sldId id="257" r:id="rId3"/></p:sldIdLst>
<p:sldSz cx="9144000" cy="5143500" type="screen16x9"/>
<p:notesSz cx="6858000" cy="9144000"/>
</p:presentation>`,
"ppt/_rels/presentation.xml.rels": `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMasters/slideMaster1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide2.xml"/>
</Relationships>`,
"ppt/slides/slide1.xml": slideXml(
"Office Tools Presentation Fixture",
"This first slide is rendered entirely in the browser.",
),
"ppt/slides/slide2.xml": slideXml(
"Second Slide Search Marker",
"Navigation reaches the second project-authored slide.",
),
"ppt/slides/_rels/slide1.xml.rels": slideRelationships(),
"ppt/slides/_rels/slide2.xml.rels": slideRelationships(),
"ppt/slideLayouts/slideLayout1.xml": `${XML_DECLARATION}
<p:sldLayout ${PRESENTATION_NS} type="blank" preserve="1">
<p:cSld name="Blank"><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
<p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>`,
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>`,
"ppt/slideMasters/slideMaster1.xml": `${XML_DECLARATION}
<p:sldMaster ${PRESENTATION_NS}>
<p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr></p:spTree></p:cSld>
<p:clrMap accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" bg1="lt1" bg2="lt2" folHlink="folHlink" hlink="hlink" tx1="dk1" tx2="dk2"/>
<p:sldLayoutIdLst><p:sldLayoutId id="1" r:id="rId1"/></p:sldLayoutIdLst>
<p:txStyles><p:titleStyle/><p:bodyStyle/><p:otherStyle/></p:txStyles>
</p:sldMaster>`,
"ppt/slideMasters/_rels/slideMaster1.xml.rels": `${XML_DECLARATION}
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>`,
"ppt/theme/theme1.xml": `${XML_DECLARATION}
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Tools Fixture">
<a:themeElements>
<a:clrScheme name="Fixture">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="1F2937"/></a:dk2><a:lt2><a:srgbClr val="F8FAFC"/></a:lt2>
<a:accent1><a:srgbClr val="356AC3"/></a:accent1><a:accent2><a:srgbClr val="D97706"/></a:accent2>
<a:accent3><a:srgbClr val="059669"/></a:accent3><a:accent4><a:srgbClr val="7C3AED"/></a:accent4>
<a:accent5><a:srgbClr val="0891B2"/></a:accent5><a:accent6><a:srgbClr val="DB2777"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme>
<a:fontScheme name="Fixture"><a:majorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="Arial"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme>
<a:fmtScheme name="Fixture"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:fillStyleLst><a:lnStyleLst><a:ln w="9525"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill></a:bgFillStyleLst></a:fmtScheme>
</a:themeElements>
</a:theme>`,
},
);
}
export function createMalformedDocx(): OoxmlFixture {
return {
buffer: nodeBuffer.from("This is deliberately not an OOXML ZIP package."),
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
name: "malformed-document.docx",
};
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
MAX_SOURCE_BYTES,
OfficeFileError,
detectOfficeFormat,
extensionOf,
familyForFormat,
formatBytes,
formatLabel,
} from "../../src/office/formats";
describe("office format handling", () => {
it.each([
["REPORT.DOCX", "docx"],
["notes.odt", "odt"],
["budget.xlsx", "xlsx"],
["budget.ODS", "ods"],
["deck.pptx", "pptx"],
["deck.odp", "odp"],
] as const)("detects %s", (name, expected) => {
expect(detectOfficeFormat({ name, size: 42 })).toBe(expected);
});
it("rejects empty, oversized, unsupported and legacy inputs explicitly", () => {
const cases = [
[{ name: "empty.docx", size: 0 }, "empty-file"],
[{ name: "huge.docx", size: MAX_SOURCE_BYTES + 1 }, "file-too-large"],
[{ name: "old.doc", size: 42 }, "legacy-format"],
[{ name: "notes.txt", size: 42 }, "unsupported-format"],
] as const;
for (const [file, code] of cases) {
try {
detectOfficeFormat(file);
throw new Error("Expected detection to fail");
} catch (error) {
expect(error).toBeInstanceOf(OfficeFileError);
expect((error as OfficeFileError).code).toBe(code);
}
}
});
it("maps family, labels and display values", () => {
expect(extensionOf("archive.name.PPTX")).toBe("pptx");
expect(familyForFormat("docx")).toBe("document");
expect(familyForFormat("ods")).toBe("spreadsheet");
expect(familyForFormat("odp")).toBe("presentation");
expect(formatLabel("xlsx")).toBe("Excel workbook");
expect(formatBytes(1536)).toBe("1.50 KiB");
});
});
+149
View File
@@ -0,0 +1,149 @@
import { strToU8, zipSync, type Zippable } from "fflate";
import type { OdfFormat } from "../../../src/office/odf";
export const MEDIA_TYPES: Readonly<Record<OdfFormat, string>> = Object.freeze({
odt: "application/vnd.oasis.opendocument.text",
ods: "application/vnd.oasis.opendocument.spreadsheet",
odp: "application/vnd.oasis.opendocument.presentation",
});
export const XMLNS = [
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"',
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"',
'xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"',
'xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"',
'xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"',
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"',
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"',
'xmlns:xlink="http://www.w3.org/1999/xlink"',
'xmlns:dc="http://purl.org/dc/elements/1.1/"',
'xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"',
'xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"',
'xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"',
].join(" ");
interface FixtureOptions {
extra?: Record<string, Uint8Array>;
manifest?: string | false;
meta?: string;
styles?: string;
mediaType?: string;
}
export function makeOdf(
format: OdfFormat,
content: string,
options: FixtureOptions = {},
): ArrayBuffer {
const mediaType = options.mediaType ?? MEDIA_TYPES[format];
const extra = options.extra ?? {};
const manifest =
options.manifest === false
? undefined
: (options.manifest ?? makeManifest(mediaType, Object.keys(extra)));
const files: Zippable = {
mimetype: [strToU8(mediaType), { level: 0 }],
"content.xml": strToU8(content),
};
if (manifest) files["META-INF/manifest.xml"] = strToU8(manifest);
if (options.meta) files["meta.xml"] = strToU8(options.meta);
if (options.styles) files["styles.xml"] = strToU8(options.styles);
for (const [path, value] of Object.entries(extra)) files[path] = value;
return ownedBuffer(zipSync(files, { level: 6 }));
}
export function makeManifest(
mediaType: string,
extraPaths: readonly string[] = [],
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="${mediaType}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
${extraPaths
.map(
(path) =>
`<manifest:file-entry manifest:full-path="${path}" manifest:media-type="${mediaFor(path)}"/>`,
)
.join("")}
</manifest:manifest>`;
}
export function odtContent(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content ${XMLNS} office:version="1.3">
<office:automatic-styles>
<style:style style:name="InlineBold" style:family="text">
<style:text-properties fo:font-weight="bold"/>
</style:style>
</office:automatic-styles>
<office:body><office:text>${body}</office:text></office:body>
</office:document-content>`;
}
export function odsContent(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content ${XMLNS} office:version="1.3">
<office:body><office:spreadsheet>${body}</office:spreadsheet></office:body>
</office:document-content>`;
}
export function odpContent(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content ${XMLNS} office:version="1.3">
<office:body><office:presentation>${body}</office:presentation></office:body>
</office:document-content>`;
}
export function zipWithTraversal(content: string): ArrayBuffer {
const files: Zippable = {
mimetype: [strToU8(MEDIA_TYPES.odt), { level: 0 }],
"content.xml": strToU8(content),
"../escape.txt": strToU8("no"),
};
return ownedBuffer(zipSync(files));
}
export function zipWithEncodedCollision(content: string): ArrayBuffer {
const files: Zippable = {
mimetype: [strToU8(MEDIA_TYPES.odt), { level: 0 }],
"content.xml": strToU8(content),
"Pictures/a.png": new Uint8Array([1]),
"Pictures/%61.png": new Uint8Array([2]),
};
return ownedBuffer(zipSync(files));
}
export function corruptFirstCentralCrc(input: ArrayBuffer): ArrayBuffer {
const bytes = new Uint8Array(input.slice(0));
const view = new DataView(bytes.buffer);
const end = findSignature(bytes, 0x06054b50);
const centralOffset = view.getUint32(end + 16, true);
view.setUint32(
centralOffset + 16,
view.getUint32(centralOffset + 16, true) ^ 1,
true,
);
return bytes.buffer;
}
function findSignature(bytes: Uint8Array, signature: number): number {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
for (let offset = bytes.byteLength - 4; offset >= 0; offset -= 1) {
if (view.getUint32(offset, true) === signature) return offset;
}
throw new Error("Signature not found in test ZIP");
}
function mediaFor(path: string): string {
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".svg")) return "image/svg+xml";
return "application/octet-stream";
}
function ownedBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
+257
View File
@@ -0,0 +1,257 @@
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");
});
});
+244
View File
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
OdfParseError,
parseOdt,
parseOpenDocument,
} from "../../../src/office/odf";
import {
corruptFirstCentralCrc,
makeManifest,
makeOdf,
MEDIA_TYPES,
odtContent,
odsContent,
XMLNS,
zipWithEncodedCollision,
zipWithTraversal,
} from "./fixtures";
function expectOdfError(
action: () => unknown,
code: OdfParseError["code"],
): void {
try {
action();
throw new Error("Expected ODF parser to reject the fixture");
} catch (error) {
expect(error).toBeInstanceOf(OdfParseError);
expect((error as OdfParseError).code).toBe(code);
}
}
describe("OpenDocument package and XML security boundaries", () => {
it("rejects non-ZIP input and unsafe traversal paths", () => {
expectOdfError(
() => parseOpenDocument(new Uint8Array([1, 2, 3]).buffer),
"invalid-zip",
);
expectOdfError(
() =>
parseOpenDocument(
zipWithTraversal(odtContent("<text:p>Safe</text:p>")),
),
"invalid-path",
);
expectOdfError(
() =>
parseOpenDocument(
zipWithEncodedCollision(odtContent("<text:p>Safe</text:p>")),
),
"invalid-path",
);
});
it("rejects CRC corruption", () => {
const fixture = makeOdf("odt", odtContent("<text:p>Integrity</text:p>"));
expectOdfError(
() => parseOpenDocument(corruptFirstCentralCrc(fixture)),
"invalid-zip",
);
});
it("rejects expected-format, filename, manifest, and body type mismatches", () => {
const ods = makeOdf("ods", odsContent('<table:table table:name="S"/>'));
expectOdfError(
() => parseOpenDocument(ods, { expectedFormat: "odt" }),
"type-mismatch",
);
expectOdfError(
() => parseOpenDocument(ods, { fileName: "wrong.odt" }),
"type-mismatch",
);
expectOdfError(
() =>
parseOpenDocument(
makeOdf("odt", odtContent("<text:p>x</text:p>"), {
manifest: makeManifest(MEDIA_TYPES.ods),
}),
),
"type-mismatch",
);
expectOdfError(
() =>
parseOdt(makeOdf("odt", odsContent('<table:table table:name="S"/>'))),
"type-mismatch",
);
});
it("rejects encrypted package declarations", () => {
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml">
<manifest:encryption-data/>
</manifest:file-entry></manifest:manifest>`;
expectOdfError(
() =>
parseOpenDocument(
makeOdf("odt", odtContent("<text:p>x</text:p>"), { manifest }),
),
"encrypted",
);
});
it("accepts manifest directories represented implicitly by child entries", () => {
const manifest = `<?xml version="1.0"?><manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<manifest:file-entry manifest:full-path="/" manifest:media-type="${MEDIA_TYPES.odt}"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="Object 1/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
</manifest:manifest>`;
const document = parseOpenDocument(
makeOdf("odt", odtContent("<text:p>Safe</text:p>"), {
manifest,
extra: {
"Object 1/content.xml": new TextEncoder().encode("unused"),
},
}),
);
expect(document.format).toBe("odt");
});
it("rejects malformed XML, DTDs/entities, and excessive XML depth", () => {
expectOdfError(
() => parseOpenDocument(makeOdf("odt", "<office:document-content>")),
"invalid-xml",
);
const entity = `<?xml version="1.0"?><!DOCTYPE x [<!ENTITY local "bad">]>
<office:document-content ${XMLNS}><office:body><office:text><text:p>&local;</text:p></office:text></office:body></office:document-content>`;
expectOdfError(
() => parseOpenDocument(makeOdf("odt", entity)),
"invalid-xml",
);
const nested =
`<text:section>`.repeat(8) +
`<text:p>x</text:p>` +
`</text:section>`.repeat(8);
expectOdfError(
() =>
parseOpenDocument(makeOdf("odt", odtContent(nested)), {
limits: { maxXmlDepth: 6 },
}),
"limit-exceeded",
);
const compact = `<?xml version="1.0"?><office:document-content ${XMLNS}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>`;
expectOdfError(
() =>
parseOpenDocument(makeOdf("odt", compact, { manifest: false }), {
limits: { maxXmlNodes: 4 },
}),
"limit-exceeded",
);
});
it("rejects dangerous external images and active links", () => {
expectOdfError(
() =>
parseOpenDocument(
makeOdf(
"odt",
odtContent(
'<draw:frame><draw:image xlink:href="https://tracker.example/image.png"/></draw:frame>',
),
),
),
"external-resource",
);
expectOdfError(
() =>
parseOpenDocument(
makeOdf(
"odt",
odtContent(
'<text:p><text:a xlink:href="javascript:alert(1)">bad</text:a></text:p>',
),
),
),
"external-resource",
);
});
it("keeps embedded objects inert and reports their omission", () => {
const document = parseOpenDocument(
makeOdf("odt", odtContent('<draw:object xlink:href="Object 1"/>')),
);
if (document.format !== "odt") throw new Error("Expected an ODT model");
expect(document.blocks).toEqual([]);
expect(document.warnings).toContain(
"1 embedded object was kept inert and omitted",
);
});
it("enforces entry, total, text, asset, repeat, and expanded-cell limits", () => {
const basic = makeOdf("odt", odtContent("<text:p>123456789</text:p>"));
expectOdfError(
() => parseOpenDocument(basic, { limits: { maxEntryBytes: 32 } }),
"limit-exceeded",
);
expectOdfError(
() => parseOpenDocument(basic, { limits: { maxTotalBytes: 50 } }),
"limit-exceeded",
);
expectOdfError(
() => parseOpenDocument(basic, { limits: { maxEntries: 2 } }),
"limit-exceeded",
);
expectOdfError(
() => parseOpenDocument(basic, { limits: { maxTextChars: 5 } }),
"limit-exceeded",
);
const repeated = makeOdf(
"ods",
odsContent(
'<table:table table:name="Huge"><table:table-row table:number-rows-repeated="4"><table:table-cell table:number-columns-repeated="4" office:value-type="string"><text:p>x</text:p></table:table-cell></table:table-row></table:table>',
),
);
expectOdfError(
() => parseOpenDocument(repeated, { limits: { maxRepeat: 3 } }),
"limit-exceeded",
);
expectOdfError(
() => parseOpenDocument(repeated, { limits: { maxCells: 15 } }),
"limit-exceeded",
);
const image = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4]);
const imageDocument = makeOdf(
"odt",
odtContent(
'<draw:frame><draw:image xlink:href="Pictures/a.png"/></draw:frame>',
),
{ extra: { "Pictures/a.png": image } },
);
expectOdfError(
() => parseOpenDocument(imageDocument, { limits: { maxAssetBytes: 4 } }),
"limit-exceeded",
);
});
it("validates option limits before reading untrusted input", () => {
expect(() =>
parseOpenDocument(new ArrayBuffer(0), { limits: { maxEntries: 0 } }),
).toThrow(TypeError);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { describeOoxmlError } from "../../src/office/ooxml-errors";
describe("OOXML error descriptions", () => {
it("offers an in-memory password retry for supported encryption", () => {
const description = describeOoxmlError(
Object.assign(new Error("encrypted"), { code: "encrypted" }),
);
expect(description.passwordRequired).toBe(true);
expect(description.message).toContain("password protected");
});
it.each([
"unsupported-encryption",
"legacy-binary-format",
"not-ooxml",
"ooxml-resource-limit",
"ooxml-decoded-image-limit",
"parser-crashed",
])("provides a stable safe message for %s", (code) => {
const description = describeOoxmlError(
Object.assign(new Error("internal diagnostic"), { code }),
);
expect(description.code).toBe(code);
expect(description.passwordRequired).toBe(false);
expect(description.message).not.toBe("internal diagnostic");
});
it("retains an ordinary parser message as the fallback", () => {
expect(describeOoxmlError(new Error("Malformed package")).message).toBe(
"Malformed package",
);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { manifest } from "../../src/toolbox/manifest";
import { APP_VERSION } from "../../src/version";
describe("Toolbox manifest", () => {
it("keeps the release and source identity pinned", () => {
expect(manifest.id).toBe("de.add-ideas.office-tools");
expect(manifest.version).toBe(APP_VERSION);
expect(manifest.entry).toBe("./");
expect(manifest.source).toEqual({
repository: "https://git.add-ideas.de/lotobo/office-tools",
license: "GPL-3.0-or-later",
});
expect(manifest.privacy).toMatchObject({
processing: "local",
fileUploads: false,
telemetry: false,
});
});
});