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