Skip to content
Source
Sign inSign up
Open History to see the last commit

officeArchive.test.ts

151 lines7.8 KB

1import JSZip from "jszip";
2import { describe, expect, it } from "vite-plus/test";
3​
4import { prepareOffice } from "./officeArchive";
5​
6const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
7const V = 'xmlns:v="urn:schemas-microsoft-com:vml"';
8const R = 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
9const relationship = (type: string, target: string) =>
10 `<Relationship Id="${type}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/${type}" Target="${target}"/>`;
11const paragraph = (text: string, properties = "") =>
12 `<w:p>${properties}<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
13​
14async function wordFile(
15 body: string,
16 parts: Partial<Record<"styles" | "numbering" | "header" | "footer" | "footnotes", string>> = {},
17 section = "",
18) {
19 const zip = new JSZip();
20 zip.file(
21 "[Content_Types].xml",
22 `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`,
23 );
24 zip.file(
25 "_rels/.rels",
26 `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${relationship("officeDocument", "word/document.xml")}</Relationships>`,
27 );
28 zip.file(
29 "word/_rels/document.xml.rels",
30 `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${Object.keys(
31 parts,
32 )
33 .map((part) => relationship(part, `${part}.xml`))
34 .join("")}</Relationships>`,
35 );
36 for (const [part, xml] of Object.entries(parts)) zip.file(`word/${part}.xml`, xml);
37 zip.file(
38 "word/document.xml",
39 `<w:document ${W} ${V} ${R}><w:body>${body}<w:sectPr>${section}<w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>`,
40 );
41 return zip.generateAsync({ type: "arraybuffer" });
43​
44async function prepared(bytes: ArrayBuffer) {
45 const result = await prepareOffice(bytes, "docx");
46 const zip = await JSZip.loadAsync(result.bytes);
47 return { truncated: result.truncated, part: (path: string) => zip.file(path)?.async("string") };
49​
50// regression: the budgets sized for a main-thread renderer cut ordinary documents short.
51describe("Word preview limits", () => {
52 it("keeps a document whose table follows a long one", async () => {
53 const rows = Array.from(
54 { length: 500 },
55 (_, row) =>
56 `<w:tr>${Array.from({ length: 5 }, (_, cell) => `<w:tc>${paragraph(`r${row}c${cell}`)}</w:tc>`).join("")}</w:tr>`,
57 ).join("");
58 const result = await prepared(
59 await wordFile(`<w:tbl>${rows}</w:tbl>${paragraph("After the table")}`),
60 );
61 expect(result.truncated).toBe(false);
62 expect(await result.part("word/document.xml")).toContain("After the table");
63 });
64​
65 it("keeps the list and style definitions of a long document", async () => {
66 const levels = Array.from(
67 { length: 9 },
68 (_, level) =>
69 `<w:lvl w:ilvl="${level}"><w:numFmt w:val="decimal"/><w:lvlText w:val="%${level + 1}."/></w:lvl>`,
70 ).join("");
71 const numbering = `<w:numbering ${W}>${Array.from({ length: 60 }, (_, list) => `<w:abstractNum w:abstractNumId="${list}">${levels}</w:abstractNum>`).join("")}${Array.from({ length: 60 }, (_, list) => `<w:num w:numId="${list + 1}"><w:abstractNumId w:val="${list}"/></w:num>`).join("")}</w:numbering>`;
72 const styles = `<w:styles ${W}>${Array.from(
73 { length: 1_500 },
74 (_, style) =>
75 `<w:style w:type="paragraph" w:styleId="S${style}">${style % 10 ? `<w:basedOn w:val="S${style - 1}"/>` : ""}<w:pPr><w:spacing w:after="${style}"/></w:pPr><w:rPr><w:sz w:val="22"/></w:rPr></w:style>`,
76 ).join("")}</w:styles>`;
77 const result = await prepared(
78 await wordFile(
79 Array.from({ length: 6_000 }, (_, index) =>
80 paragraph(
81 `Item ${index}`,
82 `<w:pPr><w:pStyle w:val="S${index % 1_500}"/><w:numPr><w:ilvl w:val="0"/><w:numId w:val="${(index % 60) + 1}"/></w:numPr></w:pPr>`,
83 ),
84 ).join(""),
85 { styles, numbering },
86 ),
87 );
88 expect(result.truncated).toBe(false);
89 expect(await result.part("word/numbering.xml")).toBe(numbering);
90 expect(await result.part("word/styles.xml")).toBe(styles);
91 expect(await result.part("word/document.xml")).toContain("Item 5999");
92 });
93​
94 it("keeps the lists of a document with picture bullets, with their text bullets", async () => {
95 const picture = `<w:numPicBullet w:numPicBulletId="0"><w:pict><v:shape style="width:9pt;height:9pt"><v:imagedata r:id="rId1"/></v:shape></w:pict></w:numPicBullet>`;
96 const pictured = `<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/><w:lvlText w:val="\uf0b7"/><w:lvlPicBulletId w:val="0"/></w:lvl></w:abstractNum>`;
97 const numbered = `<w:abstractNum w:abstractNumId="1"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl></w:abstractNum>`;
98 const lists = `<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num><w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>`;
99 const result = await prepared(
100 await wordFile(paragraph("Item", `<w:pPr><w:numPr><w:numId w:val="2"/></w:numPr></w:pPr>`), {
101 numbering: `<w:numbering ${W} ${V} ${R}>${picture}${pictured}${numbered}${lists}</w:numbering>`,
102 }),
103 );
104 expect(await result.part("word/numbering.xml")).toBe(
105 `<w:numbering ${W} ${V} ${R}>${pictured.replace('<w:lvlPicBulletId w:val="0"/>', "")}${numbered}${lists}</w:numbering>`,
106 );
107 });
108​
109 const text = "x".repeat(140_000);
110 it.each([
111 ["on its own", `<w:p><w:r><w:t>${text}</w:t></w:r></w:p>`],
112 [
113 "after an empty paragraph in a text box",
114 `<w:p><w:r><w:pict><v:shape><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r><w:r><w:t>${text}</w:t></w:r></w:p>`,
115 ],
116 ["written as CDATA", `<w:p><w:r><w:t><![CDATA[${text}]]></w:t></w:r></w:p>`],
117 ])("stops before a paragraph too long to lay out %s", async (_, long) => {
118 const result = await prepared(
119 await wordFile(paragraph("Kept") + long + paragraph("Not reached")),
120 );
121 expect(result.truncated).toBe(true);
122 const document = await result.part("word/document.xml");
123 expect(document).toContain("Kept");
124 expect(document).not.toContain("Not reached");
125 });
126​
127 it("counts only the text a paragraph lays out", async () => {
128 const formatted = `<w:p>${" ".repeat(140_000)}<w:r><w:instrText>${"x".repeat(140_000)}</w:instrText></w:r><w:r><w:t>${"x".repeat(40_000)}</w:t></w:r></w:p>`;
129 const result = await prepared(await wordFile(formatted + paragraph("After")));
130 expect(result.truncated).toBe(false);
131 expect(await result.part("word/document.xml")).toContain("After");
132 });
133​
134 it("keeps a long header", async () => {
135 const header = `<w:hdr ${W}>${Array.from({ length: 3_000 }, (_, line) => paragraph(`Header line ${line}`)).join("")}</w:hdr>`;
136 const footer = `<w:ftr ${W}>${paragraph("Footer")}</w:ftr>`;
137 const footnotes = `<w:footnotes ${W}><w:footnote w:id="1">${paragraph("Note")}</w:footnote></w:footnotes>`;
138 const result = await prepared(
139 await wordFile(
140 `<w:p><w:r><w:t>Body</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r></w:p>`,
141 { header, footer, footnotes },
142 `<w:headerReference w:type="default" r:id="header"/><w:footerReference w:type="default" r:id="footer"/>`,
143 ),
144 );
145 expect(result.truncated).toBe(false);
146 expect(await result.part("word/header.xml")).toBe(header);
147 expect(await result.part("word/footer.xml")).toBe(footer);
148 expect(await result.part("word/footnotes.xml")).toBe(footnotes);
149 expect(await result.part("word/document.xml")).toContain("Body");
150 });
151});