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
+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",
);
});
});