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[]) =>
`` +
`${title}` +
`${bullets
.map((line) => `${line}`)
.join("")}` +
"";
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(
`Say hello first`,
),
// 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(
`${body}` +
"",
),
});
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("") }), view)).rejects.toThrow(
"no slides",
);
});
});
describe("OpenDocument", () => {
test("text keeps heading levels and list membership", async () => {
const bytes = odf(
"application/vnd.oasis.opendocument.text",
"" +
'Annual report' +
"Opening paragraph." +
'Detail' +
"First point" +
"Second point" +
"",
);
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",
"" +
'' +
"Region" +
"Q1" +
"North" +
'10' +
'' +
"",
);
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",
"" +
'Kickoff' +
"AgendaTimeline" +
"",
);
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");
});
});