@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PDF adapter — the format nothing in this repo could open before.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this one renders a canvas, and why that is not a rule break
|
|
7
|
+
*
|
|
8
|
+
* "Adapters emit data, never HTML" is about CHROME: an adapter may not smuggle
|
|
9
|
+
* colours, fonts or borders past the token layer. A PDF page's pixels ARE the
|
|
10
|
+
* document — a PDF is a description of marks on a page, and reproducing it is
|
|
11
|
+
* the whole job. So the page is rasterized to a `<canvas>` in the file's own
|
|
12
|
+
* colours, while everything AROUND it (the pager, the zoom control, the page
|
|
13
|
+
* frame, the loading and error states) is brand-ui components on tokens.
|
|
14
|
+
*
|
|
15
|
+
* ## The text layer
|
|
16
|
+
*
|
|
17
|
+
* A canvas has no text, so a canvas-only PDF viewer is unusable: nothing to
|
|
18
|
+
* select, copy, search or read aloud. pdf.js exposes the text with per-item
|
|
19
|
+
* transforms, so we position transparent spans over the page. That is the same
|
|
20
|
+
* approach pdf.js's own viewer takes, and it is what makes the page selectable
|
|
21
|
+
* and reachable by a screen reader.
|
|
22
|
+
*
|
|
23
|
+
* ## Highlights are boxes, not marks
|
|
24
|
+
*
|
|
25
|
+
* Every other text adapter paints a `<mark>` around the cited characters. There
|
|
26
|
+
* is no text to wrap here — the words are pixels — so a citation is a rectangle
|
|
27
|
+
* over the raster, positioned from the page's own geometry (`pdf-text.ts`). The
|
|
28
|
+
* same layer draws a `rect` address, which is why this is the one adapter that
|
|
29
|
+
* can honour geometry a text pipeline never produced.
|
|
30
|
+
*
|
|
31
|
+
* ## The chrome is not here
|
|
32
|
+
*
|
|
33
|
+
* The pager and the zoom control used to live in this file, over `useState` no
|
|
34
|
+
* one outside the canvas could reach. They are now shell parts over provider
|
|
35
|
+
* state (ADR 0026): this renderer draws the page it is TOLD to, at the scale it
|
|
36
|
+
* is told to, and reports back what a fit mode resolved to. It keeps its own
|
|
37
|
+
* page only when mounted outside a provider.
|
|
38
|
+
*
|
|
39
|
+
* ## Pages scroll; they are not flipped
|
|
40
|
+
*
|
|
41
|
+
* The document is one continuous column of pages, virtualized over the shell's
|
|
42
|
+
* viewport (`usePagedScroll`). A pager that replaced the canvas made a PDF the
|
|
43
|
+
* only thing in the app you could not simply scroll, and it broke the two
|
|
44
|
+
* gestures readers actually use — skimming, and dragging the scrollbar to
|
|
45
|
+
* "about two thirds in". The page number is now a consequence of where the
|
|
46
|
+
* reader is, and typing one is a scroll.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { cn, StatePanel, useLocale, type ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
50
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
51
|
+
|
|
52
|
+
import { toViewerError } from "../../core/errors";
|
|
53
|
+
import type { ResolvedHighlight } from "../../core/highlight";
|
|
54
|
+
import { toMarkRanges, type MarkRanges } from "../../core/highlight-marks";
|
|
55
|
+
import { createTextIndexBuilder, type TextIndex } from "../../core/text-index";
|
|
56
|
+
import { useScrollActiveHighlightIntoView } from "../../core/use-highlight-scroll";
|
|
57
|
+
import { usePageControl } from "../../core/use-page-control";
|
|
58
|
+
import { usePagedScroll } from "../../core/use-paged-scroll";
|
|
59
|
+
import { useViewportSize } from "../../core/use-viewport-size";
|
|
60
|
+
import type {
|
|
61
|
+
AdapterDocument,
|
|
62
|
+
AdapterLoadContext,
|
|
63
|
+
AdapterModule,
|
|
64
|
+
AdapterRendererProps,
|
|
65
|
+
FileAdapter,
|
|
66
|
+
PageSize,
|
|
67
|
+
} from "../../core/types";
|
|
68
|
+
import { DEFAULT_ZOOM } from "../../core/zoom";
|
|
69
|
+
import {
|
|
70
|
+
openPdfDocument,
|
|
71
|
+
type PdfDocument,
|
|
72
|
+
type PdfPage,
|
|
73
|
+
type PdfSession,
|
|
74
|
+
type PdfTextItem,
|
|
75
|
+
} from "./pdf-engine";
|
|
76
|
+
import { pdfManifest } from "./pdf-manifest";
|
|
77
|
+
import {
|
|
78
|
+
PDF_PAGE_SEPARATOR,
|
|
79
|
+
pageOfHighlight,
|
|
80
|
+
pageTextChunk,
|
|
81
|
+
rangeBoxes,
|
|
82
|
+
rectBoxes,
|
|
83
|
+
type PdfHighlightBox,
|
|
84
|
+
type PdfTextSpan,
|
|
85
|
+
} from "./pdf-text";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Breathing room kept around a fitted page, in CSS pixels.
|
|
89
|
+
*
|
|
90
|
+
* Without it a fit-to-width page is exactly as wide as the viewport, and the
|
|
91
|
+
* page frame's own 1px border tips it over into a horizontal scrollbar — which
|
|
92
|
+
* narrows the viewport, which re-fits smaller, which removes the scrollbar.
|
|
93
|
+
*/
|
|
94
|
+
const FIT_GUTTER = 16;
|
|
95
|
+
|
|
96
|
+
/** Sanity bounds on a fitted scale, so a degenerate measurement cannot blank the page. */
|
|
97
|
+
const MIN_SCALE = 0.1;
|
|
98
|
+
const MAX_SCALE = 10;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pages whose text is extracted for the plain-text projection.
|
|
102
|
+
*
|
|
103
|
+
* Extraction costs one worker round-trip per page, so doing it for a 900-page
|
|
104
|
+
* document at load time would stall the first paint for something most readers
|
|
105
|
+
* never use. The pages a reader actually looks at get their text layer rendered
|
|
106
|
+
* on demand regardless — this cap only bounds the eager `document.text`.
|
|
107
|
+
*/
|
|
108
|
+
export const PDF_TEXT_PAGE_LIMIT = 50;
|
|
109
|
+
|
|
110
|
+
export interface PdfViewerDocument extends AdapterDocument {
|
|
111
|
+
kind: "pdf";
|
|
112
|
+
/** The live pdf.js handle. Owned by the adapter — the renderer only reads it. */
|
|
113
|
+
handle: PdfDocument;
|
|
114
|
+
pageCount: number;
|
|
115
|
+
/** Page number per stretch of the projection, so a citation can turn the page. */
|
|
116
|
+
textIndex?: TextIndex<number>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Vertical space between two pages in the scrolling column, in CSS pixels. */
|
|
120
|
+
const PAGE_GAP = 16;
|
|
121
|
+
|
|
122
|
+
/** The page frame's own 1px border, top and bottom — part of the reserved height. */
|
|
123
|
+
const PAGE_BORDER = 2;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Height assumed for a page whose size was never measured, in CSS pixels.
|
|
127
|
+
*
|
|
128
|
+
* Only reached by a document with no extractable page at all; every real one
|
|
129
|
+
* contributes at least page 1 to `pageSizes`. US Letter at 96dpi.
|
|
130
|
+
*/
|
|
131
|
+
const FALLBACK_PAGE_HEIGHT = 1056;
|
|
132
|
+
|
|
133
|
+
class PdfAdapter implements FileAdapter {
|
|
134
|
+
#session?: PdfSession;
|
|
135
|
+
|
|
136
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<PdfViewerDocument> {
|
|
137
|
+
let buffer: ArrayBuffer;
|
|
138
|
+
try {
|
|
139
|
+
buffer = await source.bytes(context.signal);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
// pdf.js takes ownership of the buffer it is handed (it transfers it to
|
|
146
|
+
// the worker), so the view is built here rather than reusing the source's
|
|
147
|
+
// memoized copy in place.
|
|
148
|
+
const session = await openPdfDocument(new Uint8Array(buffer), context.signal);
|
|
149
|
+
this.#session = session;
|
|
150
|
+
const handle = session.document;
|
|
151
|
+
const { textIndex, pageSizes } = await extractText(handle, context.signal);
|
|
152
|
+
return {
|
|
153
|
+
kind: "pdf",
|
|
154
|
+
handle,
|
|
155
|
+
pageCount: handle.numPages,
|
|
156
|
+
pageSizes,
|
|
157
|
+
text: textIndex?.text,
|
|
158
|
+
textIndex,
|
|
159
|
+
// So a passage the shell cannot find in a 900-page report reads as "past
|
|
160
|
+
// the pages we extracted" rather than "not in this document".
|
|
161
|
+
textTruncated: handle.numPages > PDF_TEXT_PAGE_LIMIT || undefined,
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// A password-protected or corrupt file lands here; the engine failing to
|
|
165
|
+
// resolve is caught upstream and becomes `parser-missing`.
|
|
166
|
+
throw toViewerError(error, "parse-failed", { fileName: source.name });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
dispose(): void {
|
|
171
|
+
// Tears down the worker and its transferred buffers. Skipping this leaks a
|
|
172
|
+
// worker per document opened, which a file browser hits within minutes.
|
|
173
|
+
// The teardown lives on the SESSION, not on the document — see `PdfSession`.
|
|
174
|
+
void this.#session?.destroy();
|
|
175
|
+
this.#session = undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Best-effort plain text, plus the page each stretch of it came from — and each
|
|
181
|
+
* page's size, which the same loop already has in hand.
|
|
182
|
+
*
|
|
183
|
+
* Never throws: a document whose text will not extract still renders — the
|
|
184
|
+
* reader loses search and citations, not the file. Whatever was extracted before
|
|
185
|
+
* the failure is kept, because half a projection still answers half the queries.
|
|
186
|
+
*
|
|
187
|
+
* The index is built with the SAME join rule the renderer re-applies per page
|
|
188
|
+
* (`pageTextChunk`), so an offset in the projection and a box on the page can
|
|
189
|
+
* never disagree about which item they mean.
|
|
190
|
+
*
|
|
191
|
+
* The sizes are collected here rather than in their own pass because this loop
|
|
192
|
+
* has already paid for `getPage` — a second pass over a 900-page document would
|
|
193
|
+
* be 900 worker round-trips for numbers we are holding.
|
|
194
|
+
*/
|
|
195
|
+
async function extractText(
|
|
196
|
+
handle: PdfDocument,
|
|
197
|
+
signal?: AbortSignal,
|
|
198
|
+
): Promise<{ textIndex?: TextIndex<number>; pageSizes?: PageSize[] }> {
|
|
199
|
+
const pages = Math.min(handle.numPages, PDF_TEXT_PAGE_LIMIT);
|
|
200
|
+
const builder = createTextIndexBuilder<number>({ separator: PDF_PAGE_SEPARATOR });
|
|
201
|
+
const pageSizes: PageSize[] = [];
|
|
202
|
+
try {
|
|
203
|
+
for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {
|
|
204
|
+
if (signal?.aborted) break;
|
|
205
|
+
const page = await handle.getPage(pageNumber);
|
|
206
|
+
const { width, height } = page.getViewport({ scale: 1 });
|
|
207
|
+
pageSizes.push({ width, height });
|
|
208
|
+
const content = await page.getTextContent();
|
|
209
|
+
builder.push(pageTextChunk(content.items).text, pageNumber);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// Fall through: return what we have.
|
|
213
|
+
}
|
|
214
|
+
const index = builder.build();
|
|
215
|
+
return {
|
|
216
|
+
textIndex: index.text.length > 0 ? index : undefined,
|
|
217
|
+
pageSizes: pageSizes.length > 0 ? pageSizes : undefined,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* -------------------------------------------------------------------------- */
|
|
222
|
+
/* Renderer */
|
|
223
|
+
/* -------------------------------------------------------------------------- */
|
|
224
|
+
|
|
225
|
+
function PdfRenderer({
|
|
226
|
+
document: doc,
|
|
227
|
+
source,
|
|
228
|
+
className,
|
|
229
|
+
highlights,
|
|
230
|
+
activeHighlightId,
|
|
231
|
+
pageNumber: pageNumberProp,
|
|
232
|
+
onPageChange,
|
|
233
|
+
zoom = DEFAULT_ZOOM,
|
|
234
|
+
onZoomResolved,
|
|
235
|
+
}: AdapterRendererProps) {
|
|
236
|
+
const pdf = doc as PdfViewerDocument;
|
|
237
|
+
|
|
238
|
+
const [pageNumber, goToPage] = usePageControl(pageNumberProp, onPageChange, pdf.pageCount);
|
|
239
|
+
|
|
240
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
241
|
+
const viewport = useViewportSize(listRef);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A page's size at scale 1.
|
|
245
|
+
*
|
|
246
|
+
* Falls back to the first page for anything past what the adapter measured
|
|
247
|
+
* (`AdapterDocument.pageSizes` may be shorter than the page count). Uniform
|
|
248
|
+
* page size is overwhelmingly the common case, and a wrong guess costs a
|
|
249
|
+
* corrected scrollbar, not a wrong render.
|
|
250
|
+
*/
|
|
251
|
+
const naturalAt = useCallback(
|
|
252
|
+
(index: number): PageSize | undefined => pdf.pageSizes?.[index] ?? pdf.pageSizes?.[0],
|
|
253
|
+
[pdf.pageSizes],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// A fit mode is a REQUEST the renderer resolves — it is the only party that
|
|
257
|
+
// knows how wide its viewport is (`AdapterRendererProps.zoom`). Fitted to the
|
|
258
|
+
// page the reader is ON, so a landscape page in a portrait document does not
|
|
259
|
+
// re-fit the whole column.
|
|
260
|
+
const natural = naturalAt(pageNumber - 1);
|
|
261
|
+
const scale = useMemo(() => {
|
|
262
|
+
if (typeof zoom === "number") return zoom;
|
|
263
|
+
// Until the page and the pane have both been measured, 100% is a better
|
|
264
|
+
// first frame than a guess that jumps.
|
|
265
|
+
if (!natural || !viewport) return DEFAULT_ZOOM;
|
|
266
|
+
const byWidth = (viewport.width - FIT_GUTTER) / natural.width;
|
|
267
|
+
const fitted =
|
|
268
|
+
zoom === "fit-width"
|
|
269
|
+
? byWidth
|
|
270
|
+
: Math.min(byWidth, (viewport.height - FIT_GUTTER) / natural.height);
|
|
271
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, fitted));
|
|
272
|
+
}, [zoom, natural, viewport]);
|
|
273
|
+
|
|
274
|
+
// Report what the fit became, so the chrome can show a percentage and the
|
|
275
|
+
// reader's next "zoom in" steps from what is on screen.
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
onZoomResolved?.(scale);
|
|
278
|
+
}, [scale, onZoomResolved]);
|
|
279
|
+
|
|
280
|
+
const estimateSize = useCallback(
|
|
281
|
+
(index: number) =>
|
|
282
|
+
Math.round((naturalAt(index)?.height ?? FALLBACK_PAGE_HEIGHT) * scale) +
|
|
283
|
+
PAGE_BORDER +
|
|
284
|
+
PAGE_GAP,
|
|
285
|
+
[naturalAt, scale],
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const virtualizer = usePagedScroll({
|
|
289
|
+
count: pdf.pageCount,
|
|
290
|
+
listRef,
|
|
291
|
+
pageNumber,
|
|
292
|
+
goToPage,
|
|
293
|
+
estimateSize,
|
|
294
|
+
sizeKey: scale,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// LOCATE happened in the shell; the per-page views below do MAP and PAINT.
|
|
298
|
+
// Computed once here because it is a function of the whole projection, not of
|
|
299
|
+
// any one page.
|
|
300
|
+
const marks = useMemo(() => toMarkRanges(highlights, pdf.text?.length ?? 0), [highlights, pdf]);
|
|
301
|
+
|
|
302
|
+
// Scrolling to the cited page is the first half of "take me there"; the mark's
|
|
303
|
+
// own `scrollIntoView`, inside the page view, is the second. Keyed on the page
|
|
304
|
+
// NUMBER, so a reader who scrolls away while the same citation stays active is
|
|
305
|
+
// not dragged back.
|
|
306
|
+
const activePage = pageOfHighlight(
|
|
307
|
+
highlights?.find((highlight) => highlight.id === activeHighlightId),
|
|
308
|
+
pdf.textIndex,
|
|
309
|
+
);
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (activePage === undefined || activePage < 1 || activePage > pdf.pageCount) return;
|
|
312
|
+
goToPage(activePage);
|
|
313
|
+
}, [activePage, pdf.pageCount, goToPage]);
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
// No viewport of its own: `FileViewerContent` is the one scroll boundary
|
|
317
|
+
// (`viewer-components.md`), and this column is a child of it. The height is
|
|
318
|
+
// the whole document's, so the scrollbar is honest from the first frame
|
|
319
|
+
// rather than growing as pages arrive.
|
|
320
|
+
<div
|
|
321
|
+
ref={listRef}
|
|
322
|
+
data-slot="pdf-pages"
|
|
323
|
+
className={cn("relative w-full", className)}
|
|
324
|
+
style={{ height: virtualizer.getTotalSize() }}
|
|
325
|
+
>
|
|
326
|
+
{virtualizer.getVirtualItems().map((item) => (
|
|
327
|
+
<div
|
|
328
|
+
key={item.key}
|
|
329
|
+
data-index={item.index}
|
|
330
|
+
ref={virtualizer.measureElement}
|
|
331
|
+
className="absolute inset-x-0 top-0"
|
|
332
|
+
style={{
|
|
333
|
+
paddingBottom: PAGE_GAP,
|
|
334
|
+
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
<PdfPageView
|
|
338
|
+
handle={pdf.handle}
|
|
339
|
+
pageNumber={item.index + 1}
|
|
340
|
+
natural={naturalAt(item.index)}
|
|
341
|
+
scale={scale}
|
|
342
|
+
alt={source.alt}
|
|
343
|
+
highlights={highlights}
|
|
344
|
+
activeHighlightId={activeHighlightId}
|
|
345
|
+
marks={marks}
|
|
346
|
+
pageStart={pdf.textIndex?.spans.find((span) => span.ref === item.index + 1)?.start}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
interface PdfPageViewProps {
|
|
355
|
+
handle: PdfDocument;
|
|
356
|
+
/** 1-based, as everything on the wire is. */
|
|
357
|
+
pageNumber: number;
|
|
358
|
+
/** This page at scale 1, when the adapter measured it. Reserves the box. */
|
|
359
|
+
natural?: PageSize;
|
|
360
|
+
scale: number;
|
|
361
|
+
alt?: string;
|
|
362
|
+
highlights?: readonly ResolvedHighlight[];
|
|
363
|
+
activeHighlightId?: string | null;
|
|
364
|
+
marks: MarkRanges;
|
|
365
|
+
/** Where this page's text begins in the projection, if it was extracted. */
|
|
366
|
+
pageStart?: number;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* One page: the raster, the transparent text over it, and any citation boxes.
|
|
371
|
+
*
|
|
372
|
+
* A component per page rather than one effect over the visible range, so that
|
|
373
|
+
* mounting and unmounting IS the lifecycle — a page scrolled out of the window
|
|
374
|
+
* cancels its own render task and releases its own pdf.js page, with no
|
|
375
|
+
* bookkeeping to get wrong.
|
|
376
|
+
*/
|
|
377
|
+
function PdfPageView({
|
|
378
|
+
handle,
|
|
379
|
+
pageNumber,
|
|
380
|
+
natural,
|
|
381
|
+
scale,
|
|
382
|
+
alt,
|
|
383
|
+
highlights,
|
|
384
|
+
activeHighlightId,
|
|
385
|
+
marks,
|
|
386
|
+
pageStart,
|
|
387
|
+
}: PdfPageViewProps) {
|
|
388
|
+
const { t, formatNumber } = useLocale();
|
|
389
|
+
|
|
390
|
+
const [size, setSize] = useState<{ width: number; height: number } | undefined>(
|
|
391
|
+
natural && { width: natural.width * scale, height: natural.height * scale },
|
|
392
|
+
);
|
|
393
|
+
const [spans, setSpans] = useState<PdfTextSpan[]>([]);
|
|
394
|
+
const [items, setItems] = useState<PdfTextItem[]>([]);
|
|
395
|
+
const [failed, setFailed] = useState<string>();
|
|
396
|
+
|
|
397
|
+
const pageRef = useRef<HTMLDivElement>(null);
|
|
398
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
399
|
+
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
let cancelled = false;
|
|
402
|
+
let task: ReturnType<PdfPage["render"]> | undefined;
|
|
403
|
+
let page: PdfPage | undefined;
|
|
404
|
+
|
|
405
|
+
void (async () => {
|
|
406
|
+
try {
|
|
407
|
+
page = await handle.getPage(pageNumber);
|
|
408
|
+
if (cancelled) return;
|
|
409
|
+
|
|
410
|
+
// Rasterize at the device pixel ratio, then present at CSS size — a 1:1
|
|
411
|
+
// canvas is visibly soft on every retina display.
|
|
412
|
+
const ratio = typeof window === "undefined" ? 1 : (window.devicePixelRatio ?? 1);
|
|
413
|
+
const viewport = page.getViewport({ scale: scale * ratio });
|
|
414
|
+
const cssViewport = page.getViewport({ scale });
|
|
415
|
+
const canvas = canvasRef.current;
|
|
416
|
+
const context = canvas?.getContext("2d");
|
|
417
|
+
if (!canvas || !context) return;
|
|
418
|
+
|
|
419
|
+
canvas.width = Math.floor(viewport.width);
|
|
420
|
+
canvas.height = Math.floor(viewport.height);
|
|
421
|
+
setSize({ width: cssViewport.width, height: cssViewport.height });
|
|
422
|
+
|
|
423
|
+
task = page.render({ canvasContext: context, viewport });
|
|
424
|
+
await task.promise;
|
|
425
|
+
if (cancelled) return;
|
|
426
|
+
|
|
427
|
+
const content = await page.getTextContent();
|
|
428
|
+
if (cancelled) return;
|
|
429
|
+
setItems(content.items);
|
|
430
|
+
setSpans(toTextSpans(content.items, cssViewport.height, scale));
|
|
431
|
+
setFailed(undefined);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
// A cancelled render is the normal outcome of scrolling quickly; only a
|
|
434
|
+
// settled failure is news (loading-states.md).
|
|
435
|
+
if (cancelled) return;
|
|
436
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
437
|
+
if (/cancel/i.test(message)) return;
|
|
438
|
+
setFailed(message);
|
|
439
|
+
}
|
|
440
|
+
})();
|
|
441
|
+
|
|
442
|
+
return () => {
|
|
443
|
+
cancelled = true;
|
|
444
|
+
task?.cancel();
|
|
445
|
+
page?.cleanup();
|
|
446
|
+
};
|
|
447
|
+
}, [handle, pageNumber, scale]);
|
|
448
|
+
|
|
449
|
+
// MAP + PAINT. Turning the shell's offsets into rectangles needs this page's
|
|
450
|
+
// own items, which the effect above already fetched for the text layer.
|
|
451
|
+
const boxes = useMemo<PdfHighlightBox[]>(() => {
|
|
452
|
+
if (!size) return [];
|
|
453
|
+
// A `rect` address is already geometry — it only has to be on this page.
|
|
454
|
+
// `page` defaults to 1 so a single-page citation needs no page number.
|
|
455
|
+
const geometry = (highlights ?? []).filter(
|
|
456
|
+
(highlight) =>
|
|
457
|
+
highlight.status === "resolved" &&
|
|
458
|
+
highlight.rects !== undefined &&
|
|
459
|
+
(highlight.page ?? 1) === pageNumber,
|
|
460
|
+
);
|
|
461
|
+
return [
|
|
462
|
+
...(pageStart === undefined ? [] : rangeBoxes(spans, pageTextChunk(items), pageStart, marks)),
|
|
463
|
+
...rectBoxes(geometry, size),
|
|
464
|
+
];
|
|
465
|
+
}, [size, highlights, pageNumber, pageStart, spans, items, marks]);
|
|
466
|
+
|
|
467
|
+
// The boxes arrive a tick after the page does (the text content is fetched
|
|
468
|
+
// asynchronously), so the scroll waits for them rather than for the id.
|
|
469
|
+
useScrollActiveHighlightIntoView(pageRef, activeHighlightId, boxes);
|
|
470
|
+
|
|
471
|
+
if (failed) {
|
|
472
|
+
return (
|
|
473
|
+
<div
|
|
474
|
+
className="mx-auto flex w-fit items-center justify-center"
|
|
475
|
+
style={size ? { width: size.width, height: size.height } : undefined}
|
|
476
|
+
>
|
|
477
|
+
<StatePanel
|
|
478
|
+
kind="error"
|
|
479
|
+
title={t("viewer.error.parseFailed")}
|
|
480
|
+
description={t("viewer.pdf.pageFailed", {
|
|
481
|
+
page: formatNumber(pageNumber),
|
|
482
|
+
})}
|
|
483
|
+
/>
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<div
|
|
490
|
+
ref={pageRef}
|
|
491
|
+
data-slot="pdf-page"
|
|
492
|
+
data-page={pageNumber}
|
|
493
|
+
className="border-border relative mx-auto w-fit border shadow-sm"
|
|
494
|
+
style={size ? { width: size.width, height: size.height } : undefined}
|
|
495
|
+
>
|
|
496
|
+
<canvas
|
|
497
|
+
ref={canvasRef}
|
|
498
|
+
// The page's own description, so a screen reader gets what the document
|
|
499
|
+
// is rather than "canvas". The text layer below carries the words
|
|
500
|
+
// themselves. Every page names its own number, which is what makes a
|
|
501
|
+
// continuous column navigable by heading-less landmark browsing.
|
|
502
|
+
role="img"
|
|
503
|
+
aria-label={alt ?? t("viewer.pdf.page", { page: formatNumber(pageNumber) })}
|
|
504
|
+
className="block"
|
|
505
|
+
style={size ? { width: size.width, height: size.height } : undefined}
|
|
506
|
+
/>
|
|
507
|
+
{/* Transparent, selectable text over the raster. `select-text` and the
|
|
508
|
+
transforms come from pdf.js's own item geometry. */}
|
|
509
|
+
<div aria-hidden={spans.length === 0} className="absolute inset-0 select-text">
|
|
510
|
+
{spans.map((span, index) => (
|
|
511
|
+
<span
|
|
512
|
+
key={index}
|
|
513
|
+
className="absolute origin-top-left whitespace-pre text-transparent"
|
|
514
|
+
style={{
|
|
515
|
+
left: span.left,
|
|
516
|
+
top: span.top,
|
|
517
|
+
fontSize: span.fontSize,
|
|
518
|
+
lineHeight: 1,
|
|
519
|
+
}}
|
|
520
|
+
>
|
|
521
|
+
{span.text}
|
|
522
|
+
</span>
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
{/* Decorative: the words themselves are in the text layer above, and the
|
|
526
|
+
shell's status line is what names the citation. A plate here would
|
|
527
|
+
otherwise be read out as an unlabelled region.
|
|
528
|
+
|
|
529
|
+
Translucent rather than a solid plate, because the "ink" it sits on is
|
|
530
|
+
the raster below it — an opaque mark would delete the very sentence it
|
|
531
|
+
is pointing at. The current box is not distinguished by colour alone
|
|
532
|
+
(WCAG 1.4.1): it is also drawn twice as thick. */}
|
|
533
|
+
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
|
534
|
+
{boxes.map((box, index) => (
|
|
535
|
+
<div
|
|
536
|
+
key={index}
|
|
537
|
+
data-slot="highlight-rect"
|
|
538
|
+
// Presence form, matching every other painter's `data-active`.
|
|
539
|
+
data-active={box.active ? "" : undefined}
|
|
540
|
+
className={cn(
|
|
541
|
+
"absolute rounded-xs",
|
|
542
|
+
box.active
|
|
543
|
+
? "bg-highlight-active/30 border-highlight-active border-2"
|
|
544
|
+
: "bg-highlight/35 border-highlight border",
|
|
545
|
+
)}
|
|
546
|
+
style={{
|
|
547
|
+
left: box.left,
|
|
548
|
+
top: box.top,
|
|
549
|
+
width: box.width,
|
|
550
|
+
height: box.height,
|
|
551
|
+
}}
|
|
552
|
+
/>
|
|
553
|
+
))}
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* pdf.js text geometry → CSS boxes.
|
|
561
|
+
*
|
|
562
|
+
* Item transforms are `[a, b, c, d, e, f]` in PDF user space, whose origin is
|
|
563
|
+
* the BOTTOM-left of the page; CSS measures from the top. `d` carries the glyph
|
|
564
|
+
* height, which is the font size once scaled.
|
|
565
|
+
*
|
|
566
|
+
* Whitespace-only items are dropped — they would be invisible clutter in the
|
|
567
|
+
* text layer — so each span carries the index of the item it came from. That is
|
|
568
|
+
* what lets a highlight box find its geometry after the filtering, without
|
|
569
|
+
* either side counting positions.
|
|
570
|
+
*/
|
|
571
|
+
export function toTextSpans(
|
|
572
|
+
items: { str: string; transform: number[]; width?: number }[],
|
|
573
|
+
pageHeight: number,
|
|
574
|
+
scale: number,
|
|
575
|
+
): PdfTextSpan[] {
|
|
576
|
+
const spans: PdfTextSpan[] = [];
|
|
577
|
+
items.forEach((item, index) => {
|
|
578
|
+
if (!item.str.trim()) return;
|
|
579
|
+
const [, , , d = 0, e = 0, f = 0] = item.transform;
|
|
580
|
+
const fontSize = Math.abs(d) * scale;
|
|
581
|
+
spans.push({
|
|
582
|
+
text: item.str,
|
|
583
|
+
left: e * scale,
|
|
584
|
+
top: pageHeight - f * scale - fontSize,
|
|
585
|
+
width: (item.width ?? 0) * scale,
|
|
586
|
+
fontSize,
|
|
587
|
+
item: index,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
return spans;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const adapterModule: AdapterModule = {
|
|
594
|
+
manifest: pdfManifest,
|
|
595
|
+
create: () => new PdfAdapter(),
|
|
596
|
+
Renderer: PdfRenderer,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
export default adapterModule;
|