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