feat: release Office Tools 0.1.0
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+95
@@ -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,
|
||||
);
|
||||
}
|
||||
Vendored
+329
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user