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

PdfViewer.browser.test.tsx

226 lines9.7 KB

1import { expect, it, vi } from "vite-plus/test";
2import { render } from "../../../test/render";
3import "~/index.css";
4​
5// Vite serves the worker module through its transform with an inline source map (6.8 MB for
6// a 1.3 MB file), which dominated this test under CPU contention. A blob of the raw file is the
7// same code without the dev-server cost.
8vi.mock(import("pdfjs-dist/build/pdf.worker.min.mjs?url"), async () => {
9 const { default: source } = await import("pdfjs-dist/build/pdf.worker.min.mjs?raw");
10 return {
11 default: URL.createObjectURL(new Blob([source], { type: "text/javascript" })),
12 };
13});
14​
15// Holds one page's Find text until the test opens it, so a scan can be observed mid-flight.
16const findGate = vi.hoisted(() => ({ page: 0, open: () => {}, opened: Promise.resolve() }));
17function setup(page: number) {
18 findGate.page = page;
19 findGate.opened = new Promise((resolve) => (findGate.open = resolve));
21vi.mock(import("./pdfDocument"), async (importOriginal) => {
22 const actual = await importOriginal();
23 return {
24 ...actual,
25 openPdf: (bytes) => {
26 const task = actual.openPdf(bytes);
27 const promise = task.promise.then((document) => {
28 const getPage = document.getPage.bind(document);
29 document.getPage = async (number) => {
30 const page = await getPage(number);
31 const getTextContent = page.getTextContent.bind(page);
32 page.getTextContent = async (params) => {
33 if (number === findGate.page) await findGate.opened;
34 return getTextContent(params);
35 };
36 return page;
37 };
38 return document;
39 });
40 return Object.defineProperty(task, "promise", { value: promise });
41 },
42 };
43});
44​
45import PdfViewer from "./PdfViewer";
46​
47// Page 1 is a short banner whose title is drawn in two runs with a font change, so pdf.js
48// splits it into two text spans. Page 2 is a letter page rotated a quarter turn.
49function testPdf(
50 pages = [
51 {
52 box: "0 0 612 140",
53 rotate: 0,
54 text: "BT /F1 24 Tf 30 70 Td (Quarter) Tj /F2 24 Tf (ly outlook) Tj ET",
55 },
56 { box: "0 0 612 792", rotate: 90, text: "BT /F1 24 Tf 30 700 Td (Budget and risks) Tj ET" },
57 ],
58) {
59 const fonts = 3 + pages.length * 2;
60 const objects = [
61 "<< /Type /Catalog /Pages 2 0 R >>",
62 `<< /Type /Pages /Count ${pages.length} /Kids [${pages.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] >>`,
63 ...pages.flatMap(({ box, rotate, text }, index) => [
64 `<< /Type /Page /Parent 2 0 R /MediaBox [${box}] /Rotate ${rotate} /Resources << /Font << /F1 ${fonts} 0 R /F2 ${fonts + 1} 0 R >> >> /Contents ${4 + index * 2} 0 R >>`,
65 `<< /Length ${text.length} >>\nstream\n${text}\nendstream`,
66 ]),
67 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
68 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
69 ];
70 let source = "%PDF-1.4\n";
71 const offsets: number[] = [];
72 for (const [index, object] of objects.entries()) {
73 offsets.push(source.length);
74 source += `${index + 1} 0 obj\n${object}\nendobj\n`;
75 }
76 const xref = source.length;
77 source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets
78 .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`)
79 .join("")}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
80 return new Blob([source], { type: "application/pdf" });
82​
83it("fits the width, then finds split and rotated text and jumps to its page", async () => {
84 const url = URL.createObjectURL(testPdf());
85 try {
86 const container = document.createElement("div");
87 container.style.cssText = "display:flex;width:500px;height:600px";
88 await using screen = await render(<PdfViewer src={url} title="Planning PDF" />, {
89 container,
90 });
91 // The milestone is pdf.js booting its worker and parsing the document, not a frame count.
92 await expect.element(screen.getByText("1 / 2 pages"), { timeout: 10_000 }).toBeVisible();
93 const region = screen.container.querySelector<HTMLElement>('[role="region"]')!;
94 // Fit width follows the measured viewport, which settles once the scrollbar appears.
95 await vi.waitFor(() => {
96 const canvas = region.querySelector('canvas[aria-label="Page 1"]')!;
97 expect(canvas.getBoundingClientRect().width).toBe(region.clientWidth - 32);
98 expect(region.scrollWidth).toBe(region.clientWidth);
99 });
100​
101 const find = screen.getByRole("searchbox", { name: "Find in PDF" });
102 await find.fill("quarterly outlook");
103 await expect.element(screen.getByRole("status")).toHaveTextContent("1/1");
104 await vi.waitFor(() =>
105 expect(
106 Array.from(region.querySelectorAll(".pdf-find-match"), (span) => span.textContent),
107 ).toEqual(["Quarter", "ly outlook"]),
108 );
109​
110 await find.fill("budget");
111 await expect.element(screen.getByText("2 / 2 pages")).toBeVisible();
112 await expect.element(screen.getByRole("status")).toHaveTextContent("1/1");
113 const rotated = await vi.waitFor(() => {
114 const span = Array.from(region.querySelectorAll("[data-pdf-text-layer] span")).find(
115 (node) => node.textContent === "Budget and risks",
116 );
117 expect(span).toBeDefined();
118 return span!;
119 });
120 // The rotated page's text runs top to bottom inside the page, so a selection lands on it.
121 const textBounds = rotated.getBoundingClientRect();
122 const pageBounds = rotated.closest("[data-pdf-text-layer]")!.getBoundingClientRect();
123 expect(textBounds.height).toBeGreaterThan(textBounds.width);
124 expect(textBounds.width).toBeGreaterThan(0);
125 expect(textBounds.left).toBeGreaterThanOrEqual(pageBounds.left - 1);
126 expect(textBounds.top).toBeGreaterThanOrEqual(pageBounds.top - 1);
127 expect(textBounds.right).toBeLessThanOrEqual(pageBounds.right + 1);
128 expect(textBounds.bottom).toBeLessThanOrEqual(pageBounds.bottom + 1);
129 expect(getComputedStyle(rotated).userSelect).toBe("text");
130 const range = document.createRange();
131 range.selectNodeContents(rotated);
132 const selection = window.getSelection()!;
133 selection.removeAllRanges();
134 selection.addRange(range);
135 expect(selection.toString()).toBe("Budget and risks");
136 selection.removeAllRanges();
137​
138 await find.fill(" ");
139 await expect.element(screen.getByRole("status")).toHaveTextContent("0/0");
140 } finally {
141 URL.revokeObjectURL(url);
142 }
143});
144​
145it("jumps to the first hit while still counting and keeps later manual navigation", async () => {
146 setup(4);
147 const page = (text: string) => ({
148 box: "0 0 612 792",
149 rotate: 0,
150 text: `BT /F1 24 Tf 30 700 Td (${text}) Tj ET`,
151 });
152 const url = URL.createObjectURL(
153 testPdf([page("Intro"), page("Needle one"), page("Needle two"), page("Closing")]),
154 );
155 try {
156 await using screen = await render(
157 <div className="flex h-[300px] w-[320px]">
158 <PdfViewer src={url} title="Searched PDF" />
159 </div>,
160 );
161 await expect.element(screen.getByText("1 / 4 pages")).toBeVisible();
162 await screen.getByRole("searchbox", { name: "Find in PDF" }).fill("needle");
163 await expect.element(screen.getByText("2 / 4 pages")).toBeVisible();
164 await expect.element(screen.getByRole("status")).toHaveTextContent("Searching…");
165​
166 await screen.getByRole("button", { name: "Next page", exact: true }).click();
167 await expect.element(screen.getByText("3 / 4 pages")).toBeVisible();
168 findGate.open();
169 await expect.element(screen.getByRole("status")).toHaveTextContent("1/2");
170 await expect.element(screen.getByText("3 / 4 pages")).toBeVisible();
171 } finally {
172 findGate.open();
173 URL.revokeObjectURL(url);
174 }
175});
176​
177it("restores page, within-page offset and zoom after a remount", async () => {
178 const url = URL.createObjectURL(testPdf());
179 const viewer = (mount: number, positionKey: string) => (
180 <div className="flex h-[300px] w-[320px]">
181 <PdfViewer key={mount} src={url} title="Restored PDF" positionKey={positionKey} />
182 </div>
183 );
184 try {
185 await using screen = await render(viewer(1, "restore:a"));
186 await expect.element(screen.getByText("1 / 2 pages")).toBeVisible();
187 await screen.getByRole("button", { name: "Zoom in", exact: true }).click();
188 await screen.getByRole("button", { name: "Next page", exact: true }).click();
189 await expect.element(screen.getByText("2 / 2 pages")).toBeVisible();
190 const region = () => screen.getByRole("region", { name: "Restored PDF" }).element();
191 const top = region().scrollTop + 40;
192 const scrolled = new Promise((resolve) =>
193 region().addEventListener("scroll", resolve, { once: true }),
194 );
195 region().scrollTop = top;
196 await scrolled;
197 await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(top, 0));
198 const zoom = screen.container.textContent!.match(/\d+%/)![0];
199​
200 await screen.rerender(viewer(2, "restore:a"));
201 await expect.element(screen.getByText("2 / 2 pages")).toBeVisible();
202 await vi.waitFor(() => expect(region().scrollTop).toBeCloseTo(top, 0));
203 await vi.waitFor(() => expect(screen.container.textContent).toContain(zoom));
204​
205 await screen.getByRole("button", { name: "Fit width", exact: true }).click();
206 await vi.waitFor(() => {
207 const canvas = region().querySelector("canvas")!;
208 expect(canvas.getBoundingClientRect().width).toBe(region().clientWidth - 32);
209 });
210 const fitTop = region().scrollTop;
211 await screen.rerender(viewer(3, "restore:a"));
212 await expect.element(screen.getByText("2 / 2 pages")).toBeVisible();
213 await vi.waitFor(() => {
214 expect(region().querySelector("canvas")!.getBoundingClientRect().width).toBe(
215 region().clientWidth - 32,
216 );
217 expect(region().scrollTop).toBeCloseTo(fitTop, 0);
218 });
219​
220 await screen.rerender(viewer(4, "restore:b"));
221 await expect.element(screen.getByText("1 / 2 pages")).toBeVisible();
222 await expect.element(screen.getByText("Fit width")).toBeVisible();
223 } finally {
224 URL.revokeObjectURL(url);
225 }
226});