204 lines
9.0 KiB
TypeScript
204 lines
9.0 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { Window } from "happy-dom";
|
|
import { zipSync, strToU8 } from "fflate";
|
|
import { readOdf, readPptx } from "../src/optional/office.ts";
|
|
import { isRtf, rtfToText } from "../src/parse/rtf.ts";
|
|
import { resolve } from "../src/resolve.ts";
|
|
import { RendererRegistry } from "../src/registry.ts";
|
|
import { kindOfExtension } from "../src/kinds.ts";
|
|
|
|
const view = new Window() as unknown as { DOMParser: new () => DOMParser };
|
|
const A = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
|
const P = "http://schemas.openxmlformats.org/presentationml/2006/main";
|
|
const OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";
|
|
const TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";
|
|
const TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
|
|
const DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0";
|
|
|
|
const file = (name: string, bytes: Uint8Array, mime = "") => ({
|
|
name,
|
|
blob: new Blob([bytes as unknown as BlobPart], { type: mime }),
|
|
});
|
|
|
|
/** A slide with one title shape and one body shape, as PowerPoint writes them. */
|
|
const slideXml = (title: string, bullets: string[]) =>
|
|
`<?xml version="1.0"?><p:sld xmlns:p="${P}" xmlns:a="${A}"><p:cSld><p:spTree>` +
|
|
`<p:sp><p:txBody><a:p><a:r><a:t>${title}</a:t></a:r></a:p></p:txBody></p:sp>` +
|
|
`<p:sp><p:txBody>${bullets
|
|
.map((line) => `<a:p><a:r><a:t>${line}</a:t></a:r></a:p>`)
|
|
.join("")}</p:txBody></p:sp>` +
|
|
"</p:spTree></p:cSld></p:sld>";
|
|
|
|
const pptx = zipSync({
|
|
// Written out of order on purpose: slide 10 must not sort before slide 2.
|
|
"ppt/slides/slide10.xml": strToU8(slideXml("Last slide", ["The end"])),
|
|
"ppt/slides/slide1.xml": strToU8(slideXml("Quarterly review", ["Revenue up", "Costs flat"])),
|
|
"ppt/slides/slide2.xml": strToU8(slideXml("Regions", ["North", "South"])),
|
|
"ppt/notesSlides/notesSlide1.xml": strToU8(
|
|
`<?xml version="1.0"?><p:notes xmlns:p="${P}" xmlns:a="${A}"><a:p><a:r><a:t>Say hello first</a:t></a:r></a:p></p:notes>`,
|
|
),
|
|
// Media must never be inflated for a listing-style read.
|
|
"ppt/media/image1.png": new Uint8Array(2048),
|
|
});
|
|
|
|
const odf = (mimetype: string, body: string) =>
|
|
zipSync({
|
|
mimetype: strToU8(mimetype),
|
|
"content.xml": strToU8(
|
|
`<?xml version="1.0"?><office:document-content xmlns:office="${OFFICE}" ` +
|
|
`xmlns:text="${TEXT}" xmlns:table="${TABLE}" xmlns:draw="${DRAW}">${body}` +
|
|
"</office:document-content>",
|
|
),
|
|
});
|
|
|
|
describe("PowerPoint", () => {
|
|
test("slides come back in numeric order with titles and bullets", async () => {
|
|
const deck = await readPptx(pptx, view);
|
|
expect(deck.type).toBe("slides");
|
|
expect(deck.slides.map((slide) => slide.title)).toEqual([
|
|
"Quarterly review",
|
|
"Regions",
|
|
"Last slide",
|
|
]);
|
|
expect(deck.slides[0]!.blocks[1]).toEqual(["Revenue up", "Costs flat"]);
|
|
expect(deck.slides[0]!.notes).toBe("Say hello first");
|
|
expect(deck.slides[1]!.notes).toBeUndefined();
|
|
});
|
|
|
|
test("a file with no slides rejects rather than showing an empty deck", async () => {
|
|
await expect(readPptx(zipSync({ "docProps/app.xml": strToU8("<x/>") }), view)).rejects.toThrow(
|
|
"no slides",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("OpenDocument", () => {
|
|
test("text keeps heading levels and list membership", async () => {
|
|
const bytes = odf(
|
|
"application/vnd.oasis.opendocument.text",
|
|
"<office:body><office:text>" +
|
|
'<text:h text:outline-level="1">Annual report</text:h>' +
|
|
"<text:p>Opening paragraph.</text:p>" +
|
|
'<text:h text:outline-level="2">Detail</text:h>' +
|
|
"<text:list><text:list-item><text:p>First point</text:p></text:list-item>" +
|
|
"<text:list-item><text:p>Second point</text:p></text:list-item></text:list>" +
|
|
"</office:text></office:body>",
|
|
);
|
|
const document = await readOdf(bytes, view);
|
|
expect(document.type).toBe("text");
|
|
if (document.type !== "text") throw new Error("unreachable");
|
|
expect(document.blocks[0]).toEqual({ level: 1, text: "Annual report" });
|
|
expect(document.blocks[1]).toEqual({ level: 0, text: "Opening paragraph." });
|
|
expect(document.blocks[2]!.level).toBe(2);
|
|
expect(document.blocks[3]).toEqual({ level: 0, text: "First point", list: true });
|
|
});
|
|
|
|
test("spreadsheets expand repeated cells but not trailing padding", async () => {
|
|
const bytes = odf(
|
|
"application/vnd.oasis.opendocument.spreadsheet",
|
|
"<office:body><office:spreadsheet>" +
|
|
'<table:table table:name="Revenue">' +
|
|
"<table:table-row><table:table-cell><text:p>Region</text:p></table:table-cell>" +
|
|
"<table:table-cell><text:p>Q1</text:p></table:table-cell></table:table-row>" +
|
|
"<table:table-row><table:table-cell><text:p>North</text:p></table:table-cell>" +
|
|
'<table:table-cell table:number-columns-repeated="2"><text:p>10</text:p></table:table-cell>' +
|
|
'<table:table-cell table:number-columns-repeated="16384"/></table:table-row>' +
|
|
"</table:table></office:spreadsheet></office:body>",
|
|
);
|
|
const document = await readOdf(bytes, view);
|
|
if (document.type !== "sheets") throw new Error("expected sheets");
|
|
expect(document.sheets[0]!.name).toBe("Revenue");
|
|
expect(document.sheets[0]!.header).toEqual(["Region", "Q1"]);
|
|
// The repeat of 2 is real data; the 16384 empty tail is dropped.
|
|
expect(document.sheets[0]!.rows).toEqual([["North", "10", "10"]]);
|
|
});
|
|
|
|
test("presentations become slides", async () => {
|
|
const bytes = odf(
|
|
"application/vnd.oasis.opendocument.presentation",
|
|
"<office:body><office:presentation>" +
|
|
'<draw:page draw:name="page1"><draw:frame><text:p>Kickoff</text:p></draw:frame>' +
|
|
"<draw:frame><text:p>Agenda</text:p><text:p>Timeline</text:p></draw:frame></draw:page>" +
|
|
"</office:presentation></office:body>",
|
|
);
|
|
const document = await readOdf(bytes, view);
|
|
if (document.type !== "slides") throw new Error("expected slides");
|
|
expect(document.slides[0]!.title).toBe("Kickoff");
|
|
expect(document.slides[0]!.blocks[1]).toEqual(["Agenda", "Timeline"]);
|
|
});
|
|
|
|
test("a zip that is not an OpenDocument file rejects", async () => {
|
|
await expect(readOdf(zipSync({ "a.txt": strToU8("hi") }), view)).rejects.toThrow(
|
|
"Not an OpenDocument",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("RTF", () => {
|
|
test("control words, escapes and unicode become text", () => {
|
|
const source =
|
|
"{\\rtf1\\ansi{\\fonttbl{\\f0 Times;}}{\\colortbl;\\red0\\green0\\blue0;}" +
|
|
"\\f0\\fs24 Hello\\tab world\\par Second line\\par " +
|
|
"Caf\\'e9 and \\u8212? dashes\\par}";
|
|
const text = rtfToText(source);
|
|
expect(text).toContain("Hello\tworld");
|
|
expect(text).toContain("Second line");
|
|
// Font and colour tables must not leak into the output.
|
|
expect(text).not.toContain("Times");
|
|
expect(text).not.toContain("red0");
|
|
expect(text).toContain("Café");
|
|
expect(text).toContain("—");
|
|
});
|
|
|
|
test("optional destinations are skipped whole", () => {
|
|
const text = rtfToText(
|
|
"{\\rtf1{\\*\\generator Word 16;}Visible text{\\*\\bkmkstart hidden}\\par}",
|
|
);
|
|
expect(text).toContain("Visible text");
|
|
expect(text).not.toContain("Word 16");
|
|
expect(text).not.toContain("hidden");
|
|
});
|
|
|
|
test("non-RTF input is rejected", () => {
|
|
expect(isRtf("hello")).toBe(false);
|
|
expect(() => rtfToText("hello")).toThrow("Not an RTF");
|
|
});
|
|
});
|
|
|
|
describe("routing", () => {
|
|
// Optional modules cannot load outside a browser, so `resolve` would skip
|
|
// every renderer that needs one. Registration and precedence are the units
|
|
// under test here; the mounted result is verified in the example app.
|
|
const registry = new RendererRegistry();
|
|
const components = (ext: string) =>
|
|
registry.candidates(kindOfExtension(ext), ext).map((renderer) => renderer.component);
|
|
|
|
test("presentations and documents prefer the converter, with a local reader behind it", () => {
|
|
expect(components(".pptx")).toEqual(["ConvertedViewer", "SlidesViewer"]);
|
|
expect(components(".odt")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
|
|
expect(components(".odp")).toEqual(["ConvertedViewer", "OpenDocumentViewer"]);
|
|
expect(components(".docx")).toEqual(["ConvertedViewer", "DocxViewer"]);
|
|
});
|
|
|
|
test("spreadsheets prefer the local reader: the data matters, not the layout", () => {
|
|
expect(components(".ods")[0]).toBe("SheetViewer");
|
|
expect(components(".xlsx")[0]).toBe("SheetViewer");
|
|
// A workbook SheetJS cannot open still has the OpenDocument reader behind it.
|
|
expect(components(".ods")).toContain("OpenDocumentViewer");
|
|
});
|
|
|
|
test("legacy binary formats stay converter-only", () => {
|
|
expect(components(".doc")).toEqual(["ConvertedViewer"]);
|
|
expect(components(".ppt")).toEqual(["ConvertedViewer"]);
|
|
});
|
|
|
|
test("RTF needs no optional module and no converter", async () => {
|
|
expect(components(".rtf")[0]).toBe("ConvertedViewer");
|
|
const source = String.raw`{\rtf1\ansi Hello\par}`;
|
|
expect(isRtf(source)).toBe(true);
|
|
const plan = await resolve(file("letter.rtf", strToU8(source)));
|
|
expect(plan.renderer.component).toBe("RtfViewer");
|
|
expect(rtfToText(source)).toBe("Hello");
|
|
});
|
|
});
|