Files

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