@elabs-ai/components-viewer 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/chunk-2NQ4RSJ3.js +18 -0
  4. package/dist/chunk-2NQ4RSJ3.js.map +1 -0
  5. package/dist/chunk-3U3MESBU.js +21 -0
  6. package/dist/chunk-3U3MESBU.js.map +1 -0
  7. package/dist/chunk-4OFQYALG.js +147 -0
  8. package/dist/chunk-4OFQYALG.js.map +1 -0
  9. package/dist/chunk-5OMONA2G.js +87 -0
  10. package/dist/chunk-5OMONA2G.js.map +1 -0
  11. package/dist/chunk-AORNMH77.js +58 -0
  12. package/dist/chunk-AORNMH77.js.map +1 -0
  13. package/dist/chunk-BCF3RWRL.js +18 -0
  14. package/dist/chunk-BCF3RWRL.js.map +1 -0
  15. package/dist/chunk-FELIAGKI.js +22 -0
  16. package/dist/chunk-FELIAGKI.js.map +1 -0
  17. package/dist/chunk-GGR7BNW4.js +18 -0
  18. package/dist/chunk-GGR7BNW4.js.map +1 -0
  19. package/dist/chunk-H3OAON3D.js +22 -0
  20. package/dist/chunk-H3OAON3D.js.map +1 -0
  21. package/dist/chunk-HMG2ERXH.js +17 -0
  22. package/dist/chunk-HMG2ERXH.js.map +1 -0
  23. package/dist/chunk-KOMDRWOU.js +21 -0
  24. package/dist/chunk-KOMDRWOU.js.map +1 -0
  25. package/dist/chunk-NMA57QZ7.js +117 -0
  26. package/dist/chunk-NMA57QZ7.js.map +1 -0
  27. package/dist/chunk-RGWDIQJR.js +26 -0
  28. package/dist/chunk-RGWDIQJR.js.map +1 -0
  29. package/dist/chunk-SLXRUYT3.js +91 -0
  30. package/dist/chunk-SLXRUYT3.js.map +1 -0
  31. package/dist/chunk-UJIJECEF.js +21 -0
  32. package/dist/chunk-UJIJECEF.js.map +1 -0
  33. package/dist/chunk-UL43NGUG.js +68 -0
  34. package/dist/chunk-UL43NGUG.js.map +1 -0
  35. package/dist/chunk-VO2273Z2.js +55 -0
  36. package/dist/chunk-VO2273Z2.js.map +1 -0
  37. package/dist/code-adapter-ADZ4UOGN.js +226 -0
  38. package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
  39. package/dist/csv-adapter-6VU3FFVU.js +100 -0
  40. package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
  41. package/dist/docx-adapter-5CQDHWTD.js +383 -0
  42. package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
  43. package/dist/image-adapter-WOHZR24J.js +132 -0
  44. package/dist/image-adapter-WOHZR24J.js.map +1 -0
  45. package/dist/index.d.ts +1353 -0
  46. package/dist/index.js +1364 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/json-adapter-ZUW5GQHE.js +90 -0
  49. package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
  50. package/dist/markdown-adapter-YC6WTBS4.js +214 -0
  51. package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
  52. package/dist/media-adapter-MCTB4GBH.js +78 -0
  53. package/dist/media-adapter-MCTB4GBH.js.map +1 -0
  54. package/dist/pdf-adapter-5PMKEXUD.js +424 -0
  55. package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
  56. package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
  57. package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
  58. package/dist/text-adapter-NFNWB5W3.js +81 -0
  59. package/dist/text-adapter-NFNWB5W3.js.map +1 -0
  60. package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
  61. package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
  62. package/package.json +103 -0
  63. package/src/adapters/code/code-adapter.test.tsx +221 -0
  64. package/src/adapters/code/code-adapter.tsx +272 -0
  65. package/src/adapters/code/code-language.test.ts +45 -0
  66. package/src/adapters/code/code-language.ts +100 -0
  67. package/src/adapters/code/code-manifest.ts +19 -0
  68. package/src/adapters/code/code-theme.test.ts +46 -0
  69. package/src/adapters/code/code-theme.ts +99 -0
  70. package/src/adapters/csv/csv-adapter.test.tsx +169 -0
  71. package/src/adapters/csv/csv-adapter.tsx +146 -0
  72. package/src/adapters/csv/csv-manifest.ts +20 -0
  73. package/src/adapters/docx/docx-adapter.test.tsx +192 -0
  74. package/src/adapters/docx/docx-adapter.tsx +328 -0
  75. package/src/adapters/docx/docx-manifest.ts +18 -0
  76. package/src/adapters/docx/docx-model.test.ts +181 -0
  77. package/src/adapters/docx/docx-model.ts +294 -0
  78. package/src/adapters/image/image-adapter.test.tsx +141 -0
  79. package/src/adapters/image/image-adapter.tsx +191 -0
  80. package/src/adapters/image/image-manifest.ts +15 -0
  81. package/src/adapters/index.ts +64 -0
  82. package/src/adapters/json/json-adapter.test.tsx +77 -0
  83. package/src/adapters/json/json-adapter.tsx +141 -0
  84. package/src/adapters/json/json-manifest.ts +11 -0
  85. package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
  86. package/src/adapters/markdown/markdown-adapter.tsx +353 -0
  87. package/src/adapters/markdown/markdown-manifest.ts +21 -0
  88. package/src/adapters/markdown/markdown-marks.test.ts +85 -0
  89. package/src/adapters/markdown/markdown-marks.ts +119 -0
  90. package/src/adapters/media/media-adapter.test.tsx +79 -0
  91. package/src/adapters/media/media-adapter.tsx +123 -0
  92. package/src/adapters/media/media-manifest.ts +17 -0
  93. package/src/adapters/office-fixture.ts +239 -0
  94. package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
  95. package/src/adapters/pdf/pdf-adapter.tsx +599 -0
  96. package/src/adapters/pdf/pdf-engine.ts +171 -0
  97. package/src/adapters/pdf/pdf-fixture.ts +27 -0
  98. package/src/adapters/pdf/pdf-manifest.ts +30 -0
  99. package/src/adapters/pdf/pdf-text.test.ts +171 -0
  100. package/src/adapters/pdf/pdf-text.ts +202 -0
  101. package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
  102. package/src/adapters/pptx/pptx-adapter.tsx +341 -0
  103. package/src/adapters/pptx/pptx-manifest.ts +19 -0
  104. package/src/adapters/pptx/pptx-model.test.ts +219 -0
  105. package/src/adapters/pptx/pptx-model.ts +248 -0
  106. package/src/adapters/text/text-adapter.test.tsx +125 -0
  107. package/src/adapters/text/text-adapter.tsx +117 -0
  108. package/src/adapters/text/text-manifest.ts +19 -0
  109. package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
  110. package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
  111. package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
  112. package/src/components/grid-text.test.ts +68 -0
  113. package/src/components/grid-text.ts +77 -0
  114. package/src/components/marked-text.tsx +36 -0
  115. package/src/components/sheet-table.tsx +142 -0
  116. package/src/core/errors.ts +120 -0
  117. package/src/core/highlight-marks.test.ts +95 -0
  118. package/src/core/highlight-marks.ts +95 -0
  119. package/src/core/highlight-resolve.test.ts +208 -0
  120. package/src/core/highlight-resolve.ts +191 -0
  121. package/src/core/highlight.ts +125 -0
  122. package/src/core/registry.test.ts +172 -0
  123. package/src/core/registry.ts +188 -0
  124. package/src/core/scroll-host.ts +42 -0
  125. package/src/core/text-index.test.ts +95 -0
  126. package/src/core/text-index.ts +159 -0
  127. package/src/core/types.ts +285 -0
  128. package/src/core/use-highlight-scroll.ts +59 -0
  129. package/src/core/use-page-control.ts +47 -0
  130. package/src/core/use-paged-scroll.ts +194 -0
  131. package/src/core/use-viewport-size.ts +54 -0
  132. package/src/core/zoom.ts +56 -0
  133. package/src/file-viewer/file-viewer-context.tsx +239 -0
  134. package/src/file-viewer/file-viewer-find.tsx +204 -0
  135. package/src/file-viewer/file-viewer-pager.tsx +120 -0
  136. package/src/file-viewer/file-viewer-zoom.tsx +166 -0
  137. package/src/file-viewer/file-viewer.stories.tsx +947 -0
  138. package/src/file-viewer/file-viewer.test.tsx +947 -0
  139. package/src/file-viewer/file-viewer.tsx +1143 -0
  140. package/src/index.ts +157 -0
@@ -0,0 +1,489 @@
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { useState } from "react";
3
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import type { ResolvedHighlight } from "../../core/highlight";
6
+ import { createTextIndexBuilder } from "../../core/text-index";
7
+ import type { AdapterRendererProps } from "../../core/types";
8
+ import adapterModule, { toTextSpans } from "./pdf-adapter";
9
+ import { openPdfDocument as openPdfDocumentImpl } from "./pdf-engine";
10
+ import type { PdfDocument, PdfPage, PdfSession, PdfTextItem } from "./pdf-engine";
11
+ import { PDF_PAGE_SEPARATOR, pageTextChunk } from "./pdf-text";
12
+
13
+ // The engine itself is exercised in the browser (the story opens a real PDF);
14
+ // here it is stubbed so the adapter's OWN contract — page count, text, dispose,
15
+ // error mapping — can be asserted without a worker or a canvas.
16
+ vi.mock("./pdf-engine", () => ({ openPdfDocument: vi.fn() }));
17
+ const openPdfDocument = vi.mocked(openPdfDocumentImpl);
18
+
19
+ /**
20
+ * A stand-in for a pdf.js document.
21
+ *
22
+ * jsdom has no 2D canvas context, so the renderer's raster path bails early by
23
+ * design — which is exactly what lets the page and zoom wiring be tested here.
24
+ * The raster itself is verified in the browser, by the story.
25
+ *
26
+ * It carries NO `destroy` on purpose: the real `PDFDocumentProxy` has none
27
+ * (pdf.js removed it in v5, and the worker belongs to the loading task). A stub
28
+ * that offered one is precisely what let `dispose()` ship broken — it threw a
29
+ * `TypeError` on every real unmount and leaked a worker per file opened, while
30
+ * this suite stayed green.
31
+ */
32
+ function fakeHandle(numPages = 3, pages: PdfTextItem[][] = []): PdfDocument {
33
+ const pageFor = (pageNumber: number): PdfPage => ({
34
+ getViewport: ({ scale }) => ({ width: 612 * scale, height: 792 * scale }),
35
+ render: () => ({ promise: Promise.resolve(), cancel: () => undefined }),
36
+ getTextContent: () => Promise.resolve({ items: pages[pageNumber - 1] ?? [] }),
37
+ cleanup: () => undefined,
38
+ });
39
+ return {
40
+ numPages,
41
+ getPage: (pageNumber) => Promise.resolve(pageFor(pageNumber)),
42
+ };
43
+ }
44
+
45
+ /** What `openPdfDocument` actually resolves to: the document plus its teardown. */
46
+ function fakeSession(document: PdfDocument): PdfSession {
47
+ return { document, destroy: () => Promise.resolve() };
48
+ }
49
+
50
+ const SOURCE = {
51
+ name: "report.pdf",
52
+ mediaType: "application/pdf",
53
+ category: "document",
54
+ extension: "pdf",
55
+ bytes: () => Promise.resolve(new ArrayBuffer(0)),
56
+ text: () => Promise.resolve(""),
57
+ url: () => Promise.resolve("blob:report"),
58
+ revoke: () => undefined,
59
+ } as unknown as AdapterRendererProps["source"];
60
+
61
+ /** US Letter at 72dpi — what `fakeHandle`'s viewport reports at scale 1. */
62
+ const LETTER = { width: 612, height: 792 };
63
+
64
+ /** One page's slot in the scrolling column: the page, its border, and the gap. */
65
+ const SLOT_HEIGHT = 792 + 2 + 16;
66
+
67
+ function renderPdf(numPages = 3, props: Partial<AdapterRendererProps> = {}) {
68
+ const document = {
69
+ kind: "pdf" as const,
70
+ handle: fakeHandle(numPages),
71
+ pageCount: numPages,
72
+ // What the loader captures while extracting text. Present here so the
73
+ // column's reserved height is a real number rather than the fallback.
74
+ pageSizes: Array.from({ length: numPages }, () => LETTER),
75
+ };
76
+ const { Renderer } = adapterModule;
77
+ return render(<Renderer document={document} source={SOURCE} {...props} />);
78
+ }
79
+
80
+ /** Where the column was asked to scroll to, in CSS pixels. */
81
+ function scrolledTo(scrollTo: ReturnType<typeof vi.fn>): number | undefined {
82
+ const last = scrollTo.mock.calls.at(-1)?.[0] as { top?: number } | undefined;
83
+ return last?.top;
84
+ }
85
+
86
+ /** Every mounted page, in document order. */
87
+ const pages = () => globalThis.document.querySelectorAll('[data-slot="pdf-page"]');
88
+
89
+ // jsdom has no 2D context and logs a "Not implemented" stack every time one is
90
+ // asked for. Returning null is the same answer, without burying the run in noise
91
+ // — and it is the code path the renderer is written to survive.
92
+ let getContext: ReturnType<typeof vi.spyOn>;
93
+
94
+ // jsdom implements no scrolling whatsoever, so the programmatic scroll is the
95
+ // only observable half of "take me to page N" here. What the reader then sees —
96
+ // the pages that mount, the pager following the gesture back — is browser
97
+ // behaviour, and the story is what verifies it.
98
+ const scrollTo = vi.fn();
99
+
100
+ beforeAll(() => {
101
+ getContext = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as typeof getContext;
102
+ getContext.mockReturnValue(null);
103
+ Object.defineProperty(HTMLElement.prototype, "scrollTo", {
104
+ configurable: true,
105
+ writable: true,
106
+ value: scrollTo,
107
+ });
108
+ });
109
+
110
+ beforeEach(() => {
111
+ scrollTo.mockClear();
112
+ });
113
+
114
+ describe("pdf renderer — page and zoom are the shell's, not the adapter's", () => {
115
+ it("names the page for assistive tech instead of exposing a bare canvas", async () => {
116
+ renderPdf();
117
+ expect(await screen.findByRole("img", { name: "Page 1" })).toBeInTheDocument();
118
+ });
119
+
120
+ it("stacks the pages — a document scrolls, it does not flip", async () => {
121
+ // The headline of continuous scroll: page 2 is already there, one gesture
122
+ // below page 1, rather than behind a "next" button that swaps the canvas.
123
+ renderPdf(3);
124
+ expect(await screen.findByRole("img", { name: "Page 1" })).toBeInTheDocument();
125
+ expect(await screen.findByRole("img", { name: "Page 2" })).toBeInTheDocument();
126
+ });
127
+
128
+ it("reserves the whole document's height, so the scrollbar never lies", () => {
129
+ // Sized from `pageSizes` rather than from what has rendered, so the thumb
130
+ // does not shrink page by page as a long file streams in.
131
+ const { container } = renderPdf(3);
132
+ const column = container.querySelector('[data-slot="pdf-pages"]') as HTMLElement;
133
+ expect(column.style.height).toBe(`${3 * SLOT_HEIGHT}px`);
134
+ });
135
+
136
+ it("mounts a window, not the document — a long file costs what is on screen", async () => {
137
+ // 200 pages is 200 canvases and 200 rasterization jobs if the column is a
138
+ // plain stack. The cost has to follow the viewport, not the page count.
139
+ renderPdf(200);
140
+ await screen.findByRole("img", { name: "Page 1" });
141
+ expect(pages().length).toBeLessThan(10);
142
+ expect(screen.queryByRole("img", { name: "Page 60" })).not.toBeInTheDocument();
143
+ });
144
+
145
+ it("draws no chrome of its own — the pager lives in the shell (ADR 0026)", () => {
146
+ renderPdf();
147
+ expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
148
+ expect(screen.queryByRole("button", { name: "Next page" })).not.toBeInTheDocument();
149
+ });
150
+
151
+ it("scrolls to the page the shell asked for, including on the very first frame", async () => {
152
+ // A deep link mounts already pointing at page 3. Nothing has scrolled yet,
153
+ // so the request has to survive mount rather than be answered with "you are
154
+ // on page 1".
155
+ renderPdf(3, { pageNumber: 3 });
156
+ await waitFor(() => expect(scrolledTo(scrollTo)).toBe(2 * SLOT_HEIGHT));
157
+ });
158
+
159
+ it("clamps a page number past the end of the document", async () => {
160
+ // A stale deep link ("page 9 of a 3-page file") lands on the last page
161
+ // rather than scrolling past the end — the shell may be driven by a URL
162
+ // nobody validated.
163
+ renderPdf(3, { pageNumber: 9 });
164
+ await waitFor(() => expect(scrolledTo(scrollTo)).toBe(2 * SLOT_HEIGHT));
165
+ });
166
+
167
+ it("pages itself when it is mounted outside a provider", async () => {
168
+ // A `Renderer` used directly still has to be usable, so the page control is
169
+ // the controlled/uncontrolled trio, not a required prop.
170
+ const onPageChange = vi.fn();
171
+ renderPdf(3, { onPageChange });
172
+ expect(await screen.findByRole("img", { name: "Page 1" })).toBeInTheDocument();
173
+ expect(onPageChange).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("reports the scale it resolved, so the shell can show a percentage", async () => {
177
+ const onZoomResolved = vi.fn();
178
+ renderPdf(1, { zoom: 2, onZoomResolved });
179
+ await waitFor(() => expect(onZoomResolved).toHaveBeenCalledWith(2));
180
+ });
181
+
182
+ it("falls back to 100% while a fit mode has nothing to measure against", async () => {
183
+ // jsdom reports every box as 0×0, which is exactly the "hidden element"
184
+ // case the measurement guard exists for: a fit that cannot be computed must
185
+ // not resolve to 0 and blank the page.
186
+ const onZoomResolved = vi.fn();
187
+ renderPdf(1, { zoom: "fit-width", onZoomResolved });
188
+ await waitFor(() => expect(onZoomResolved).toHaveBeenCalledWith(1));
189
+ });
190
+ });
191
+
192
+ describe("pdf adapter — load and dispose", () => {
193
+ it("reports the page count and extracts text, then releases the worker", async () => {
194
+ const handle = fakeHandle(4);
195
+ const session = fakeSession(handle);
196
+ const destroy = vi.spyOn(session, "destroy");
197
+ vi.spyOn(handle, "getPage").mockResolvedValue({
198
+ getViewport: ({ scale }) => ({ width: 612 * scale, height: 792 * scale }),
199
+ render: () => ({ promise: Promise.resolve(), cancel: () => undefined }),
200
+ getTextContent: () =>
201
+ Promise.resolve({ items: [{ str: "Hello", transform: [], width: 0, height: 0 }] }),
202
+ cleanup: () => undefined,
203
+ });
204
+ openPdfDocument.mockResolvedValue(session);
205
+
206
+ const adapter = adapterModule.create();
207
+ const document = await adapter.load(
208
+ {
209
+ name: "report.pdf",
210
+ bytes: () => Promise.resolve(new ArrayBuffer(8)),
211
+ } as unknown as AdapterRendererProps["source"],
212
+ {},
213
+ );
214
+
215
+ expect(document.pageCount).toBe(4);
216
+ expect(document.text).toContain("Hello");
217
+
218
+ // A leaked pdf.js worker per opened document is minutes away from a
219
+ // browsing session that grinds to a halt. The teardown must go through the
220
+ // SESSION: the document proxy has no `destroy`, so reaching for one there
221
+ // throws a TypeError inside an unmount and releases nothing.
222
+ expect((handle as { destroy?: unknown }).destroy).toBeUndefined();
223
+ expect(() => adapter.dispose?.()).not.toThrow();
224
+ expect(destroy).toHaveBeenCalledTimes(1);
225
+ });
226
+
227
+ it("reports a corrupt file as parse-failed, not as a crash", async () => {
228
+ openPdfDocument.mockRejectedValue(new Error("Invalid PDF structure."));
229
+ const adapter = adapterModule.create();
230
+
231
+ await expect(
232
+ adapter.load(
233
+ {
234
+ name: "broken.pdf",
235
+ bytes: () => Promise.resolve(new ArrayBuffer(8)),
236
+ } as unknown as AdapterRendererProps["source"],
237
+ {},
238
+ ),
239
+ ).rejects.toMatchObject({ code: "parse-failed" });
240
+ });
241
+ });
242
+
243
+ describe("toTextSpans", () => {
244
+ it("flips the PDF origin so text lands over the raster", () => {
245
+ // PDF user space measures from the BOTTOM-left; CSS from the top. A glyph
246
+ // 700 units up a 792-tall page sits 92 - fontSize from the top.
247
+ const [span] = toTextSpans(
248
+ [{ str: "Hello", transform: [12, 0, 0, 12, 72, 700], width: 30 }],
249
+ 792,
250
+ 1,
251
+ );
252
+ expect(span).toEqual({
253
+ text: "Hello",
254
+ left: 72,
255
+ top: 792 - 700 - 12,
256
+ width: 30,
257
+ fontSize: 12,
258
+ item: 0,
259
+ });
260
+ });
261
+
262
+ it("scales geometry with the zoom level", () => {
263
+ const [span] = toTextSpans(
264
+ [{ str: "Hello", transform: [12, 0, 0, 12, 72, 700], width: 30 }],
265
+ 1584,
266
+ 2,
267
+ );
268
+ expect(span?.left).toBe(144);
269
+ expect(span?.fontSize).toBe(24);
270
+ expect(span?.width).toBe(60);
271
+ });
272
+
273
+ it("drops whitespace-only items — they would be invisible clutter in the DOM", () => {
274
+ expect(toTextSpans([{ str: " ", transform: [12, 0, 0, 12, 0, 0] }], 792, 1)).toEqual([]);
275
+ });
276
+
277
+ it("carries the item's original index, so a dropped item cannot shift the rest", () => {
278
+ const spans = toTextSpans(
279
+ [
280
+ { str: " ", transform: [12, 0, 0, 12, 0, 0] },
281
+ { str: "Hello", transform: [12, 0, 0, 12, 72, 700] },
282
+ ],
283
+ 792,
284
+ 1,
285
+ );
286
+ // Position 0 in the rendered spans, but item 1 in the projection.
287
+ expect(spans[0]?.item).toBe(1);
288
+ });
289
+ });
290
+
291
+ /* -------------------------------------------------------------------------- */
292
+ /* Highlights */
293
+ /* -------------------------------------------------------------------------- */
294
+
295
+ const PAGE_ONE: PdfTextItem[] = [
296
+ { str: "Alpha", transform: [12, 0, 0, 12, 72, 700], width: 40, height: 12 },
297
+ { str: "Beta", transform: [12, 0, 0, 12, 72, 680], width: 32, height: 12 },
298
+ ];
299
+ const PAGE_TWO: PdfTextItem[] = [
300
+ { str: "Gamma", transform: [12, 0, 0, 12, 72, 700], width: 48, height: 12 },
301
+ ];
302
+
303
+ /** The projection the loader would have built from those pages. */
304
+ function projection() {
305
+ const builder = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });
306
+ builder.push(pageTextChunk(PAGE_ONE).text, 1);
307
+ builder.push(pageTextChunk(PAGE_TWO).text, 2);
308
+ return builder.build();
309
+ }
310
+
311
+ /**
312
+ * The renderer under a stand-in for `FileViewerProvider`.
313
+ *
314
+ * Page state lives in the shell now, so "turns to the cited page" is only a
315
+ * real assertion if something up here actually holds it. This harness is the
316
+ * smallest thing that does what the provider does — hold the number, feed it
317
+ * back down — plus a spy, so the request itself can be asserted as well as its
318
+ * effect.
319
+ */
320
+ function Harness({
321
+ highlights,
322
+ activeHighlightId,
323
+ onPageChange,
324
+ }: {
325
+ highlights: ResolvedHighlight[];
326
+ activeHighlightId?: string;
327
+ onPageChange?: (page: number) => void;
328
+ }) {
329
+ // Built once: a fresh document identity per render would restart every effect
330
+ // the renderer keys on `handle`.
331
+ const [document] = useState(() => {
332
+ const textIndex = projection();
333
+ return {
334
+ kind: "pdf" as const,
335
+ handle: fakeHandle(2, [PAGE_ONE, PAGE_TWO]),
336
+ pageCount: 2,
337
+ text: textIndex.text,
338
+ textIndex,
339
+ };
340
+ });
341
+ const [page, setPage] = useState(1);
342
+ const { Renderer } = adapterModule;
343
+ return (
344
+ <Renderer
345
+ document={document}
346
+ source={SOURCE}
347
+ highlights={highlights}
348
+ activeHighlightId={activeHighlightId}
349
+ pageNumber={page}
350
+ onPageChange={(next) => {
351
+ setPage(next);
352
+ onPageChange?.(next);
353
+ }}
354
+ />
355
+ );
356
+ }
357
+
358
+ function renderHighlighted(
359
+ highlights: ResolvedHighlight[],
360
+ activeHighlightId?: string,
361
+ onPageChange?: (page: number) => void,
362
+ ) {
363
+ return render(
364
+ <Harness
365
+ highlights={highlights}
366
+ activeHighlightId={activeHighlightId}
367
+ onPageChange={onPageChange}
368
+ />,
369
+ );
370
+ }
371
+
372
+ const boxes = () => globalThis.document.querySelectorAll('[data-slot="highlight-rect"]');
373
+
374
+ describe("pdf renderer — highlights", () => {
375
+ // The raster path needs a 2D context to get as far as the text layer; jsdom
376
+ // has none, so a bare object stands in for it. Nothing here draws pixels — it
377
+ // is the geometry that is under test.
378
+ beforeEach(() => {
379
+ getContext.mockReturnValue({} as unknown as CanvasRenderingContext2D);
380
+ });
381
+ afterAll(() => {
382
+ getContext.mockReturnValue(null);
383
+ });
384
+
385
+ it("paints a box over the passage a `range` address located", async () => {
386
+ // "Beta" — the second item of page 1, at offsets 6–10.
387
+ renderHighlighted([
388
+ {
389
+ id: "c1",
390
+ source: "citation",
391
+ status: "resolved",
392
+ address: { kind: "range", start: 6, end: 10 },
393
+ range: [6, 10],
394
+ active: false,
395
+ },
396
+ ]);
397
+
398
+ await waitFor(() => expect(boxes()).toHaveLength(1));
399
+ // 792 - 680 - 12 from the top, at the item's own left edge and width.
400
+ expect((boxes()[0] as HTMLElement).style.left).toBe("72px");
401
+ expect((boxes()[0] as HTMLElement).style.width).toBe("32px");
402
+ });
403
+
404
+ it("paints a `rect` address with no text involved at all", async () => {
405
+ renderHighlighted([
406
+ {
407
+ id: "c1",
408
+ source: "citation",
409
+ status: "resolved",
410
+ address: { kind: "rect", page: 1, rects: [{ x: 0.5, y: 0.25, width: 0.25, height: 0.1 }] },
411
+ rects: [{ x: 0.5, y: 0.25, width: 0.25, height: 0.1 }],
412
+ page: 1,
413
+ active: true,
414
+ },
415
+ ]);
416
+
417
+ await waitFor(() => expect(boxes()).toHaveLength(1));
418
+ const box = boxes()[0] as HTMLElement;
419
+ // Fractions of the 612 × 792 page, so the same address survives a zoom.
420
+ expect(box.style.left).toBe("306px");
421
+ expect(box.style.top).toBe("198px");
422
+ // Presence form, the same shape every other painter emits, so one selector
423
+ // finds the current highlight regardless of who drew it.
424
+ expect(box).toHaveAttribute("data-active", "");
425
+ });
426
+
427
+ it("keeps a rect on the page it belongs to", async () => {
428
+ renderHighlighted([
429
+ {
430
+ id: "c1",
431
+ source: "citation",
432
+ status: "resolved",
433
+ address: { kind: "rect", page: 2, rects: [{ x: 0, y: 0, width: 1, height: 0.1 }] },
434
+ rects: [{ x: 0, y: 0, width: 1, height: 0.1 }],
435
+ page: 2,
436
+ active: false,
437
+ },
438
+ ]);
439
+
440
+ await screen.findByRole("img", { name: "Page 1" });
441
+ await waitFor(() => expect(boxes()).toHaveLength(1));
442
+ // Both pages are mounted at once under continuous scroll, so "on page 2" is
443
+ // a claim about WHERE the box is drawn — not about how few exist.
444
+ expect(boxes()[0]?.closest('[data-slot="pdf-page"]')).toHaveAttribute("data-page", "2");
445
+ });
446
+
447
+ it("asks the shell to turn to the page the active citation is on", async () => {
448
+ // "Gamma" lives on page 2, at offsets 12–17 of the projection.
449
+ const onPageChange = vi.fn();
450
+ renderHighlighted(
451
+ [
452
+ {
453
+ id: "c1",
454
+ source: "citation",
455
+ status: "resolved",
456
+ address: { kind: "range", start: 12, end: 17 },
457
+ range: [12, 17],
458
+ active: true,
459
+ },
460
+ ],
461
+ "c1",
462
+ onPageChange,
463
+ );
464
+
465
+ // The renderer no longer owns the pager, so "navigate first" is a REQUEST
466
+ // it makes — and the mark only paints once the shell honours it.
467
+ await waitFor(() => expect(onPageChange).toHaveBeenCalledWith(2));
468
+ expect(await screen.findByRole("img", { name: "Page 2" })).toBeInTheDocument();
469
+ await waitFor(() => expect(boxes()).toHaveLength(1));
470
+ });
471
+
472
+ it("keeps the boxes out of the accessibility tree — the text layer has the words", async () => {
473
+ const { container } = renderHighlighted([
474
+ {
475
+ id: "c1",
476
+ source: "citation",
477
+ status: "resolved",
478
+ address: { kind: "range", start: 0, end: 5 },
479
+ range: [0, 5],
480
+ active: false,
481
+ },
482
+ ]);
483
+
484
+ await waitFor(() => expect(boxes()).toHaveLength(1));
485
+ expect(container.querySelector('[aria-hidden="true"] [data-slot="highlight-rect"]')).not.toBe(
486
+ null,
487
+ );
488
+ });
489
+ });