diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f5770c1f4516b..ba1e1e612e6d3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -355,16 +355,22 @@ jobs:
# The web suite is the largest outside the server. In the shared Test job it
# started last and ran alone for about 40s after everything else finished.
- # release.yml mirrors this test, test_web, and test_server split.
+ # Browser tests are bound by the Vite server transforming modules for each
+ # file, not by Chromium, so a second runner (and Vite server) halves the job
+ # that gated every run. release.yml mirrors this test, test_web, and test_server split.
test_web:
permissions:
contents: read
id-token: write
- name: Test Web
+ name: Test Web ${{ matrix.shard }}
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2]
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -406,7 +412,7 @@ jobs:
fi
- name: Test
- run: vp run --filter @t3tools/web test
+ run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
# apps/server sets `fileParallelism: false`, so its test files run strictly
# one at a time. Sharding spreads them over separate runners instead of
@@ -481,6 +487,17 @@ jobs:
node apps/server/node_modules/playwright-core/cli.js install-deps chromium
fi
+ # Each server test file runs in its own Vitest process, and a fresh install starts
+ # Vitest's content-keyed module cache empty, so every file re-transformed its imports.
+ # Only pushes to main save it, so pull requests never evict each other's entries.
+ - name: Restore Vitest module cache
+ if: steps.changes.outputs.test_server != 'false'
+ uses: actions/cache/restore@v6
+ with:
+ path: node_modules/.vitest-cache
+ key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
+ restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
+
- name: Test
if: steps.changes.outputs.test_server != 'false'
env:
@@ -491,6 +508,13 @@ jobs:
--uncached apps/server/integration/transferBudgetV2.integration.test.ts
--verify-hits 1 apps/server
+ - name: Save Vitest module cache
+ if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false'
+ uses: actions/cache/save@v6
+ with:
+ path: node_modules/.vitest-cache
+ key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
+
# integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard
# produces these files. Gating the upload on their presence keeps a
# single `thread-transfer-results` artifact per run, which is the name
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84acc7c74f865..8827876b3829f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -300,11 +300,15 @@ jobs:
# Web browser tests time out when they share a runner with the other package
# suites: Chromium gets too little CPU and diff workers miss their 10s start budget.
test_web:
- name: Release web tests
+ name: Release web tests ${{ matrix.shard }}
needs: [preflight]
if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 10
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2]
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -344,10 +348,11 @@ jobs:
fi
- name: Test
- run: vp run --filter @t3tools/web test
+ run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }}
# Browser tests run source modules; this boots the production chunk graph.
- name: Boot the built web client
+ if: matrix.shard == 1
run: |
vp run --filter @t3tools/web build
node apps/web/scripts/verify-built-boot.ts
diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx
index 4ecc965f6fe32..285032b5b6a0a 100644
--- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx
+++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx
@@ -78,6 +78,7 @@ import { decodeImagePreview } from "./decodeImagePreview";
import type { BinaryWorkerResult } from "./binaryPreviewProtocol";
import type { TextPreviewResult } from "./largeTextProtocol";
import type { ReactElement } from "react";
+import type { OfficeWorkerResult } from "./officePreviewProtocol";
// Real worker imports and WASM initialization can exceed waitFor's default second
// when the full browser project runs concurrently on CI.
@@ -481,8 +482,6 @@ describe("bounded binary previews", () => {
}
});
});
-import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol";
-import { WORD_LIMITS } from "./officeArchive";
describe("large text preview recovery", () => {
it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => {
@@ -921,1046 +920,6 @@ describe("large text preview recovery", () => {
await vi.waitFor(() => expect(load).toHaveBeenCalledWith(0));
});
});
-it.each(["", ""])(
- "opens an empty Word document with %s",
- async (body) => {
- const { default: JSZip } = await import("jszip");
- const { prepareOffice } = await import("./officeArchive");
- const archive = new JSZip().file(
- "word/document.xml",
- `${body}`,
- );
- const prepared = await prepareOffice(
- await archive.generateAsync({ type: "arraybuffer" }),
- "docx",
- );
- const result = await JSZip.loadAsync(prepared.bytes);
- const xml = new DOMParser().parseFromString(
- await result.file("word/document.xml")!.async("string"),
- "application/xml",
- );
- expect(xml.querySelector("parsererror")).toBeNull();
- expect(
- xml.getElementsByTagNameNS(
- "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
- "body",
- ),
- ).toHaveLength(1);
- },
-);
-
-it.each([false, true])(
- "rejects excessive archive entries before JSZip (directories: %s)",
- async (directories) => {
- const { default: JSZip } = await import("jszip");
- const { prepareOffice } = await import("./officeArchive");
- const archive = new JSZip();
- for (let i = 0; i < 4096; i++)
- archive.file(`entry-${i}${directories ? "/" : ".txt"}`, "", { dir: directories });
- const bytes = await archive.generateAsync({ type: "arraybuffer" });
- const load = vi.spyOn(JSZip, "loadAsync");
- try {
- await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries");
- expect(load).not.toHaveBeenCalled();
- // The actual central-directory records must also be bounded if EOCD lies.
- const view = new DataView(bytes);
- view.setUint16(bytes.byteLength - 22 + 8, 1, true);
- view.setUint16(bytes.byteLength - 22 + 10, 1, true);
- await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries");
- expect(load).not.toHaveBeenCalled();
- } finally {
- load.mockRestore();
- }
- },
-);
-
-it("rejects oversized embedded images before decoding pixels and retains ordinary images", async () => {
- const { officeImageBitmap } = await import("./office/image-bitmap");
- const canvas = new OffscreenCanvas(2, 3);
- canvas.getContext("2d");
- const blob = await canvas.convertToBlob({ type: "image/png" });
- const bytes = await blob.arrayBuffer();
- new DataView(bytes).setUint32(16, 12000);
- new DataView(bytes).setUint32(20, 12000);
- const decode = vi.spyOn(globalThis, "createImageBitmap");
- try {
- await expect(officeImageBitmap(new Blob([bytes]))).rejects.toThrow("pixel limit");
- expect(decode).not.toHaveBeenCalled();
- const bitmap = await officeImageBitmap(blob);
- expect([bitmap.width, bitmap.height]).toEqual([2, 3]);
- bitmap.close();
- } finally {
- decode.mockRestore();
- }
-});
-
-it.each(["utf-16le", "utf-16be", "windows-1252"])(
- "preserves %s Office XML and safe hyperlink targets",
- async (encoding) => {
- const { default: JSZip } = await import("jszip");
- const { prepareOffice } = await import("./officeArchive");
- const archive = new JSZip();
- const encode = (text: string) => {
- const xml = `${text}`;
- if (encoding === "windows-1252") return Uint8Array.from(xml, (char) => char.charCodeAt(0));
- const bytes = new Uint8Array(2 + xml.length * 2);
- const view = new DataView(bytes.buffer);
- const little = encoding === "utf-16le";
- view.setUint16(0, 0xfeff, little);
- for (let i = 0; i < xml.length; i++) view.setUint16(2 + i * 2, xml.charCodeAt(i), little);
- return bytes;
- };
- archive.file(
- "word/document.xml",
- encode(
- 'café',
- ),
- );
- archive.file(
- "word/_rels/document.xml.rels",
- encode(
- `${["https://example.com/page", "http://example.com", "mailto:maria@example.com", "javascript:alert(1)", "file:///tmp/private"].map((target, i) => ``).join("")}`,
- ),
- );
- const prepared = await prepareOffice(
- await archive.generateAsync({ type: "arraybuffer" }),
- "docx",
- );
- const result = await JSZip.loadAsync(prepared.bytes);
- expect(await result.file("word/document.xml")!.async("string")).toContain("café");
- const links = await result.file("word/_rels/document.xml.rels")!.async("string");
- expect(links).toContain('encoding="UTF-8"');
- expect(links).toContain("https://example.com/page");
- expect(links).toContain("http://example.com");
- expect(links).toContain("mailto:maria@example.com");
- expect(links).not.toContain("javascript:");
- expect(links).not.toContain("file:");
- },
-);
-
-it.each([
- ["", "worksheets/./sheet1.xml", false, false],
- ["x:", "../xl/worksheets/sheet1.xml", false, false],
- ["", "/xl/worksheets/sheet1.xml", true, false],
- ["", "worksheets/sheet1.xml", false, true],
- ["", "worksheets/sheet1.xml", true, true],
-])(
- "preserves %sworksheet metadata with relationship target %s",
- async (prefix, target, empty, localPrefix) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
- archive.file(
- "xl/workbook.xml",
- '',
- );
- archive.file(
- "xl/_rels/workbook.xml.rels",
- ``,
- );
- archive.file(
- "xl/worksheets/sheet1.xml",
- `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/>${prefix}sheetViews><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/>${prefix}cols><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}${prefix}t>${prefix}is>${prefix}c>${prefix}row>${prefix}sheetData><${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>${prefix}mergeCells>${prefix}worksheet>`
- .replace(
- /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g,
- prefix ? "<$1$2>$1>" : "$&",
- )
- .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data))
- .replace(/<(\/?)sheetData\b/g, (tag, closing: string) =>
- localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag,
- )
- .replace(/="([^"]*)"/g, (attribute, value: string) =>
- prefix || empty
- ? ` = '${value.replace(/\d/g, (digit) => `${digit.charCodeAt(0)};`)}'`
- : attribute,
- ),
- );
- const bytes = await archive.generateAsync({ type: "arraybuffer" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]);
- });
- expect(result.type).toBe("xlsx");
- if (result.type !== "xlsx") throw new Error("Expected bounded workbook");
- // The engine counts the chart sheet before it.
- expect(result.view?.hiddenGridlines).toEqual([1]);
- const bounded = await JSZip.loadAsync(result.bytes);
- const sheet = new DOMParser().parseFromString(
- await bounded.file("xl/worksheets/sheet1.xml")!.async("string"),
- "application/xml",
- );
- expect(sheet.querySelector("parsererror")).toBeNull();
- const row = sheet.getElementsByTagNameNS(ns, "row")[0];
- if (empty) expect(row).toBeUndefined();
- else {
- expect(row!.getAttribute("ht")).toBe("42");
- expect(row!.getAttribute("hidden")).toBe("1");
- expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50");
- expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096);
- }
- expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0);
- expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0);
- const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) =>
- ["min", "max", "width"].map((name) => column.getAttribute(name)),
- );
- // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out.
- expect(columns).toEqual([
- [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"],
- // Hidden columns keep their span past the limit, so they stay hidden.
- [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null],
- ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"],
- ]);
- } finally {
- worker.terminate();
- }
- },
-);
-
-it("reports the final slide at maximum scroll after page visibility changes", async () => {
- const { OfficePages } = await import("./office/OfficePages");
- const changed = vi.fn();
- const container = document.createElement("div");
- container.style.cssText = "display:flex;flex-direction:column;width:400px;height:400px";
- await using screen = await render(
- ({ width: 400, height: 240 }))}
- label="Slide"
- paint={async () => undefined}
- onPageChange={changed}
- onError={(error) => {
- throw error;
- }}
- />,
- { container },
- );
- const scroller = screen.container.querySelector("[data-office-pages]")!;
- await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight));
- await act(() => {
- scroller.scrollTop = scroller.scrollHeight;
- });
- await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(5));
- await act(() => {
- scroller.scrollTop = 10;
- });
- await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(0));
-});
-
-it("preserves merged text across tile clears", async () => {
- const { paintFrame } = await import("./office/sheet-frame");
- const canvas = new OffscreenCanvas(400, 80);
- const context = canvas.getContext("2d")!;
- paintFrame(
- context,
- {
- viewport: { x: 0, y: 0, width: 400, height: 80 },
- list: { width: 400, height: 80, commands: [] },
- tiles: [
- {
- x: 0,
- y: 0,
- clipX: 0,
- clipY: 0,
- list: {
- width: 208,
- height: 80,
- commands: [
- {
- op: "text",
- x: 2,
- y: 40,
- text: "MMMMMMMMMMMMMMMMMMMMMMMM",
- fontSize: 20,
- color: "#000",
- clip: { x: 0, y: 0, w: 400, h: 80 },
- },
- ],
- },
- },
- { x: 192, y: 0, clipX: 200, clipY: 0, list: { width: 208, height: 80, commands: [] } },
- ],
- },
- { ratio: 1, origin: { x: 0, y: 0 } },
- );
- const pixels = context.getImageData(240, 15, 100, 35).data;
- expect(pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true);
-});
-
-it.each([
- "paragraphs",
- "runs",
- "tables",
- "auxiliary XML",
- "XML bytes",
- "renamed XML",
- "section after prefix",
- "oversized section",
-])("bounds excessive Word %s before rendering DOM", async (scenario) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const body = ["paragraphs", "section after prefix", "oversized section"].includes(scenario)
- ? "x".repeat(200_000)
- : scenario === "runs"
- ? `${"x".repeat(50_001)}`
- : scenario === "tables"
- ? "".repeat(2 * WORD_LIMITS.bodyElements)
- : scenario === "XML bytes"
- ? `${"x".repeat(2 * 1024 * 1024)}`
- : "";
- const section =
- '';
- archive.file(
- "word/document.xml",
- `${body}${scenario === "section after prefix" ? '' : ""}${scenario === "oversized section" ? `${"".repeat(2_001)}` : section}`,
- );
- if (scenario === "auxiliary XML" || scenario === "renamed XML")
- archive.file(
- scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml",
- `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`,
- );
- const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
- const src = URL.createObjectURL(file);
- try {
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- void file
- .arrayBuffer()
- .then((bytes) => worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]));
- });
- if (
- scenario === "auxiliary XML" ||
- scenario === "renamed XML" ||
- scenario === "oversized section"
- ) {
- expect(result).toMatchObject({
- type: "error",
- message: expect.stringContaining("Preview limited"),
- });
- } else {
- expect(result.type).toBe("docx");
- if (result.type !== "docx") throw new Error("Expected a Word prefix");
- expect(result.truncated).toBe(true);
- const bounded = await JSZip.loadAsync(result.bytes);
- const markup = await bounded.file("word/document.xml")!.async("string");
- {
- const xml = markup;
- const document = new DOMParser().parseFromString(xml, "application/xml");
- expect(document.querySelector("parsererror")).toBeNull();
- const bodies = document.getElementsByTagNameNS("*", "body");
- expect(bodies).toHaveLength(1);
- const section = bodies[0]!.lastElementChild!;
- expect(section.localName).toBe("sectPr");
- const size = section.getElementsByTagNameNS("*", "pgSz")[0]!;
- expect(size.getAttributeNS(section.namespaceURI, "w")).toBe(
- scenario === "section after prefix" ? "11906" : "16838",
- );
- expect(size.getAttributeNS(section.namespaceURI, "orient")).toBe(
- scenario === "section after prefix" ? null : "landscape",
- );
- }
- expect(markup.length).toBeLessThan(body.length);
- expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes);
- expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags);
- }
- } finally {
- worker.terminate();
- }
- } finally {
- URL.revokeObjectURL(src);
- }
-});
-
-it.each([
- "relationship markup",
- "relationship bytes",
- "content type",
- "metadata type",
- "deep XML",
- "query path",
-])("bounds XML hidden in media by %s", async (scenario) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const path = `word/media/payload.png${scenario === "query path" ? "?xml" : ""}`;
- archive.file(
- "word/document.xml",
- '',
- );
- archive.file(
- "[Content_Types].xml",
- `${scenario === "metadata type" ? '' : ""}`,
- );
- archive.file(
- "word/_rels/document.xml.rels",
- `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`,
- );
- archive.file(
- path,
- scenario === "relationship bytes"
- ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}`
- : scenario === "deep XML"
- ? `${"".repeat(65)}${"".repeat(65)}`
- : `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`,
- );
- const bytes = await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
- });
- expect(result).toMatchObject({
- type: "error",
- message: expect.stringContaining("Preview limited"),
- });
- } finally {
- worker.terminate();
- }
-});
-
-const EXPANDED_WORD_ROLES = [
- "footnotes",
- "note roots",
- "endnotes",
- "header",
- "footer",
- "comments",
- "styles",
- "style chain",
- "style count",
- "style visits",
- "numbering",
- "picture numbering",
-] as const;
-
-async function expandedWordFixture(role: (typeof EXPANDED_WORD_ROLES)[number]) {
- const archive = new JSZip();
- const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
- const type = role.startsWith("style")
- ? "styles"
- : role === "picture numbering"
- ? "numbering"
- : role === "note roots"
- ? "footnotes"
- : role;
- archive.file(
- "_rels/.rels",
- '',
- );
- const text = (value: string) => `${value}`;
- // Each copy of the 2,000-paragraph payload costs 6,000 markup units, so enough
- // references (or pages) exceed the expansion budget.
- const copies = Math.ceil(WORD_LIMITS.expandedMarkup / 6_000) + 1;
- archive.file(
- "word/document.xml",
- `${text("body retained")}${Array.from({ length: copies }, () => ``).join("")}`,
- );
- archive.file(
- "word/_rels/document.xml.rels",
- ``,
- );
- const xml =
- role === "style chain" || role === "style count" || role === "style visits"
- ? `${Array.from({ length: role === "style count" ? WORD_LIMITS.styles + 1 : role === "style visits" ? Math.ceil(WORD_LIMITS.styleVisits / 30) + 1 : 1_000 }, (_, i) => `${i ? `= 30 ? 29 : i - 1}"/>` : ""}`).join("")}`
- : role === "styles"
- ? `${Array.from({ length: 100 }, (_, i) => ``).join("")}`
- : role.includes("numbering")
- ? `${role === "picture numbering" ? '' : ""}${Array.from({ length: 9 }, (_, i) => ``).join("")}${Array.from({ length: WORD_LIMITS.numberingReferences + 1 }, (_, i) => ``).join("")}`
- : role === "header" || role === "footer"
- ? `${text("omitted payload").repeat(2_000)}`
- : `${text("omitted payload").repeat(2_000)}`;
- archive.file("word/media/aux.xml", xml);
- if (role === "note roots") {
- // Each note's XML repeats the 32 KB root, which the expansion budget charges twice.
- const notes = Math.ceil(WORD_LIMITS.expandedBytes / 64_000) + 1;
- archive.file(
- "word/document.xml",
- `${text("body retained")}${Array.from({ length: notes }, (_, id) => ``).join("")}`,
- );
- archive.file(
- "word/media/aux.xml",
- `${Array.from({ length: notes }, (_, id) => `${text("omitted payload")}`).join("")}`,
- );
- }
- return archive.generateAsync({ type: "blob", compression: "DEFLATE" });
-}
-
-// Rendering starts a Word engine worker per case, which compiles about 30 MB of WebAssembly and
-// took 10-17 s per test on loaded CI runners. Omission and retention are prepareOffice decisions,
-// so they are checked there.
-it.each(EXPANDED_WORD_ROLES)(
- "keeps the body and omits expanded %s from the prepared document",
- async (role) => {
- const file = await expandedWordFixture(role);
- const { SaxesParser } = await import("saxes");
- const { prepareOffice } = await import("./officeArchive");
- const parse = vi.spyOn(SaxesParser.prototype, "write");
- try {
- const prepared = await prepareOffice(await file.arrayBuffer(), "docx");
- expect(prepared.truncated).toBe(true);
- if (role === "note roots")
- expect(
- parse.mock.calls.reduce(
- (sum, [chunk]) => sum + (typeof chunk === "string" ? chunk.length : 0),
- 0,
- ),
- ).toBeLessThan(WORD_LIMITS.expandedBytes + 4 * 1024 * 1024);
- const bounded = await JSZip.loadAsync(prepared.bytes);
- const markup = await bounded.file("word/document.xml")!.async("string");
- expect(markup).toContain("body retained");
- expect(bounded.file("word/media/aux.xml")).toBeNull();
- // A dangling header or footer reference makes the engine reject the whole body.
- expect(markup).not.toMatch(/ {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
- const r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
- const text = (value: string) => `${value}`;
- archive.file(
- "_rels/.rels",
- ``,
- );
- if (scenario === "python-docx package" || scenario === "binary package properties") {
- const packageRelationships = "http://schemas.openxmlformats.org/package/2006/relationships";
- archive.file(
- "_rels/.rels",
- ``,
- );
- archive.file(
- "[Content_Types].xml",
- '',
- );
- archive.file(
- "docProps/thumbnail.jpeg",
- new Uint8Array([255, 216, 255, 224, 0, 16, 74, 70, 73, 70]),
- );
- archive.file("_xmlsignatures/origin.sigs", new Uint8Array());
- for (const part of ["core", "app", "custom"])
- archive.file(`docProps/${part}.xml`, '');
- if (scenario === "binary package properties") {
- const roles = [
- `${packageRelationships}/metadata/core-properties`,
- `${r}/extended-properties`,
- `${packageRelationships}/metadata/custom-properties`,
- `${packageRelationships}/digital-signature/signature`,
- ];
- archive.file(
- "_rels/.rels",
- `${roles.map((role, i) => ``).join("")}`,
- );
- for (const i of roles.keys())
- archive.file(`docProps/metadata${i}.dat`, new Uint8Array([255, 216, 255, 0]));
- }
- }
- const ordinary = scenario === "ordinary parts";
- archive.file(
- "word/document.xml",
- `${text("ordinary body")}styled body${ordinary ? '' : ""}${ordinary ? '' : ""}`,
- );
- archive.file(
- "word/_rels/document.xml.rels",
- `${["header", "footer", "footnotes", "endnotes", "comments", "fontTable", "styles"].map((role) => ``).join("")}`,
- );
- for (const [role, root] of [
- ["header", "hdr"],
- ["footer", "ftr"],
- ])
- archive.file(
- `word/${role}.xml`,
- `${text(`${role} retained`)}`,
- );
- for (const role of ["footnotes", "endnotes", "comments"])
- archive.file(
- `word/${role}.xml`,
- `${text(`${role} retained`)}`,
- );
- archive.file(
- "word/fontTable.xml",
- ``,
- );
- archive.file(
- "word/styles.xml",
- `${Array.from({ length: 20 }, (_, i) => `${scenario === "inherited styles" ? '' : ""}`).join("")}`,
- );
- if (scenario === "default template styles") {
- const heading =
- '';
- const conditions = [
- "wholeTable",
- "firstRow",
- "lastRow",
- "firstCol",
- "lastCol",
- "band1Horz",
- "band2Horz",
- "band1Vert",
- "band2Vert",
- ];
- archive.file(
- "word/styles.xml",
- `${Array.from({ length: 276 }, (_, i) => ``).join("")}${heading}${Array.from({ length: 61 }, (_, i) => ``).join("")}${Array.from({ length: 100 }, (_, i) => `${i ? '' : ""}${conditions.map((type) => ``).join("")}`).join("")}`,
- );
- archive.file(
- "word/_rels/document.xml.rels",
- (await archive.file("word/_rels/document.xml.rels")!.async("string")).replace(
- "",
- ``,
- ),
- );
- archive.file(
- "word/numbering.xml",
- `${Array.from({ length: 9 }, (_, i) => ``).join("")}`,
- );
- const document = await archive.file("word/document.xml")!.async("string");
- archive.file(
- "word/document.xml",
- document.replace(
- "",
- 'styled cell',
- ),
- );
- }
- const { prepareOffice } = await import("./officeArchive");
- const prepared = await prepareOffice(
- await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" }),
- "docx",
- );
- expect(prepared.truncated).toBe(false);
- const bounded = await JSZip.loadAsync(prepared.bytes);
- const markup = await bounded.file("word/document.xml")!.async("string");
- expect(markup).toContain("ordinary body");
- expect(markup).toContain("styled body");
- expect(bounded.file("word/styles.xml")).not.toBeNull();
- if (scenario === "default template styles") {
- expect(markup).toContain("styled cell");
- expect(await bounded.file("word/styles.xml")!.async("string")).toContain('w:styleId="Table99"');
- }
- if (ordinary) {
- expect(markup).toMatch(/ {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- archive.file(
- "xl/workbook.xml",
- '',
- );
- archive.file(
- "xl/_rels/workbook.xml.rels",
- '',
- );
- const sharedPath = cdata ? "customStrings.xml" : "sharedStrings.xml";
- archive.file(
- "xl/_rels/workbook.xml.rels",
- (await archive.file("xl/_rels/workbook.xml.rels")!.async("string")).replace(
- "",
- ``,
- ),
- );
- const content = "x".repeat(32_767);
- archive.file(
- `xl/${sharedPath}`,
- `${cdata ? `` : content}`,
- );
- archive.file(
- "xl/worksheets/sheet1.xml",
- `${Array.from({ length: 500 }, (_, i) => `${Array.from({ length: 50 }, (_, j) => `${cdata ? "" : "0"}`).join("")}
`).join("")}`.replace(
- '',
- ``,
- ),
- );
- const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- void file
- .arrayBuffer()
- .then((bytes) => worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]));
- });
- expect(result.type).toBe("xlsx");
- if (result.type !== "xlsx") throw new Error("Expected bounded workbook");
- const bounded = await JSZip.loadAsync(result.bytes);
- const sheet = new DOMParser().parseFromString(
- await bounded.file("xl/worksheets/sheet1.xml")!.async("string"),
- "application/xml",
- );
- const cells = [...sheet.getElementsByTagName("t")].map((cell) => cell.textContent ?? "");
- expect(cells.reduce((total, cell) => total + cell.length, 0)).toBe(1024 * 1024);
- expect(Math.max(...cells.map((cell) => cell.length))).toBe(4096);
- expect(result.truncated).toBe(true);
- } finally {
- worker.terminate();
- }
- },
-);
-
-// A body just over the read limit once each paragraph repeats its text this often.
-it.each([1, Math.ceil(WORD_LIMITS.bodyBytes / 5_000 / 4)])(
- "counts shared Word settings once with %s text repetitions",
- async (repetitions) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
- archive.file(
- "word/document.xml",
- `${`${"body".repeat(repetitions)}`.repeat(5_000)}`,
- );
- archive.file(
- "word/_rels/document.xml.rels",
- '',
- );
- const settings = `${"s".repeat(120_000)}`;
- archive.file("word/settings.xml", settings);
- const bytes = await archive.generateAsync({ type: "arraybuffer" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
- });
- expect(result.type).toBe("docx");
- if (result.type !== "docx") throw new Error("Expected a Word prefix");
- expect(result.truncated).toBe(repetitions > 1);
- const bounded = await JSZip.loadAsync(result.bytes);
- const document = await bounded.file("word/document.xml")!.async("string");
- const retainedSettings = await bounded.file("word/settings.xml")!.async("string");
- expect(document.match(//g)!.length).toBeGreaterThan(100);
- expect(document.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes);
- expect(retainedSettings).toBe(settings);
- } finally {
- worker.terminate();
- }
- },
-);
-
-it.each(["repeated binary", "XML relationship alias", "duplicate relationship ID"])(
- "bounds Word image expansion for %s",
- async (scenario) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- archive.file(
- "[Content_Types].xml",
- '',
- );
- archive.file(
- "word/_rels/document.xml.rels",
- `${scenario === "duplicate relationship ID" ? '' : ""}`,
- );
- archive.file(
- "word/media/payload.png",
- scenario === "XML relationship alias" ? "" : new Uint8Array(8 * 1024 * 1024),
- );
- if (scenario === "duplicate relationship ID")
- archive.file("word/media/tiny.png", new Uint8Array(1));
- archive.file(
- "word/document.xml",
- `${''.repeat(7)}`,
- );
- const bytes = await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
- });
- expect(result).toMatchObject({
- type: "error",
- message: expect.stringContaining("expanded markup"),
- });
- } finally {
- worker.terminate();
- }
- },
-);
-
-it.each(["XFD200", "XFD300", "XFD1048576", "AX500", "AZ9999999"])(
- "bounds spreadsheet cells at %s before padding",
- async (ref) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- archive.file(
- "xl/workbook.xml",
- '',
- );
- // A nonstandard relationship target must get the same bounds.
- archive.file(
- "xl/_rels/workbook.xml.rels",
- '',
- );
- archive.file(
- "xl/custom.xml",
- `7
${ref === "XFD300" ? `` : `9`}
`,
- );
- const bytes = await archive.generateAsync({ type: "arraybuffer" });
- const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const result = await new Promise((resolve) => {
- worker.addEventListener(
- "message",
- (event: MessageEvent) => resolve(event.data),
- { once: true },
- );
- worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]);
- });
- if (ref === "AZ9999999" || ref === "XFD1048576") {
- expect(result).toMatchObject({
- type: "error",
- message: expect.stringContaining(ref === "AZ9999999" ? "invalid" : "preview limit"),
- });
- } else {
- expect(result.type).toBe("xlsx");
- if (result.type !== "xlsx") throw new Error("Expected workbook values");
- const bounded = await JSZip.loadAsync(result.bytes);
- const sheet = new DOMParser().parseFromString(
- await bounded.file("xl/custom.xml")!.async("string"),
- "application/xml",
- );
- expect(sheet.querySelector('c[r="A1"] v')?.textContent).toBe("7");
- expect(sheet.querySelectorAll("row").length).toBeLessThanOrEqual(500);
- expect(result.truncated).toBe(ref === "XFD200" || ref === "XFD300");
- if (ref === "AX500") expect(sheet.querySelector('c[r="AX500"] v')?.textContent).toBe("9");
- else expect(sheet.querySelector('c[r="' + ref + '"]')).toBeNull();
- }
- } finally {
- worker.terminate();
- }
- },
-);
-
-it.each(['', ''])(
- "shows workbook formats and underline %s",
- async (underline) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- archive.file(
- "xl/workbook.xml",
- '',
- );
- archive.file(
- "xl/_rels/workbook.xml.rels",
- '',
- );
- archive.file(
- "xl/styles.xml",
- `${underline}`,
- );
- archive.file(
- "xl/worksheets/sheet1.xml",
- '10044986Total
',
- );
- const { prepareOffice } = await import("./officeArchive");
- const prepared = await prepareOffice(
- await archive.generateAsync({ type: "arraybuffer" }),
- "xlsx",
- );
- const styles = await (
- await JSZip.loadAsync(prepared.bytes)
- )
- .file("xl/styles.xml")!
- .async("string");
- expect(styles).toContain("");
- const worker = new Worker(new URL("./office/spreadsheet.worker.ts", import.meta.url), {
- type: "module",
- });
- try {
- const { createWorkerClient } = await import("./office/worker-rpc");
- const client = createWorkerClient(worker);
- const bytes = prepared.bytes;
- await client.call("open", { bytes, view: prepared.view! }, { transfer: [bytes] });
- const cells = await client.call("cells", {
- sheet: 0,
- range: { top: 0, bottom: 0, left: 0, right: 1 },
- });
- // Ids under 164 that a workbook defines keep its formats, not Excel's built-in ones.
- expect(cells[0]).toEqual(["¥100.00", "01-Mar-2023"]);
- } finally {
- worker.terminate();
- }
- archive.file("xl/styles.xml", "");
- await expect(
- prepareOffice(await archive.generateAsync({ type: "arraybuffer" }), "xlsx"),
- ).rejects.toThrow("duplicate stylesheet blocks");
- },
-);
-
-it("keeps spreadsheet dates on their calendar day west of UTC", async () => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- archive.file(
- "xl/workbook.xml",
- '',
- );
- archive.file(
- "xl/_rels/workbook.xml.rels",
- '',
- );
- archive.file(
- "xl/styles.xml",
- '',
- );
- archive.file(
- "xl/worksheets/sheet1.xml",
- '46023
',
- );
- const bytes = await archive.generateAsync({ type: "arraybuffer" });
- const workerModule = new URL(
- "./office/spreadsheet.worker.ts?worker_file&type=module",
- import.meta.url,
- ).href;
- // Give the worker a west-of-UTC default while preserving explicit format options.
- const bootstrap = URL.createObjectURL(
- new Blob(
- [
- `
- const NativeURL = URL;
- globalThis.URL = class extends NativeURL {
- constructor(input, base) { super(input, base?.startsWith("blob:") ? ${JSON.stringify(location.href)} : base); }
- };
- const format = Date.prototype.toLocaleDateString;
- Date.prototype.toLocaleDateString = function(locale, options) {
- return format.call(this, "en-US", { timeZone: "America/Los_Angeles", ...options });
- };
- await import(${JSON.stringify(workerModule)});
- postMessage({ type: "ready" });
- `,
- ],
- { type: "text/javascript" },
- ),
- );
- const worker = new Worker(bootstrap, { type: "module" });
- try {
- await new Promise((resolve, reject) => {
- worker.addEventListener(
- "message",
- (event) => {
- if (event.data.type === "ready") resolve();
- },
- { once: true },
- );
- worker.addEventListener("error", (event) => reject(new Error(event.message)), { once: true });
- });
- const { createWorkerClient } = await import("./office/worker-rpc");
- const client = createWorkerClient(worker);
- await client.call(
- "open",
- {
- bytes,
- view: { hiddenGridlines: [], themeFonts: { major: "Calibri Light", minor: "Calibri" } },
- },
- { transfer: [bytes] },
- );
- const cells = await client.call("cells", {
- sheet: 0,
- range: { top: 0, bottom: 0, left: 0, right: 0 },
- });
- expect(cells[0]![0]).toMatch(/^1\/1\/(26|2026)$/);
- } finally {
- worker.terminate();
- URL.revokeObjectURL(bootstrap);
- }
-});
-
-it.each([60, 100])("prepares all %i pages of a table-heavy Word report", async (pages) => {
- const { default: JSZip } = await import("jszip");
- const archive = new JSZip();
- const paragraph = (text: string) =>
- `${text}`;
- archive.file(
- "word/document.xml",
- `${Array.from({ length: pages }, (_, page) => paragraph(`page ${page + 1}`) + `${Array.from({ length: 10 }, () => `${Array.from({ length: 5 }, () => `${paragraph("report entry")}`).join("")}`).join("")}`).join("")}`,
- );
- archive.file(
- "_rels/.rels",
- '',
- );
- archive.file(
- "[Content_Types].xml",
- '',
- );
- // Binary media must not consume the document's markup budget.
- archive.file("word/media/image.png", new Uint8Array(2_500_000));
- const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
- await using screen = await render(
- ,
- );
- // The first Office preview in the file loads the lazy viewer chain and the preparation worker.
- await vi.waitFor(() => expect(screen.container.textContent).toContain("page " + pages), {
- timeout: 10_000,
- });
- expect(screen.container.textContent).not.toContain("Preview limited");
- expect(screen.container.querySelector('[role="alert"]')).toBeNull();
-});
-
describe("standalone Mermaid previews", () => {
it.each([
'A@{ "img": "//attacker.invalid/pixel.png" }',
diff --git a/apps/web/src/components/files/AttachmentFilePreview.office.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.office.browser.test.tsx
new file mode 100644
index 0000000000000..c628075ed86bb
--- /dev/null
+++ b/apps/web/src/components/files/AttachmentFilePreview.office.browser.test.tsx
@@ -0,0 +1,1086 @@
+import { act, render } from "../../../test/render";
+import { expect, it, vi } from "vite-plus/test";
+import "~/index.css";
+
+const { refresh, assetUrlState } = vi.hoisted(() => ({
+ refresh: vi.fn<() => Promise>(),
+ assetUrlState: vi.fn<() => import("~/assets/assetUrls").AssetUrlState>(),
+}));
+
+vi.mock(import("~/assets/assetUrls"), async (original) => ({
+ ...(await original()),
+ useAssetUrlRefresh: () => refresh,
+ useAssetUrlState: assetUrlState,
+}));
+// The real source view highlights through a worker pool; the text it receives is what matters here.
+// Never load the original: its highlighter graph can outlast waitFor on a cold CI browser.
+vi.mock(import("./ReadOnlySourcePreview"), () => ({
+ default: ({ text, wordWrap }: { text: string; wordWrap?: boolean }) => (
+ {text}
+ ),
+}));
+
+// The Word engine compiles about 30 MB of WebAssembly in a fresh worker per document, which took
+// 10-17 s per test on loaded CI runners. These tests cover fetching, preparation and remounts, so
+// the stand-in shows the prepared body's text instead of laying it out.
+vi.mock(import("./office/WordDocument"), async () => {
+ const { use } = await import("react");
+ const { default: Zip } = await import("jszip");
+ const texts = new WeakMap>();
+ const read = (bytes: ArrayBuffer) =>
+ Zip.loadAsync(bytes)
+ .then((zip) => zip.file("word/document.xml")!.async("string"))
+ .then((xml) => xml.replace(/<[^>]+>/g, " "));
+ return {
+ default: ({ bytes }: { bytes: ArrayBuffer }) => {
+ let text = texts.get(bytes);
+ if (!text) texts.set(bytes, (text = read(bytes)));
+ return {use(text)};
+ },
+ };
+});
+
+import { AttachmentFilePreview } from "./AttachmentFilePreview";
+import JSZip from "jszip";
+import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol";
+import { WORD_LIMITS } from "./officeArchive";
+
+it.each(["", ""])(
+ "opens an empty Word document with %s",
+ async (body) => {
+ const { default: JSZip } = await import("jszip");
+ const { prepareOffice } = await import("./officeArchive");
+ const archive = new JSZip().file(
+ "word/document.xml",
+ `${body}`,
+ );
+ const prepared = await prepareOffice(
+ await archive.generateAsync({ type: "arraybuffer" }),
+ "docx",
+ );
+ const result = await JSZip.loadAsync(prepared.bytes);
+ const xml = new DOMParser().parseFromString(
+ await result.file("word/document.xml")!.async("string"),
+ "application/xml",
+ );
+ expect(xml.querySelector("parsererror")).toBeNull();
+ expect(
+ xml.getElementsByTagNameNS(
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
+ "body",
+ ),
+ ).toHaveLength(1);
+ },
+);
+
+it.each([false, true])(
+ "rejects excessive archive entries before JSZip (directories: %s)",
+ async (directories) => {
+ const { default: JSZip } = await import("jszip");
+ const { prepareOffice } = await import("./officeArchive");
+ const archive = new JSZip();
+ for (let i = 0; i < 4096; i++)
+ archive.file(`entry-${i}${directories ? "/" : ".txt"}`, "", { dir: directories });
+ const bytes = await archive.generateAsync({ type: "arraybuffer" });
+ const load = vi.spyOn(JSZip, "loadAsync");
+ try {
+ await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries");
+ expect(load).not.toHaveBeenCalled();
+ // The actual central-directory records must also be bounded if EOCD lies.
+ const view = new DataView(bytes);
+ view.setUint16(bytes.byteLength - 22 + 8, 1, true);
+ view.setUint16(bytes.byteLength - 22 + 10, 1, true);
+ await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries");
+ expect(load).not.toHaveBeenCalled();
+ } finally {
+ load.mockRestore();
+ }
+ },
+);
+
+it("rejects oversized embedded images before decoding pixels and retains ordinary images", async () => {
+ const { officeImageBitmap } = await import("./office/image-bitmap");
+ const canvas = new OffscreenCanvas(2, 3);
+ canvas.getContext("2d");
+ const blob = await canvas.convertToBlob({ type: "image/png" });
+ const bytes = await blob.arrayBuffer();
+ new DataView(bytes).setUint32(16, 12000);
+ new DataView(bytes).setUint32(20, 12000);
+ const decode = vi.spyOn(globalThis, "createImageBitmap");
+ try {
+ await expect(officeImageBitmap(new Blob([bytes]))).rejects.toThrow("pixel limit");
+ expect(decode).not.toHaveBeenCalled();
+ const bitmap = await officeImageBitmap(blob);
+ expect([bitmap.width, bitmap.height]).toEqual([2, 3]);
+ bitmap.close();
+ } finally {
+ decode.mockRestore();
+ }
+});
+
+it.each(["utf-16le", "utf-16be", "windows-1252"])(
+ "preserves %s Office XML and safe hyperlink targets",
+ async (encoding) => {
+ const { default: JSZip } = await import("jszip");
+ const { prepareOffice } = await import("./officeArchive");
+ const archive = new JSZip();
+ const encode = (text: string) => {
+ const xml = `${text}`;
+ if (encoding === "windows-1252") return Uint8Array.from(xml, (char) => char.charCodeAt(0));
+ const bytes = new Uint8Array(2 + xml.length * 2);
+ const view = new DataView(bytes.buffer);
+ const little = encoding === "utf-16le";
+ view.setUint16(0, 0xfeff, little);
+ for (let i = 0; i < xml.length; i++) view.setUint16(2 + i * 2, xml.charCodeAt(i), little);
+ return bytes;
+ };
+ archive.file(
+ "word/document.xml",
+ encode(
+ 'café',
+ ),
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ encode(
+ `${["https://example.com/page", "http://example.com", "mailto:maria@example.com", "javascript:alert(1)", "file:///tmp/private"].map((target, i) => ``).join("")}`,
+ ),
+ );
+ const prepared = await prepareOffice(
+ await archive.generateAsync({ type: "arraybuffer" }),
+ "docx",
+ );
+ const result = await JSZip.loadAsync(prepared.bytes);
+ expect(await result.file("word/document.xml")!.async("string")).toContain("café");
+ const links = await result.file("word/_rels/document.xml.rels")!.async("string");
+ expect(links).toContain('encoding="UTF-8"');
+ expect(links).toContain("https://example.com/page");
+ expect(links).toContain("http://example.com");
+ expect(links).toContain("mailto:maria@example.com");
+ expect(links).not.toContain("javascript:");
+ expect(links).not.toContain("file:");
+ },
+);
+
+it.each([
+ ["", "worksheets/./sheet1.xml", false, false],
+ ["x:", "../xl/worksheets/sheet1.xml", false, false],
+ ["", "/xl/worksheets/sheet1.xml", true, false],
+ ["", "worksheets/sheet1.xml", false, true],
+ ["", "worksheets/sheet1.xml", true, true],
+])(
+ "preserves %sworksheet metadata with relationship target %s",
+ async (prefix, target, empty, localPrefix) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
+ archive.file(
+ "xl/workbook.xml",
+ '',
+ );
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ ``,
+ );
+ archive.file(
+ "xl/worksheets/sheet1.xml",
+ `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/>${prefix}sheetViews><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/>${prefix}cols><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}${prefix}t>${prefix}is>${prefix}c>${prefix}row>${prefix}sheetData><${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>${prefix}mergeCells>${prefix}worksheet>`
+ .replace(
+ /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g,
+ prefix ? "<$1$2>$1>" : "$&",
+ )
+ .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data))
+ .replace(/<(\/?)sheetData\b/g, (tag, closing: string) =>
+ localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag,
+ )
+ .replace(/="([^"]*)"/g, (attribute, value: string) =>
+ prefix || empty
+ ? ` = '${value.replace(/\d/g, (digit) => `${digit.charCodeAt(0)};`)}'`
+ : attribute,
+ ),
+ );
+ const bytes = await archive.generateAsync({ type: "arraybuffer" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]);
+ });
+ expect(result.type).toBe("xlsx");
+ if (result.type !== "xlsx") throw new Error("Expected bounded workbook");
+ // The engine counts the chart sheet before it.
+ expect(result.view?.hiddenGridlines).toEqual([1]);
+ const bounded = await JSZip.loadAsync(result.bytes);
+ const sheet = new DOMParser().parseFromString(
+ await bounded.file("xl/worksheets/sheet1.xml")!.async("string"),
+ "application/xml",
+ );
+ expect(sheet.querySelector("parsererror")).toBeNull();
+ const row = sheet.getElementsByTagNameNS(ns, "row")[0];
+ if (empty) expect(row).toBeUndefined();
+ else {
+ expect(row!.getAttribute("ht")).toBe("42");
+ expect(row!.getAttribute("hidden")).toBe("1");
+ expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50");
+ expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096);
+ }
+ expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0);
+ expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0);
+ const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) =>
+ ["min", "max", "width"].map((name) => column.getAttribute(name)),
+ );
+ // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out.
+ expect(columns).toEqual([
+ [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"],
+ // Hidden columns keep their span past the limit, so they stay hidden.
+ [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null],
+ ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"],
+ ]);
+ } finally {
+ worker.terminate();
+ }
+ },
+);
+
+it("reports the final slide at maximum scroll after page visibility changes", async () => {
+ const { OfficePages } = await import("./office/OfficePages");
+ const changed = vi.fn();
+ const container = document.createElement("div");
+ container.style.cssText = "display:flex;flex-direction:column;width:400px;height:400px";
+ await using screen = await render(
+ ({ width: 400, height: 240 }))}
+ label="Slide"
+ paint={async () => undefined}
+ onPageChange={changed}
+ onError={(error) => {
+ throw error;
+ }}
+ />,
+ { container },
+ );
+ const scroller = screen.container.querySelector("[data-office-pages]")!;
+ await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight));
+ await act(() => {
+ scroller.scrollTop = scroller.scrollHeight;
+ });
+ await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(5));
+ await act(() => {
+ scroller.scrollTop = 10;
+ });
+ await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(0));
+});
+
+it("preserves merged text across tile clears", async () => {
+ const { paintFrame } = await import("./office/sheet-frame");
+ const canvas = new OffscreenCanvas(400, 80);
+ const context = canvas.getContext("2d")!;
+ paintFrame(
+ context,
+ {
+ viewport: { x: 0, y: 0, width: 400, height: 80 },
+ list: { width: 400, height: 80, commands: [] },
+ tiles: [
+ {
+ x: 0,
+ y: 0,
+ clipX: 0,
+ clipY: 0,
+ list: {
+ width: 208,
+ height: 80,
+ commands: [
+ {
+ op: "text",
+ x: 2,
+ y: 40,
+ text: "MMMMMMMMMMMMMMMMMMMMMMMM",
+ fontSize: 20,
+ color: "#000",
+ clip: { x: 0, y: 0, w: 400, h: 80 },
+ },
+ ],
+ },
+ },
+ { x: 192, y: 0, clipX: 200, clipY: 0, list: { width: 208, height: 80, commands: [] } },
+ ],
+ },
+ { ratio: 1, origin: { x: 0, y: 0 } },
+ );
+ const pixels = context.getImageData(240, 15, 100, 35).data;
+ expect(pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true);
+});
+
+it.each([
+ "paragraphs",
+ "runs",
+ "tables",
+ "auxiliary XML",
+ "XML bytes",
+ "renamed XML",
+ "section after prefix",
+ "oversized section",
+])("bounds excessive Word %s before rendering DOM", async (scenario) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const body = ["paragraphs", "section after prefix", "oversized section"].includes(scenario)
+ ? "x".repeat(200_000)
+ : scenario === "runs"
+ ? `${"x".repeat(50_001)}`
+ : scenario === "tables"
+ ? "".repeat(2 * WORD_LIMITS.bodyElements)
+ : scenario === "XML bytes"
+ ? `${"x".repeat(2 * 1024 * 1024)}`
+ : "";
+ const section =
+ '';
+ archive.file(
+ "word/document.xml",
+ `${body}${scenario === "section after prefix" ? '' : ""}${scenario === "oversized section" ? `${"".repeat(2_001)}` : section}`,
+ );
+ if (scenario === "auxiliary XML" || scenario === "renamed XML")
+ archive.file(
+ scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml",
+ `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`,
+ );
+ const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
+ const src = URL.createObjectURL(file);
+ try {
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ void file
+ .arrayBuffer()
+ .then((bytes) => worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]));
+ });
+ if (
+ scenario === "auxiliary XML" ||
+ scenario === "renamed XML" ||
+ scenario === "oversized section"
+ ) {
+ expect(result).toMatchObject({
+ type: "error",
+ message: expect.stringContaining("Preview limited"),
+ });
+ } else {
+ expect(result.type).toBe("docx");
+ if (result.type !== "docx") throw new Error("Expected a Word prefix");
+ expect(result.truncated).toBe(true);
+ const bounded = await JSZip.loadAsync(result.bytes);
+ const markup = await bounded.file("word/document.xml")!.async("string");
+ {
+ const xml = markup;
+ const document = new DOMParser().parseFromString(xml, "application/xml");
+ expect(document.querySelector("parsererror")).toBeNull();
+ const bodies = document.getElementsByTagNameNS("*", "body");
+ expect(bodies).toHaveLength(1);
+ const section = bodies[0]!.lastElementChild!;
+ expect(section.localName).toBe("sectPr");
+ const size = section.getElementsByTagNameNS("*", "pgSz")[0]!;
+ expect(size.getAttributeNS(section.namespaceURI, "w")).toBe(
+ scenario === "section after prefix" ? "11906" : "16838",
+ );
+ expect(size.getAttributeNS(section.namespaceURI, "orient")).toBe(
+ scenario === "section after prefix" ? null : "landscape",
+ );
+ }
+ expect(markup.length).toBeLessThan(body.length);
+ expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes);
+ expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags);
+ }
+ } finally {
+ worker.terminate();
+ }
+ } finally {
+ URL.revokeObjectURL(src);
+ }
+});
+
+it.each([
+ "relationship markup",
+ "relationship bytes",
+ "content type",
+ "metadata type",
+ "deep XML",
+ "query path",
+])("bounds XML hidden in media by %s", async (scenario) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const path = `word/media/payload.png${scenario === "query path" ? "?xml" : ""}`;
+ archive.file(
+ "word/document.xml",
+ '',
+ );
+ archive.file(
+ "[Content_Types].xml",
+ `${scenario === "metadata type" ? '' : ""}`,
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`,
+ );
+ archive.file(
+ path,
+ scenario === "relationship bytes"
+ ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}`
+ : scenario === "deep XML"
+ ? `${"".repeat(65)}${"".repeat(65)}`
+ : `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`,
+ );
+ const bytes = await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
+ });
+ expect(result).toMatchObject({
+ type: "error",
+ message: expect.stringContaining("Preview limited"),
+ });
+ } finally {
+ worker.terminate();
+ }
+});
+
+const EXPANDED_WORD_ROLES = [
+ "footnotes",
+ "note roots",
+ "endnotes",
+ "header",
+ "footer",
+ "comments",
+ "styles",
+ "style chain",
+ "style count",
+ "style visits",
+ "numbering",
+ "picture numbering",
+] as const;
+
+async function expandedWordFixture(role: (typeof EXPANDED_WORD_ROLES)[number]) {
+ const archive = new JSZip();
+ const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ const type = role.startsWith("style")
+ ? "styles"
+ : role === "picture numbering"
+ ? "numbering"
+ : role === "note roots"
+ ? "footnotes"
+ : role;
+ archive.file(
+ "_rels/.rels",
+ '',
+ );
+ const text = (value: string) => `${value}`;
+ // Each copy of the 2,000-paragraph payload costs 6,000 markup units, so enough
+ // references (or pages) exceed the expansion budget.
+ const copies = Math.ceil(WORD_LIMITS.expandedMarkup / 6_000) + 1;
+ archive.file(
+ "word/document.xml",
+ `${text("body retained")}${Array.from({ length: copies }, () => ``).join("")}`,
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ ``,
+ );
+ const xml =
+ role === "style chain" || role === "style count" || role === "style visits"
+ ? `${Array.from({ length: role === "style count" ? WORD_LIMITS.styles + 1 : role === "style visits" ? Math.ceil(WORD_LIMITS.styleVisits / 30) + 1 : 1_000 }, (_, i) => `${i ? `= 30 ? 29 : i - 1}"/>` : ""}`).join("")}`
+ : role === "styles"
+ ? `${Array.from({ length: 100 }, (_, i) => ``).join("")}`
+ : role.includes("numbering")
+ ? `${role === "picture numbering" ? '' : ""}${Array.from({ length: 9 }, (_, i) => ``).join("")}${Array.from({ length: WORD_LIMITS.numberingReferences + 1 }, (_, i) => ``).join("")}`
+ : role === "header" || role === "footer"
+ ? `${text("omitted payload").repeat(2_000)}`
+ : `${text("omitted payload").repeat(2_000)}`;
+ archive.file("word/media/aux.xml", xml);
+ if (role === "note roots") {
+ // Each note's XML repeats the 32 KB root, which the expansion budget charges twice.
+ const notes = Math.ceil(WORD_LIMITS.expandedBytes / 64_000) + 1;
+ archive.file(
+ "word/document.xml",
+ `${text("body retained")}${Array.from({ length: notes }, (_, id) => ``).join("")}`,
+ );
+ archive.file(
+ "word/media/aux.xml",
+ `${Array.from({ length: notes }, (_, id) => `${text("omitted payload")}`).join("")}`,
+ );
+ }
+ return archive.generateAsync({ type: "blob", compression: "DEFLATE" });
+}
+
+// Rendering starts a Word engine worker per case, which compiles about 30 MB of WebAssembly and
+// took 10-17 s per test on loaded CI runners. Omission and retention are prepareOffice decisions,
+// so they are checked there.
+it.each(EXPANDED_WORD_ROLES)(
+ "keeps the body and omits expanded %s from the prepared document",
+ async (role) => {
+ const file = await expandedWordFixture(role);
+ const { SaxesParser } = await import("saxes");
+ const { prepareOffice } = await import("./officeArchive");
+ const parse = vi.spyOn(SaxesParser.prototype, "write");
+ try {
+ const prepared = await prepareOffice(await file.arrayBuffer(), "docx");
+ expect(prepared.truncated).toBe(true);
+ if (role === "note roots")
+ expect(
+ parse.mock.calls.reduce(
+ (sum, [chunk]) => sum + (typeof chunk === "string" ? chunk.length : 0),
+ 0,
+ ),
+ ).toBeLessThan(WORD_LIMITS.expandedBytes + 4 * 1024 * 1024);
+ const bounded = await JSZip.loadAsync(prepared.bytes);
+ const markup = await bounded.file("word/document.xml")!.async("string");
+ expect(markup).toContain("body retained");
+ expect(bounded.file("word/media/aux.xml")).toBeNull();
+ // A dangling header or footer reference makes the engine reject the whole body.
+ expect(markup).not.toMatch(/ {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ const r = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+ const text = (value: string) => `${value}`;
+ archive.file(
+ "_rels/.rels",
+ ``,
+ );
+ if (scenario === "python-docx package" || scenario === "binary package properties") {
+ const packageRelationships = "http://schemas.openxmlformats.org/package/2006/relationships";
+ archive.file(
+ "_rels/.rels",
+ ``,
+ );
+ archive.file(
+ "[Content_Types].xml",
+ '',
+ );
+ archive.file(
+ "docProps/thumbnail.jpeg",
+ new Uint8Array([255, 216, 255, 224, 0, 16, 74, 70, 73, 70]),
+ );
+ archive.file("_xmlsignatures/origin.sigs", new Uint8Array());
+ for (const part of ["core", "app", "custom"])
+ archive.file(`docProps/${part}.xml`, '');
+ if (scenario === "binary package properties") {
+ const roles = [
+ `${packageRelationships}/metadata/core-properties`,
+ `${r}/extended-properties`,
+ `${packageRelationships}/metadata/custom-properties`,
+ `${packageRelationships}/digital-signature/signature`,
+ ];
+ archive.file(
+ "_rels/.rels",
+ `${roles.map((role, i) => ``).join("")}`,
+ );
+ for (const i of roles.keys())
+ archive.file(`docProps/metadata${i}.dat`, new Uint8Array([255, 216, 255, 0]));
+ }
+ }
+ const ordinary = scenario === "ordinary parts";
+ archive.file(
+ "word/document.xml",
+ `${text("ordinary body")}styled body${ordinary ? '' : ""}${ordinary ? '' : ""}`,
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ `${["header", "footer", "footnotes", "endnotes", "comments", "fontTable", "styles"].map((role) => ``).join("")}`,
+ );
+ for (const [role, root] of [
+ ["header", "hdr"],
+ ["footer", "ftr"],
+ ])
+ archive.file(
+ `word/${role}.xml`,
+ `${text(`${role} retained`)}`,
+ );
+ for (const role of ["footnotes", "endnotes", "comments"])
+ archive.file(
+ `word/${role}.xml`,
+ `${text(`${role} retained`)}`,
+ );
+ archive.file(
+ "word/fontTable.xml",
+ ``,
+ );
+ archive.file(
+ "word/styles.xml",
+ `${Array.from({ length: 20 }, (_, i) => `${scenario === "inherited styles" ? '' : ""}`).join("")}`,
+ );
+ if (scenario === "default template styles") {
+ const heading =
+ '';
+ const conditions = [
+ "wholeTable",
+ "firstRow",
+ "lastRow",
+ "firstCol",
+ "lastCol",
+ "band1Horz",
+ "band2Horz",
+ "band1Vert",
+ "band2Vert",
+ ];
+ archive.file(
+ "word/styles.xml",
+ `${Array.from({ length: 276 }, (_, i) => ``).join("")}${heading}${Array.from({ length: 61 }, (_, i) => ``).join("")}${Array.from({ length: 100 }, (_, i) => `${i ? '' : ""}${conditions.map((type) => ``).join("")}`).join("")}`,
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ (await archive.file("word/_rels/document.xml.rels")!.async("string")).replace(
+ "",
+ ``,
+ ),
+ );
+ archive.file(
+ "word/numbering.xml",
+ `${Array.from({ length: 9 }, (_, i) => ``).join("")}`,
+ );
+ const document = await archive.file("word/document.xml")!.async("string");
+ archive.file(
+ "word/document.xml",
+ document.replace(
+ "",
+ 'styled cell',
+ ),
+ );
+ }
+ const { prepareOffice } = await import("./officeArchive");
+ const prepared = await prepareOffice(
+ await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" }),
+ "docx",
+ );
+ expect(prepared.truncated).toBe(false);
+ const bounded = await JSZip.loadAsync(prepared.bytes);
+ const markup = await bounded.file("word/document.xml")!.async("string");
+ expect(markup).toContain("ordinary body");
+ expect(markup).toContain("styled body");
+ expect(bounded.file("word/styles.xml")).not.toBeNull();
+ if (scenario === "default template styles") {
+ expect(markup).toContain("styled cell");
+ expect(await bounded.file("word/styles.xml")!.async("string")).toContain('w:styleId="Table99"');
+ }
+ if (ordinary) {
+ expect(markup).toMatch(/ {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ archive.file(
+ "xl/workbook.xml",
+ '',
+ );
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ '',
+ );
+ const sharedPath = cdata ? "customStrings.xml" : "sharedStrings.xml";
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ (await archive.file("xl/_rels/workbook.xml.rels")!.async("string")).replace(
+ "",
+ ``,
+ ),
+ );
+ const content = "x".repeat(32_767);
+ archive.file(
+ `xl/${sharedPath}`,
+ `${cdata ? `` : content}`,
+ );
+ archive.file(
+ "xl/worksheets/sheet1.xml",
+ `${Array.from({ length: 500 }, (_, i) => `${Array.from({ length: 50 }, (_, j) => `${cdata ? "" : "0"}`).join("")}
`).join("")}`.replace(
+ '',
+ ``,
+ ),
+ );
+ const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ void file
+ .arrayBuffer()
+ .then((bytes) => worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]));
+ });
+ expect(result.type).toBe("xlsx");
+ if (result.type !== "xlsx") throw new Error("Expected bounded workbook");
+ const bounded = await JSZip.loadAsync(result.bytes);
+ const sheet = new DOMParser().parseFromString(
+ await bounded.file("xl/worksheets/sheet1.xml")!.async("string"),
+ "application/xml",
+ );
+ const cells = [...sheet.getElementsByTagName("t")].map((cell) => cell.textContent ?? "");
+ expect(cells.reduce((total, cell) => total + cell.length, 0)).toBe(1024 * 1024);
+ expect(Math.max(...cells.map((cell) => cell.length))).toBe(4096);
+ expect(result.truncated).toBe(true);
+ } finally {
+ worker.terminate();
+ }
+ },
+);
+
+// A body just over the read limit once each paragraph repeats its text this often.
+it.each([1, Math.ceil(WORD_LIMITS.bodyBytes / 5_000 / 4)])(
+ "counts shared Word settings once with %s text repetitions",
+ async (repetitions) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
+ archive.file(
+ "word/document.xml",
+ `${`${"body".repeat(repetitions)}`.repeat(5_000)}`,
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ '',
+ );
+ const settings = `${"s".repeat(120_000)}`;
+ archive.file("word/settings.xml", settings);
+ const bytes = await archive.generateAsync({ type: "arraybuffer" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
+ });
+ expect(result.type).toBe("docx");
+ if (result.type !== "docx") throw new Error("Expected a Word prefix");
+ expect(result.truncated).toBe(repetitions > 1);
+ const bounded = await JSZip.loadAsync(result.bytes);
+ const document = await bounded.file("word/document.xml")!.async("string");
+ const retainedSettings = await bounded.file("word/settings.xml")!.async("string");
+ expect(document.match(//g)!.length).toBeGreaterThan(100);
+ expect(document.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes);
+ expect(retainedSettings).toBe(settings);
+ } finally {
+ worker.terminate();
+ }
+ },
+);
+
+it.each(["repeated binary", "XML relationship alias", "duplicate relationship ID"])(
+ "bounds Word image expansion for %s",
+ async (scenario) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ archive.file(
+ "[Content_Types].xml",
+ '',
+ );
+ archive.file(
+ "word/_rels/document.xml.rels",
+ `${scenario === "duplicate relationship ID" ? '' : ""}`,
+ );
+ archive.file(
+ "word/media/payload.png",
+ scenario === "XML relationship alias" ? "" : new Uint8Array(8 * 1024 * 1024),
+ );
+ if (scenario === "duplicate relationship ID")
+ archive.file("word/media/tiny.png", new Uint8Array(1));
+ archive.file(
+ "word/document.xml",
+ `${''.repeat(7)}`,
+ );
+ const bytes = await archive.generateAsync({ type: "arraybuffer", compression: "DEFLATE" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes]);
+ });
+ expect(result).toMatchObject({
+ type: "error",
+ message: expect.stringContaining("expanded markup"),
+ });
+ } finally {
+ worker.terminate();
+ }
+ },
+);
+
+it.each(["XFD200", "XFD300", "XFD1048576", "AX500", "AZ9999999"])(
+ "bounds spreadsheet cells at %s before padding",
+ async (ref) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ archive.file(
+ "xl/workbook.xml",
+ '',
+ );
+ // A nonstandard relationship target must get the same bounds.
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ '',
+ );
+ archive.file(
+ "xl/custom.xml",
+ `7
${ref === "XFD300" ? `` : `9`}
`,
+ );
+ const bytes = await archive.generateAsync({ type: "arraybuffer" });
+ const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const result = await new Promise((resolve) => {
+ worker.addEventListener(
+ "message",
+ (event: MessageEvent) => resolve(event.data),
+ { once: true },
+ );
+ worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]);
+ });
+ if (ref === "AZ9999999" || ref === "XFD1048576") {
+ expect(result).toMatchObject({
+ type: "error",
+ message: expect.stringContaining(ref === "AZ9999999" ? "invalid" : "preview limit"),
+ });
+ } else {
+ expect(result.type).toBe("xlsx");
+ if (result.type !== "xlsx") throw new Error("Expected workbook values");
+ const bounded = await JSZip.loadAsync(result.bytes);
+ const sheet = new DOMParser().parseFromString(
+ await bounded.file("xl/custom.xml")!.async("string"),
+ "application/xml",
+ );
+ expect(sheet.querySelector('c[r="A1"] v')?.textContent).toBe("7");
+ expect(sheet.querySelectorAll("row").length).toBeLessThanOrEqual(500);
+ expect(result.truncated).toBe(ref === "XFD200" || ref === "XFD300");
+ if (ref === "AX500") expect(sheet.querySelector('c[r="AX500"] v')?.textContent).toBe("9");
+ else expect(sheet.querySelector('c[r="' + ref + '"]')).toBeNull();
+ }
+ } finally {
+ worker.terminate();
+ }
+ },
+);
+
+it.each(['', ''])(
+ "shows workbook formats and underline %s",
+ async (underline) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ archive.file(
+ "xl/workbook.xml",
+ '',
+ );
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ '',
+ );
+ archive.file(
+ "xl/styles.xml",
+ `${underline}`,
+ );
+ archive.file(
+ "xl/worksheets/sheet1.xml",
+ '10044986Total
',
+ );
+ const { prepareOffice } = await import("./officeArchive");
+ const prepared = await prepareOffice(
+ await archive.generateAsync({ type: "arraybuffer" }),
+ "xlsx",
+ );
+ const styles = await (
+ await JSZip.loadAsync(prepared.bytes)
+ )
+ .file("xl/styles.xml")!
+ .async("string");
+ expect(styles).toContain("");
+ const worker = new Worker(new URL("./office/spreadsheet.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ try {
+ const { createWorkerClient } = await import("./office/worker-rpc");
+ const client = createWorkerClient(worker);
+ const bytes = prepared.bytes;
+ await client.call("open", { bytes, view: prepared.view! }, { transfer: [bytes] });
+ const cells = await client.call("cells", {
+ sheet: 0,
+ range: { top: 0, bottom: 0, left: 0, right: 1 },
+ });
+ // Ids under 164 that a workbook defines keep its formats, not Excel's built-in ones.
+ expect(cells[0]).toEqual(["¥100.00", "01-Mar-2023"]);
+ } finally {
+ worker.terminate();
+ }
+ archive.file("xl/styles.xml", "");
+ await expect(
+ prepareOffice(await archive.generateAsync({ type: "arraybuffer" }), "xlsx"),
+ ).rejects.toThrow("duplicate stylesheet blocks");
+ },
+);
+
+it("keeps spreadsheet dates on their calendar day west of UTC", async () => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ archive.file(
+ "xl/workbook.xml",
+ '',
+ );
+ archive.file(
+ "xl/_rels/workbook.xml.rels",
+ '',
+ );
+ archive.file(
+ "xl/styles.xml",
+ '',
+ );
+ archive.file(
+ "xl/worksheets/sheet1.xml",
+ '46023
',
+ );
+ const bytes = await archive.generateAsync({ type: "arraybuffer" });
+ const workerModule = new URL(
+ "./office/spreadsheet.worker.ts?worker_file&type=module",
+ import.meta.url,
+ ).href;
+ // Give the worker a west-of-UTC default while preserving explicit format options.
+ const bootstrap = URL.createObjectURL(
+ new Blob(
+ [
+ `
+ const NativeURL = URL;
+ globalThis.URL = class extends NativeURL {
+ constructor(input, base) { super(input, base?.startsWith("blob:") ? ${JSON.stringify(location.href)} : base); }
+ };
+ const format = Date.prototype.toLocaleDateString;
+ Date.prototype.toLocaleDateString = function(locale, options) {
+ return format.call(this, "en-US", { timeZone: "America/Los_Angeles", ...options });
+ };
+ await import(${JSON.stringify(workerModule)});
+ postMessage({ type: "ready" });
+ `,
+ ],
+ { type: "text/javascript" },
+ ),
+ );
+ const worker = new Worker(bootstrap, { type: "module" });
+ try {
+ await new Promise((resolve, reject) => {
+ worker.addEventListener(
+ "message",
+ (event) => {
+ if (event.data.type === "ready") resolve();
+ },
+ { once: true },
+ );
+ worker.addEventListener("error", (event) => reject(new Error(event.message)), { once: true });
+ });
+ const { createWorkerClient } = await import("./office/worker-rpc");
+ const client = createWorkerClient(worker);
+ await client.call(
+ "open",
+ {
+ bytes,
+ view: { hiddenGridlines: [], themeFonts: { major: "Calibri Light", minor: "Calibri" } },
+ },
+ { transfer: [bytes] },
+ );
+ const cells = await client.call("cells", {
+ sheet: 0,
+ range: { top: 0, bottom: 0, left: 0, right: 0 },
+ });
+ expect(cells[0]![0]).toMatch(/^1\/1\/(26|2026)$/);
+ } finally {
+ worker.terminate();
+ URL.revokeObjectURL(bootstrap);
+ }
+});
+
+it.each([60, 100])("prepares all %i pages of a table-heavy Word report", async (pages) => {
+ const { default: JSZip } = await import("jszip");
+ const archive = new JSZip();
+ const paragraph = (text: string) =>
+ `${text}`;
+ archive.file(
+ "word/document.xml",
+ `${Array.from({ length: pages }, (_, page) => paragraph(`page ${page + 1}`) + `${Array.from({ length: 10 }, () => `${Array.from({ length: 5 }, () => `${paragraph("report entry")}`).join("")}`).join("")}`).join("")}`,
+ );
+ archive.file(
+ "_rels/.rels",
+ '',
+ );
+ archive.file(
+ "[Content_Types].xml",
+ '',
+ );
+ // Binary media must not consume the document's markup budget.
+ archive.file("word/media/image.png", new Uint8Array(2_500_000));
+ const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" });
+ await using screen = await render(
+ ,
+ );
+ // The first Office preview in the file loads the lazy viewer chain and the preparation worker.
+ await vi.waitFor(() => expect(screen.container.textContent).toContain("page " + pages), {
+ timeout: 10_000,
+ });
+ expect(screen.container.textContent).not.toContain("Preview limited");
+ expect(screen.container.querySelector('[role="alert"]')).toBeNull();
+});