= {
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
+ ".m4a": "audio/mp4",
+ ".mp3": "audio/mpeg",
+ ".mp4": "video/mp4",
+ ".ogg": "audio/ogg",
".pdf": "application/pdf",
".png": "image/png",
".svg": "image/svg+xml",
".txt": "text/plain; charset=utf-8",
".wasm": "application/wasm",
+ ".wav": "audio/wav",
+ ".webm": "video/webm",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json
index 83b1b8ef..7a031c1a 100644
--- a/packages/email-renderer/package.json
+++ b/packages/email-renderer/package.json
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/email-renderer",
- "version": "0.1.47",
+ "version": "0.1.50",
"type": "module",
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
"exports": {
diff --git a/packages/email-renderer/src/blocks.ts b/packages/email-renderer/src/blocks.ts
index fbf8f1f5..3b85a5f7 100644
--- a/packages/email-renderer/src/blocks.ts
+++ b/packages/email-renderer/src/blocks.ts
@@ -12,6 +12,7 @@ import {
type ValueSchema,
} from "@wrnexus/blocks";
import { brandColor, brandFont, brandWidth } from "./brand.ts";
+import { richTextSchema, renderRichText } from "./rich-text.ts";
import { emailStyleValue } from "./style-values.ts";
const styles = record(json, { maxKeys: 50 }) as never;
@@ -332,19 +333,20 @@ export const headingBlock = leaf(
"Heading",
"icon-[lucide--heading]",
object({
+ richText: richTextSchema,
text: string({ min: 1, max: 500, default: "Heading" }),
level: choice(["1", "2", "3", "4"] as const, { default: "2" }),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
- `${html(attrs(node).text)}`,
+ `${renderRichText(attrs(node))}`,
node,
),
ampEmail: ({ node }) =>
- emailCell(`${html(attrs(node).text)}`, node),
+ emailCell(`${renderRichText(attrs(node))}`, node),
mjml: ({ node }) =>
- `${html(attrs(node).text)}`,
+ `${renderRichText(attrs(node))}`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
@@ -353,15 +355,15 @@ export const textBlock = leaf(
"content.text",
"Text",
"icon-[lucide--text]",
- object({ text: string({ max: 200_000, default: "" }) }) as never,
+ object({ richText: richTextSchema, text: string({ max: 200_000, default: "" }) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
- `${multilineText(attrs(node).text)}
`,
+ `${renderRichText(attrs(node))}
`,
node,
),
- ampEmail: ({ node }) => emailCell(`${multilineText(attrs(node).text)}
`, node),
- mjml: ({ node }) => `${multilineText(attrs(node).text)}`,
+ ampEmail: ({ node }) => emailCell(`${renderRichText(attrs(node))}
`, node),
+ mjml: ({ node }) => `${renderRichText(attrs(node))}`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
diff --git a/packages/email-renderer/src/presets.ts b/packages/email-renderer/src/presets.ts
index f5399bcd..5451e94d 100644
--- a/packages/email-renderer/src/presets.ts
+++ b/packages/email-renderer/src/presets.ts
@@ -1,66 +1,101 @@
import type { BlockNode, JsonObject } from "@wrnexus/blocks";
+/** Designed compositions made entirely from native, editable email blocks. */
export function createSectionPreset(name: string, id: () => string): BlockNode {
- const node = (
- type: string,
- props: JsonObject,
- slots: Record = {},
- ): BlockNode => ({
- id: id(),
- type,
- version: 1,
- props,
- styles: {},
- slots,
+ const node = (type: string, props: JsonObject, slots: Record = {}, appearance: Record = {}): BlockNode => ({
+ id: id(), type, version: 1, props,
+ styles: Object.fromEntries(Object.entries(appearance).map(([key, base]) => [key, {base}])), slots,
});
- const text = (value: string) => node("content.text", { text: value });
- const heading = (value: string) => node("content.heading", { text: value, level: "2" });
- const button = (label: string, href: string) => node("content.button", { label, href });
- let content: BlockNode[];
- if (name === "welcome")
- content = [
- heading("Welcome, {{recipientName|friend}}!"),
- text("We’re glad you’re here. Discover what’s new and find your next favorite."),
- button("Explore now", "https://example.com"),
+ const text = (value: string, appearance: Record = {}) =>
+ node("content.text", {text: value}, {}, {fontSize: "16px", color: "#475569", ...appearance});
+ const heading = (value: string, appearance: Record = {}) =>
+ node("content.heading", {text: value, level: "2"}, {}, {fontSize: "22px", color: "#14213d", ...appearance});
+ const eyebrow = (value: string, color = "#0f766e") => text(value, {fontSize: "11px", fontWeight: "700", color});
+ const button = (label: string, background = "#14213d", color = "#ffffff", href = "https://example.com") =>
+ node("content.button", {label, href}, {}, {backgroundColor: background, color, padding: "14px 24px", borderRadius: "8px", fontSize: "14px", fontWeight: "700"});
+ const space = (height = "16") => node("content.spacer", {height});
+ const column = (content: BlockNode[], width = "100%") => node("layout.column", {width}, {content});
+ const row = (columns: BlockNode[]) => node("layout.columns", {}, {columns});
+ let rows: BlockNode[], background = "#ffffff", padding = "40px";
+ if (name === "welcome") {
+ background = "#edf7f3";
+ rows = [row([column([
+ eyebrow("A FRESH START"),
+ heading("Good things start here.", {fontSize: "26px"}),
+ text("Welcome, {{recipientName|friend}}. A little inspiration, thoughtful discoveries, and more of what you love — all in one place."),
+ space("8"), button("Make yourself at home", "#0f766e"),
+ space("24"), text("YOUR NEXT CHAPTER / STARTS TODAY", {fontSize: "10px", color: "#0f766e", fontWeight: "700"}),
+ ])])];
+ } else if (name === "feature") {
+ background = "#f4f1eb";
+ rows = [row([
+ column([
+ text("THE EDIT", {fontSize: "11px", fontWeight: "700", color: "#9a3412"}),
+ heading("Less noise.\nMore possibility.", {fontSize: "23px"}),
+ text("Meet your next everyday favorite. Thoughtfully made, beautifully simple, and ready for whatever comes next.", {fontSize: "14px"}),
+ space("8"), button("Discover the collection", "#9a3412"),
+ ], "65%"),
+ column([
+ text("01", {fontSize: "64px", fontWeight: "700", color: "#9a3412", textAlign: "center", backgroundColor: "#eadfd1", padding: "24px 8px"}),
+ text("THOUGHTFULLY\nCURATED", {fontSize: "11px", fontWeight: "700", textAlign: "center", color: "#9a3412", padding: "16px 8px"}),
+ ], "35%"),
+ ])];
+ } else if (name === "announcement") {
+ background = "#14213d";
+ rows = [row([column([
+ eyebrow("JUST RELEASED", "#a7f3d0"),
+ heading("Something new.\nMade for you.", {fontSize: "28px", color: "#ffffff"}),
+ text("A fresh perspective. A better experience. Discover the latest chapter and see what we’ve been working on.", {color: "#cbd5e1"}),
+ space("12"), button("Take a first look", "#a7f3d0", "#14213d"),
+ space("20"), text("BE THE FIRST TO EXPLORE", {fontSize: "10px", color: "#a7f3d0", fontWeight: "700"}),
+ ])])];
+ } else if (name === "testimonial") {
+ background = "#fff7ed";
+ rows = [row([column([
+ eyebrow("FROM OUR COMMUNITY", "#9a3412"),
+ text("“", {fontSize: "64px", color: "#c2410c", fontWeight: "700"}),
+ heading("The details make\nall the difference.", {fontSize: "23px", color: "#431407"}),
+ text("“A thoughtful experience from start to finish. Everything felt effortless — and the result was even better than I imagined.”", {color: "#7c2d12"}),
+ space("12"), text("CUSTOMER NAME", {fontSize: "12px", fontWeight: "700", color: "#431407"}),
+ text("Customer story · Replace with an approved quote", {fontSize: "11px", color: "#9a3412"}),
+ ])])];
+ } else if (name === "two-column") {
+ rows = [
+ row([column([eyebrow("A LITTLE INSPIRATION"), heading("Two ways to find your next favorite."), space("12")])]),
+ row([
+ column([
+ text("01 / DISCOVER", {fontSize: "11px", fontWeight: "700", color: "#0f766e", backgroundColor: "#edf7f3", padding: "20px 16px"}),
+ heading("Fresh perspectives", {fontSize: "16px", padding: "16px 16px 0"}),
+ text("Ideas and inspiration to make every day feel a little different.", {fontSize: "14px", padding: "0 16px"}),
+ button("Explore ideas", "#0f766e"),
+ ], "50%"),
+ column([
+ text("02 / EXPLORE", {fontSize: "11px", fontWeight: "700", color: "#9a3412", backgroundColor: "#fff7ed", padding: "20px 16px"}),
+ heading("Thoughtful essentials", {fontSize: "16px", padding: "16px 16px 0"}),
+ text("Considered details. Everyday favorites. Find something that fits.", {fontSize: "14px", padding: "0 16px"}),
+ button("Find your favorite", "#9a3412"),
+ ], "50%"),
+ ]),
];
- else if (name === "feature")
- content = [
- node("content.image", {
- src: "https://placehold.co/600x300",
- alt: "Replace with your featured image",
- width: "600",
- height: "300",
- decorative: false,
- }),
- heading("Something worth sharing"),
- text("Introduce your product, tell your story, or share your latest announcement."),
- button("Learn more", "https://example.com"),
- ];
- else if (name === "footer")
- content = [
+ } else if (name === "footer") {
+ background = "#f1f5f9";
+ padding = "32px 40px";
+ rows = [row([column([
+ eyebrow("LET’S STAY CONNECTED", "#334155"),
+ heading("Thanks for being here.", {fontSize: "18px"}),
+ text("Good things are better shared. We’re glad you’re part of our community.", {fontSize: "13px"}),
node("content.divider", {}),
- text("Thank you for being part of our community."),
- text("{{organizationAddress|Add your organization address}}"),
- button("Unsubscribe", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
- ];
- else if (name === "announcement")
- content = [heading("Big news, just for you"), text("Share your latest launch, update, or upcoming event with your community."), button("See what’s new", "https://example.com")];
- else if (name === "testimonial")
- content = [heading("What our customers say"), text("“A thoughtful experience from start to finish. We couldn’t be happier.”"), text("— Customer name, Company")];
- else if (name === "two-column") {
- const columns = ["First highlight", "Second highlight"].map(title => node("layout.column", { width: "50%" }, { content: [heading(title), text("Describe a benefit, share an update, or introduce a service."), button("Learn more", "https://example.com")] }));
- return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [node("layout.columns", {}, { columns })] });
- }
- else throw new Error("Unknown section preset.");
- const column = node("layout.column", { width: "100%" }, { content });
- const columns = node("layout.columns", {}, { columns: [column] });
- return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [columns] });
+ text("{{organizationAddress|Add your organization address}}", {fontSize: "12px", color: "#64748b"}),
+ text("You’re receiving this email because you signed up for our updates.", {fontSize: "11px", color: "#64748b"}),
+ button("Unsubscribe", "#e2e8f0", "#334155", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
+ ])])];
+ } else throw new Error("Unknown section preset.");
+ return node("layout.section", {background, padding}, {content: rows});
}
-/** Native, editable section presets; no HTML snapshots. */
export const emailPresets = ["welcome", "feature", "announcement", "testimonial", "two-column", "footer"].map(id => ({
id,
- name: { key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1) },
+ name: {key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1)},
icon: "icon-[lucide--layout-template]",
- create: (context: { id(): string }) => createSectionPreset(id, context.id),
+ create: (context: {id(): string}) => createSectionPreset(id, context.id),
}));
diff --git a/packages/email-renderer/src/rich-text.ts b/packages/email-renderer/src/rich-text.ts
new file mode 100644
index 00000000..86712bab
--- /dev/null
+++ b/packages/email-renderer/src/rich-text.ts
@@ -0,0 +1,26 @@
+import type { ValueSchema } from "@wrnexus/blocks";
+
+export interface TextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
+export const richTextSchema: ValueSchema = {
+ kind: "rich-text",
+ parse(value = [], path = "richText") {
+ if (!Array.isArray(value) || value.length > 4000 || value.some(run => !run || typeof run !== "object" || typeof run.text !== "string" || Object.keys(run).some(key => !["text", "bold", "italic", "underline"].includes(key)) || ["bold", "italic", "underline"].some(key => run[key] !== undefined && typeof run[key] !== "boolean")) || value.reduce((size, run) => size + run.text.length, 0) > 200000)
+ return { ok: false, errors: [{code: "email.rich-text", message: "Invalid or oversized rich text.", severity: "error", path}] };
+ return { ok: true, value };
+ },
+};
+
+const escape = (value: string) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'").replace(/\r\n?|\n/g, "
");
+/** Ignore stale formatting if a plain-text inspector or importer changed the text. */
+export function renderRichText(props: Record): string {
+ const parsed = richTextSchema.parse(props.richText);
+ const text = String(props.text ?? "");
+ if (!parsed.ok || !parsed.value.length || parsed.value.map(run => run.text).join("") !== text) return escape(text);
+ return parsed.value.map(run => {
+ let result = escape(run.text);
+ if (run.underline) result = `${result}`;
+ if (run.italic) result = `${result}`;
+ if (run.bold) result = `${result}`;
+ return result;
+ }).join("");
+}
diff --git a/packages/email-renderer/test/preset-designs.test.ts b/packages/email-renderer/test/preset-designs.test.ts
new file mode 100644
index 00000000..79293ebe
--- /dev/null
+++ b/packages/email-renderer/test/preset-designs.test.ts
@@ -0,0 +1,21 @@
+import { expect, test } from "bun:test";
+import { createSectionPreset, emailPresets } from "../src/presets.ts";
+import { renderEmailPreview } from "../src/browser.ts";
+import type { BlockDocument } from "@wrnexus/blocks";
+
+test("every designed preset renders natively without remote placeholder assets", async () => {
+ for (const preset of emailPresets) {
+ let id = 0;
+ const section = createSectionPreset(preset.id, () => `preset-${++id}`);
+ const document: BlockDocument = {schemaVersion:1,kind:"email",meta:{subject:"Preset QA"},theme:{tokens:{}},root:{id:"root",type:"layout.email-document",version:1,props:{width:"600"},styles:{},slots:{content:[section]}}};
+ const before = JSON.stringify(document);
+ const result = await renderEmailPreview(document, {variables:{recipientName:"Alex",organizationAddress:"Company address",unsubscribeUrl:"https://example.com/unsubscribe"}});
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.value.html).not.toContain("placehold.co");
+ expect(result.value.html).toContain(String(section.props.background));
+ expect(result.value.text.length).toBeGreaterThan(80);
+ }
+ expect(JSON.stringify(document)).toBe(before);
+ }
+});
diff --git a/packages/email-renderer/test/rich-text.test.ts b/packages/email-renderer/test/rich-text.test.ts
new file mode 100644
index 00000000..cc529827
--- /dev/null
+++ b/packages/email-renderer/test/rich-text.test.ts
@@ -0,0 +1,10 @@
+import { expect, test } from "bun:test";
+import { renderRichText, richTextSchema } from "../src/rich-text.ts";
+
+test("semantic text is escaped, bounded, and rejects arbitrary markup properties", () => {
+ expect(renderRichText({text:"\nworld",richText:[{text:"",bold:true},{text:"\nworld",italic:true,underline:true}]})).toBe("<hello>
world");
+ expect(richTextSchema.parse([{text:"hello",html:"",
+ src: '/file?x="',
+ });
+ expect(html).toContain('data-wrnexus-runtime="viewer"');
+ expect(html).not.toContain("");
+ expect(html).toContain('data-component="ViewerShell"');
+});
+
+test("framework bundles editor separately and injects only the viewer runtime", async () => {
+ const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
+ const assets = mergePluginAssets(runtimes, []);
+ const viewer = assets.find((asset) => asset.id === "runtime:viewer")!;
+ const editor = assets.find((asset) => asset.id === "runtime:viewer-code")!;
+ const decode = (body: string | ArrayBuffer) =>
+ typeof body === "string" ? body : new TextDecoder().decode(body);
+ const main = decode(await readPluginAsset(viewer));
+ expect(main).not.toContain("class EditorView");
+ expect(main).toContain("/__wrnexus/assets/viewer-code.js");
+ expect(decode(await readPluginAsset(editor))).toContain("mountBuilderSourceEditor");
+ expect(
+ runtimeScriptsForMarkup('', runtimes),
+ ).toHaveLength(1);
+});
+
+test("example page compiles with explicit Viewer import", () => {
+ const path = new URL("../../../examples/basic-app/app/pages/file-viewer.wrn", import.meta.url);
+ expect(compileWrnFile(readFileSync(path, "utf8"), path.pathname)).toContain("Viewer");
+});
+test("plugin contributes existing browser assets and mounts no conversion routes", () => {
+ const plugin = viewerPlugin();
+ expect(plugin.routeEntries).toBeUndefined();
+ expect(Array.isArray(plugin.componentDirs)).toBe(true);
+ for (const file of [
+ new URL("../src/runtime.ts", import.meta.url),
+ new URL("../viewer.css", import.meta.url),
+ ])
+ expect(readFileSync(file, "utf8").length).toBeGreaterThan(0);
+ // The two core runtimes, plus one per installed optional parser (and the
+ // pdf.js worker). Only the core viewer runtime is ever injected into a page.
+ const ids = (plugin.clientRuntimes as ClientRuntimeDefinition[]).map((runtime) => runtime.id);
+ expect(ids.slice(0, 2)).toEqual(["viewer-code", "viewer"]);
+ for (const id of ids.slice(2)) expect(id.startsWith("viewer-")).toBe(true);
+ expect(plugin.styleSources).toHaveLength(1);
+});
+
+test("optional parsers are contributed only when their package resolves", () => {
+ const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
+ const ids = new Set(runtimes.map((runtime) => runtime.id));
+ const resolvable = (specifier: string) => {
+ try {
+ createRequire(new URL("../src/plugin.ts", import.meta.url)).resolve(specifier);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ for (const [id, specifier] of [
+ ["viewer-sheet", "xlsx"],
+ ["viewer-docx", "mammoth"],
+ ["viewer-zip", "fflate"],
+ ["viewer-pdfjs", "pdfjs-dist"],
+ ] as const)
+ expect(ids.has(id)).toBe(resolvable(specifier));
+ // pdf.js is useless without its worker asset.
+ expect(ids.has("viewer-pdfjs-worker")).toBe(ids.has("viewer-pdfjs"));
+});
+
+test("optional runtimes are served but never injected into a page", () => {
+ const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
+ const markup = '';
+ expect(runtimeScriptsForMarkup(markup, runtimes)).toHaveLength(1);
+ const assets = mergePluginAssets(runtimes, []);
+ for (const id of ["viewer-sheet", "viewer-zip", "viewer-docx", "viewer-pdfjs"]) {
+ if (!runtimes.some((runtime) => runtime.id === id)) continue;
+ const asset = assets.find((item) => item.id === `runtime:${id}`)!;
+ expect(asset.publicPath).toBe(`/__wrnexus/assets/${id}.js`);
+ }
+});
+
+test("each optional runtime bundles for the browser and exports its parser", async () => {
+ const runtimes = viewerPlugin().clientRuntimes as ClientRuntimeDefinition[];
+ const assets = mergePluginAssets(runtimes, []);
+ const decode = (body: string | ArrayBuffer) =>
+ typeof body === "string" ? body : new TextDecoder().decode(body);
+ for (const [id, exported] of [
+ ["viewer-sheet", "readWorkbook"],
+ ["viewer-docx", "docxToHtml"],
+ ["viewer-zip", "listZip"],
+ ["viewer-pdfjs", "renderPdf"],
+ ] as const) {
+ const asset = assets.find((item) => item.id === `runtime:${id}`);
+ if (!asset) continue;
+ const code = decode(await readPluginAsset(asset));
+ expect(code.length).toBeGreaterThan(0);
+ expect(code).toContain(exported);
+ }
+}, 120_000);
diff --git a/packages/viewer/test/convert.test.ts b/packages/viewer/test/convert.test.ts
new file mode 100644
index 00000000..abef07fc
--- /dev/null
+++ b/packages/viewer/test/convert.test.ts
@@ -0,0 +1,203 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { configureConversion } from "../src/convert/config.ts";
+import { handleCapabilities, handleConvert } from "../src/convert/service.ts";
+import { localBinaryConverter } from "../src/convert/local.ts";
+import { resetConverterProbe } from "../src/convert/client.ts";
+import { viewerPlugin } from "../src/plugin.ts";
+import { resolve } from "../src/resolve.ts";
+import type { ViewerConverter } from "../src/convert/types.ts";
+
+const encoder = new TextEncoder();
+
+function fakeConverter(overrides: Partial = {}): ViewerConverter {
+ return {
+ name: "fake",
+ version: "1",
+ supports: (_kind, ext) => ext === ".docx",
+ outputType: (ext) => (ext === ".docx" ? "application/pdf" : undefined),
+ toPreview: async () => ({ mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") }),
+ ...overrides,
+ };
+}
+
+const request = (path: string) => new Request(`https://app.test${path}`);
+const convertRequest = (src: string, name: string) =>
+ request(`/api/viewer/preview?src=${encodeURIComponent(src)}&name=${encodeURIComponent(name)}`);
+
+afterEach(() => {
+ configureConversion(undefined);
+ resetConverterProbe();
+});
+
+describe("conversion routes", () => {
+ test("everything 404s until conversion is configured", async () => {
+ expect(handleCapabilities().status).toBe(404);
+ expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(404);
+ });
+
+ test("capabilities advertise only what a converter claims", async () => {
+ configureConversion({ converters: [fakeConverter()] });
+ const body = (await handleCapabilities().json()) as {
+ converters: string[];
+ formats: Record;
+ };
+ expect(body.converters).toEqual(["fake"]);
+ expect(body.formats[".docx"]).toBe("application/pdf");
+ expect(body.formats[".psd"]).toBeUndefined();
+ });
+
+ test("unknown extensions and unsupported formats are refused, not attempted", async () => {
+ configureConversion({ converters: [fakeConverter()] });
+ expect((await handleConvert(convertRequest("/a.foo", "a.foo"), "preview")).status).toBe(415);
+ expect((await handleConvert(convertRequest("/a.psd", "a.psd"), "preview")).status).toBe(415);
+ expect((await handleConvert(request("/api/viewer/preview"), "preview")).status).toBe(400);
+ });
+
+ test("the default source policy refuses cross-origin URLs without fetching", async () => {
+ let fetched = false;
+ const original = globalThis.fetch;
+ globalThis.fetch = (async () => {
+ fetched = true;
+ return new Response("x");
+ }) as unknown as typeof fetch;
+ try {
+ configureConversion({ converters: [fakeConverter()] });
+ const response = await handleConvert(
+ convertRequest("https://evil.test/a.docx", "a.docx"),
+ "preview",
+ );
+ expect(response.status).toBe(403);
+ expect(fetched).toBe(false);
+ } finally {
+ globalThis.fetch = original;
+ }
+ });
+
+ test("a converted result is returned, then served from cache", async () => {
+ let conversions = 0;
+ configureConversion({
+ converters: [
+ fakeConverter({
+ toPreview: async () => {
+ conversions++;
+ return { mime: "application/pdf", bytes: encoder.encode("%PDF-1.7 fake") };
+ },
+ }),
+ ],
+ resolveSource: async () => ({
+ body: new Response(encoder.encode("docx bytes")).body!,
+ size: 10,
+ }),
+ });
+ const first = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
+ expect(first.status).toBe(200);
+ expect(first.headers.get("content-type")).toBe("application/pdf");
+ expect(first.headers.get("x-content-type-options")).toBe("nosniff");
+ expect(first.headers.get("x-viewer-cache")).toBe("miss");
+ expect(await first.text()).toContain("%PDF");
+
+ const second = await handleConvert(convertRequest("/files/a.docx", "a.docx"), "preview");
+ expect(second.headers.get("x-viewer-cache")).toBe("hit");
+ expect(conversions).toBe(1);
+ });
+
+ test("oversized sources are refused before the converter runs", async () => {
+ let ran = false;
+ configureConversion({
+ maxInputBytes: 8,
+ converters: [
+ fakeConverter({
+ toPreview: async () => {
+ ran = true;
+ return { mime: "application/pdf", bytes: new Uint8Array() };
+ },
+ }),
+ ],
+ resolveSource: async () => ({ body: new Response("x").body!, size: 999 }),
+ });
+ expect((await handleConvert(convertRequest("/a.docx", "a.docx"), "preview")).status).toBe(413);
+ expect(ran).toBe(false);
+ });
+
+ test("a converter failure reports a generic error, never its own message", async () => {
+ configureConversion({
+ converters: [
+ fakeConverter({
+ toPreview: async () => {
+ throw new Error("soffice crashed at C:/secret/path");
+ },
+ }),
+ ],
+ resolveSource: async () => ({ body: new Response("x").body! }),
+ });
+ const response = await handleConvert(convertRequest("/a.docx", "a.docx"), "preview");
+ expect(response.status).toBe(422);
+ expect(await response.text()).not.toContain("secret");
+ });
+});
+
+describe("plugin wiring", () => {
+ test("routes appear only when conversion is configured", () => {
+ expect(viewerPlugin().routeEntries).toBeUndefined();
+ const routes = viewerPlugin({ convert: { converters: [fakeConverter()] } }).routeEntries as {
+ path: string;
+ }[];
+ expect(routes.map((route) => route.path)).toEqual([
+ "/api/viewer/capabilities",
+ "/api/viewer/preview",
+ "/api/viewer/thumb",
+ "/api/viewer/text",
+ ]);
+ // Configuring the plugin is what arms the handlers.
+ expect(handleCapabilities().status).toBe(200);
+ });
+});
+
+describe("local binary converter", () => {
+ test("a disabled binary makes its formats unsupported", () => {
+ const converter = localBinaryConverter({
+ binaries: { soffice: false, magick: false, ffmpeg: false, "7z": false },
+ });
+ expect(converter.supports("doc", ".docx")).toBe(false);
+ expect(converter.outputType(".psd")).toBeUndefined();
+ });
+ test("an unknown extension is never routed to a binary", () => {
+ expect(localBinaryConverter().supports("binary", ".xyz")).toBe(false);
+ });
+ test("conversion of an unsupported format rejects rather than spawning", async () => {
+ const converter = localBinaryConverter({ binaries: { soffice: false } });
+ await expect(
+ converter.toPreview(new Response("x").body!, {
+ name: "a.docx",
+ ext: ".docx",
+ kind: "doc",
+ signal: new AbortController().signal,
+ }),
+ ).rejects.toThrow("No local converter");
+ });
+});
+
+describe("tier 3 planning", () => {
+ test("office and design files keep their extension kind despite generic bytes", async () => {
+ // A .docx is a ZIP and a .psd is opaque binary; neither signature identifies
+ // the content, so the extension must still choose the converter.
+ const docx = await resolve({
+ name: "report.docx",
+ blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
+ });
+ expect(docx.kind).toBe("doc");
+ const psd = await resolve({
+ name: "art.psd",
+ blob: new Blob([new Uint8Array([0x38, 0x42, 0x50, 0x53, 0, 1])]),
+ });
+ expect(psd.kind).toBe("binary");
+ });
+ test("without a reachable converter they fall back and ask for no conversion", async () => {
+ const plan = await resolve({
+ name: "report.docx",
+ blob: new Blob([new Uint8Array([0x50, 0x4b, 3, 4, 0, 0])]),
+ });
+ expect(plan.renderer.component).toBe("DownloadCard");
+ expect(plan.needsConvert).toBe(false);
+ });
+});
diff --git a/packages/viewer/test/follow-ups.test.ts b/packages/viewer/test/follow-ups.test.ts
new file mode 100644
index 00000000..11ddb6ea
--- /dev/null
+++ b/packages/viewer/test/follow-ups.test.ts
@@ -0,0 +1,259 @@
+import { afterEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { firstOf, sameOriginSource } from "../src/convert/sources.ts";
+import { configureConversion } from "../src/convert/config.ts";
+import { handleConvert } from "../src/convert/service.ts";
+import { converterFormats, convertUrl, resetConverterProbe } from "../src/convert/client.ts";
+import { loadOptional } from "../src/optional/load.ts";
+import { sanitizeHtml } from "../src/parse/html.ts";
+import { waveformPeaks } from "../src/renderers/waveform.ts";
+import { mountViewer } from "../src/runtime.ts";
+import { registerRenderer } from "../src/registry.ts";
+import { extractText } from "../src/text.ts";
+
+afterEach(() => {
+ configureConversion(undefined);
+ resetConverterProbe();
+});
+const tick = () => new Promise((resolve) => setTimeout(resolve, 40));
+
+test("runtime preserves overridden chrome and mounts inside the renderer presentation", async () => {
+ const window = new Window();
+ const host = window.document.createElement("section");
+ window.document.body.append(host);
+ // Trusted test fixture representing SSR output from an overridden component.
+ host.innerHTML =
+ '';
+ const before = host.innerHTML;
+ const dispose = mountViewer(host as unknown as HTMLElement, {
+ name: "photo.png",
+ blob: new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])]),
+ });
+ await tick();
+ expect(host.querySelector(".custom-toolbar [data-viewer-title]")?.textContent).toBe("photo.png");
+ expect(
+ host.querySelector(".custom-content .custom-image [data-viewer-mount] img"),
+ ).not.toBeNull();
+ dispose();
+ expect(host.innerHTML).toBe(before);
+ await window.happyDOM.close();
+});
+
+test("filmstrip consumes the thumbnail endpoint and releases its object URLs", async () => {
+ const window = new Window({ url: "https://thumb.test/demo" });
+ const host = window.document.createElement("section");
+ window.document.body.append(host);
+ const saved = globalThis.fetch;
+ const requests: string[] = [];
+ globalThis.fetch = (async (input, init) => {
+ const url = new URL(String(input));
+ requests.push(url.pathname);
+ if (url.pathname.endsWith("/capabilities"))
+ return Response.json({ formats: { ".psd": "image/png" } });
+ if (url.pathname.endsWith("/thumb"))
+ return new Response(new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), {
+ headers: { "content-type": "image/png" },
+ });
+ if (init?.method === "HEAD") return new Response(null, { headers: { "content-length": "8" } });
+ return new Response(new Uint8Array([56, 66, 80, 83, 0, 0, 0, 0]));
+ }) as typeof fetch;
+ let dispose: (() => void) | undefined;
+ try {
+ dispose = mountViewer(host as unknown as HTMLElement, [
+ { name: "art.psd", src: "https://thumb.test/art.psd" },
+ ]);
+ await tick();
+ expect(requests).toContain("/api/viewer/thumb");
+ const image = host.querySelector(".wrn-viewer-file img") as unknown as HTMLImageElement;
+ expect(image).not.toBeNull();
+ const url = image.src;
+ dispose();
+ dispose = undefined;
+ await expect(saved(url)).rejects.toThrow();
+ } finally {
+ dispose?.();
+ globalThis.fetch = saved;
+ await window.happyDOM.close();
+ }
+});
+test("rotating links hit one stable cache entry, but denied access never reads it", async () => {
+ let allowed = true;
+ let conversions = 0;
+ let cancellations = 0;
+ configureConversion({
+ converters: [
+ {
+ name: "stable-test",
+ supports: () => true,
+ outputType: () => "application/pdf",
+ toPreview: async () => {
+ conversions++;
+ return { mime: "application/pdf", bytes: new TextEncoder().encode("%PDF") };
+ },
+ },
+ ],
+ resolveSource: async () =>
+ allowed
+ ? {
+ identity: "reports:key:revision1",
+ body: new ReadableStream({
+ cancel() {
+ cancellations++;
+ },
+ }),
+ }
+ : undefined,
+ });
+ const request = (token: string) =>
+ new Request(
+ `https://app.test/api/viewer/preview?name=a.docx&src=${encodeURIComponent("/api/viewer/file?token=" + token)}`,
+ );
+ expect((await handleConvert(request("first"), "preview")).headers.get("x-viewer-cache")).toBe(
+ "miss",
+ );
+ expect((await handleConvert(request("rotated"), "preview")).headers.get("x-viewer-cache")).toBe(
+ "hit",
+ );
+ expect(conversions).toBe(1);
+ expect(cancellations).toBeGreaterThanOrEqual(1);
+ allowed = false;
+ expect((await handleConvert(request("rotated"), "preview")).status).toBe(403);
+});
+test("source composition falls through on undefined and stops on errors", async () => {
+ const seen: string[] = [];
+ const result = await firstOf(
+ async () => {
+ seen.push("upload");
+ return undefined;
+ },
+ async () => {
+ seen.push("static");
+ return { body: new Response("static").body!, identity: "static" };
+ },
+ )(new Request("https://app.test"), { src: "/a", name: "a" });
+ expect(result?.identity).toBe("static");
+ expect(seen).toEqual(["upload", "static"]);
+ await expect(
+ firstOf(
+ async () => {
+ throw new Error("denied");
+ },
+ async () => {
+ throw new Error("must not run");
+ },
+ )(new Request("https://app.test"), { src: "/a", name: "a" }),
+ ).rejects.toThrow("denied");
+});
+test("same-origin policy refuses viewer API links instead of bypassing private policy", async () => {
+ const source = sameOriginSource();
+ for (const src of [
+ "/api/viewer/file?token=bad",
+ "https://other.test/a",
+ "https://user:pass@app.test/a",
+ ])
+ expect(await source(new Request("https://app.test"), { src, name: "a.docx" })).toBeUndefined();
+});
+test("client helpers use the supplied document origin without global document", async () => {
+ const fetched: string[] = [];
+ const request = (async (url: string) => {
+ fetched.push(String(url));
+ return Response.json({ formats: { ".docx": "application/pdf" } });
+ }) as unknown as typeof fetch;
+ expect(await converterFormats({ baseURI: "https://one.test/page", fetch: request })).toEqual({
+ ".docx": "application/pdf",
+ });
+ expect(fetched).toEqual(["https://one.test/api/viewer/capabilities"]);
+ expect(convertUrl({ src: "/file", name: "a.docx" }, "text", "https://two.test/page")).toContain(
+ "https://two.test/api/viewer/text",
+ );
+ expect(
+ await loadOptional<{ ok: boolean }>("viewer-sheet", "https://three.test/page", async (url) => {
+ fetched.push(url);
+ return { ok: true };
+ }),
+ ).toEqual({ ok: true });
+ expect(fetched[1]).toBe("https://three.test/__wrnexus/assets/viewer-sheet.js");
+});
+test("HTML sanitizer preserves text and layout while removing active and external content", async () => {
+ const window = new Window();
+ const html = sanitizeHtml(
+ 'Hello
link
safe
',
+ window as unknown as { DOMParser: new () => DOMParser },
+ );
+ expect(html).toContain("Hello
");
+ for (const bad of [
+ "script",
+ "onclick",
+ "iframe",
+ "javascript:",
+ "https://evil",
+ "onerror",
+ " {
+ expect(
+ waveformPeaks([new Float32Array([0, 0.5, 0, 0]), new Float32Array([0, 0, -2, 0])], 2),
+ ).toEqual([0.5, 1]);
+ expect(waveformPeaks([new Float32Array(4)], 2)).toEqual([0, 0]);
+});
+test("filmstrip switching disposes each active renderer and supports keyboard selection", async () => {
+ const window = new Window();
+ const host = window.document.createElement("section");
+ window.document.body.append(host);
+ const clean: string[] = [];
+ const unregister = registerRenderer({
+ kind: "text",
+ exts: [".txt"],
+ tier: 2,
+ component: "TextTest",
+ mount: ({ parent, source }) => {
+ parent.textContent = source.name;
+ return () => {
+ clean.push(source.name);
+ };
+ },
+ });
+ const dispose = mountViewer(host as unknown as HTMLElement, [
+ { name: "one.txt", blob: new Blob(["one"]) },
+ { name: "two.txt", blob: new Blob(["two"]) },
+ ]);
+ await tick();
+ const buttons = host.querySelectorAll(".wrn-viewer-file");
+ (buttons[1] as unknown as HTMLButtonElement).click();
+ await tick();
+ expect(clean).toEqual(["one.txt"]);
+ expect(buttons[1]!.getAttribute("aria-pressed")).toBe("true");
+ buttons[1]!.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
+ await tick();
+ expect(clean).toEqual(["one.txt", "two.txt"]);
+ expect(buttons[0]!.getAttribute("aria-pressed")).toBe("true");
+ dispose();
+ expect(clean).toEqual(["one.txt", "two.txt", "one.txt"]);
+ expect(host.children.length).toBe(0);
+ unregister();
+ await window.happyDOM.close();
+});
+test("text extraction rejects non-text and enforces bounded output", async () => {
+ const source = { src: "/a.docx", name: "a.docx" };
+ expect(
+ await extractText(source, {
+ baseURI: "https://app.test",
+ fetch: (async () =>
+ new Response("search words", {
+ headers: { "content-type": "text/plain" },
+ })) as unknown as typeof fetch,
+ }),
+ ).toBe("search words");
+ await expect(
+ extractText(source, {
+ baseURI: "https://app.test",
+ fetch: (async () =>
+ new Response("%PDF", {
+ headers: { "content-type": "application/pdf" },
+ })) as unknown as typeof fetch,
+ }),
+ ).rejects.toThrow("unavailable");
+});
diff --git a/packages/viewer/test/office.test.ts b/packages/viewer/test/office.test.ts
new file mode 100644
index 00000000..802423b9
--- /dev/null
+++ b/packages/viewer/test/office.test.ts
@@ -0,0 +1,203 @@
+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");
+ });
+});
diff --git a/packages/viewer/test/optional.test.ts b/packages/viewer/test/optional.test.ts
new file mode 100644
index 00000000..85f9c403
--- /dev/null
+++ b/packages/viewer/test/optional.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, test } from "bun:test";
+import { docxToHtml } from "../src/optional/docx.ts";
+import { readWorkbook } from "../src/optional/sheet.ts";
+import { listZip } from "../src/optional/zip.ts";
+import { zipSync, strToU8 } from "fflate";
+
+/**
+ * These exercise the parsers themselves. The browser wiring — one runtime asset
+ * per installed package, fetched on first use — is covered in component.test.ts.
+ */
+describe("zip listing", () => {
+ const archive = zipSync({
+ "docs/readme.md": strToU8("hello world\n".repeat(20)),
+ "src/index.ts": strToU8("export const value = 1;\n"),
+ });
+
+ test("entries are listed with their uncompressed sizes", async () => {
+ const entries = await listZip(archive);
+ expect(entries.map((entry) => entry.name).sort()).toEqual(["docs/readme.md", "src/index.ts"]);
+ expect(entries.find((entry) => entry.name === "docs/readme.md")!.size).toBe(240);
+ });
+
+ test("a corrupt archive rejects rather than resolving empty", async () => {
+ await expect(listZip(new Uint8Array([0x50, 0x4b, 3, 4, 9, 9, 9]))).rejects.toThrow();
+ });
+});
+
+describe("workbook reading", () => {
+ // Built with the same shape as examples/basic-app/public/viewer/sample.xlsx.
+ const NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+ const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ const sheet = (rows: (string | number)[][]) =>
+ `${rows
+ .map(
+ (row, rowIndex) =>
+ `${row
+ .map((value, index) => {
+ const ref = `${String.fromCharCode(65 + index)}${rowIndex + 1}`;
+ return typeof value === "number"
+ ? `${value}`
+ : `${value}`;
+ })
+ .join("")}
`,
+ )
+ .join("")}`;
+
+ const workbook = zipSync({
+ "[Content_Types].xml": strToU8(
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ "",
+ ),
+ "_rels/.rels": strToU8(
+ ``,
+ ),
+ "xl/workbook.xml": strToU8(
+ `` +
+ '' +
+ '' +
+ "",
+ ),
+ "xl/_rels/workbook.xml.rels": strToU8(
+ `` +
+ `` +
+ `` +
+ "",
+ ),
+ "xl/worksheets/sheet1.xml": strToU8(
+ sheet([
+ ["Region", "Q1"],
+ ["North", 15400],
+ ["South", 11200],
+ ]),
+ ),
+ "xl/worksheets/sheet2.xml": strToU8(
+ sheet([
+ ["Team", "People"],
+ ["Engineering", 42],
+ ]),
+ ),
+ });
+
+ test("every sheet comes back named, with a header and string cells", () => {
+ const sheets = readWorkbook(workbook);
+ expect(sheets.map((item) => item.name)).toEqual(["Revenue", "Headcount"]);
+ expect(sheets[0]!.header).toEqual(["Region", "Q1"]);
+ expect(sheets[0]!.rows).toEqual([
+ ["North", "15400"],
+ ["South", "11200"],
+ ]);
+ expect(sheets[1]!.rows).toEqual([["Engineering", "42"]]);
+ });
+});
+
+describe("document conversion", () => {
+ const W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ const R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ const paragraph = (text: string, style?: string) =>
+ `${style ? `` : ""}` +
+ `${text}`;
+ const document = zipSync({
+ "[Content_Types].xml": strToU8(
+ '' +
+ '' +
+ '' +
+ '' +
+ "",
+ ),
+ "_rels/.rels": strToU8(
+ ``,
+ ),
+ "word/document.xml": strToU8(
+ `` +
+ paragraph("Quarterly summary", "Heading1") +
+ paragraph("Bodies of text survive; page layout does not.") +
+ "",
+ ),
+ });
+
+ test("a docx becomes semantic HTML", async () => {
+ const html = await docxToHtml(document);
+ expect(html).toContain("Quarterly summary
");
+ expect(html).toContain("Bodies of text survive");
+ // Semantic output only: no document chrome, and nothing executable.
+ expect(html).not.toContain("