svelte-pdf-view 0.1.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.
@@ -0,0 +1,281 @@
1
+ /* Copyright 2024 Mozilla Foundation
2
+ *
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ */
15
+ import { setLayerDimensions } from 'pdfjs-dist';
16
+ export const RenderingStates = {
17
+ INITIAL: 0,
18
+ RUNNING: 1,
19
+ PAUSED: 2,
20
+ FINISHED: 3
21
+ };
22
+ export class PDFPageView {
23
+ id;
24
+ eventBus;
25
+ container;
26
+ viewport;
27
+ pdfPage = null;
28
+ scale;
29
+ rotation;
30
+ pdfPageRotate = 0;
31
+ div;
32
+ canvas = null;
33
+ canvasWrapper = null;
34
+ textLayerDiv = null;
35
+ loadingDiv = null;
36
+ renderingState = RenderingStates.INITIAL;
37
+ renderTask = null;
38
+ // Text layer instance for updates
39
+ textLayer = null;
40
+ textLayerRendered = false;
41
+ // Text layer data for search
42
+ textDivs = [];
43
+ textContentItemsStr = [];
44
+ constructor(options) {
45
+ this.id = options.id;
46
+ this.container = options.container;
47
+ this.eventBus = options.eventBus;
48
+ this.scale = options.scale ?? 1.0;
49
+ this.rotation = options.rotation ?? 0;
50
+ this.viewport = options.defaultViewport;
51
+ // Create page container
52
+ this.div = document.createElement('div');
53
+ this.div.className = 'page';
54
+ this.div.setAttribute('data-page-number', String(this.id));
55
+ this.div.setAttribute('role', 'region');
56
+ this.setDimensions();
57
+ this.container.appendChild(this.div);
58
+ // Add loading indicator
59
+ this.loadingDiv = document.createElement('div');
60
+ this.loadingDiv.className = 'loadingIcon';
61
+ this.loadingDiv.textContent = 'Loading...';
62
+ this.div.appendChild(this.loadingDiv);
63
+ }
64
+ setDimensions() {
65
+ const { width, height } = this.viewport;
66
+ this.div.style.width = `${Math.floor(width)}px`;
67
+ this.div.style.height = `${Math.floor(height)}px`;
68
+ // Set CSS variables for text layer scaling
69
+ // viewport.scale already includes our scale factor
70
+ this.div.style.setProperty('--scale-factor', String(this.viewport.scale));
71
+ // Set rotation attribute for text layer
72
+ const totalRotation = (this.rotation + this.pdfPageRotate) % 360;
73
+ this.div.setAttribute('data-main-rotation', String(totalRotation));
74
+ // Update text layer dimensions if it exists
75
+ // mustFlip=false because the text layer uses raw page coordinates
76
+ // and rotation is handled via CSS transforms
77
+ if (this.textLayerDiv) {
78
+ setLayerDimensions(this.textLayerDiv, this.viewport, /* mustFlip */ false);
79
+ }
80
+ }
81
+ setPdfPage(pdfPage) {
82
+ this.pdfPage = pdfPage;
83
+ this.pdfPageRotate = pdfPage.rotate;
84
+ this.updateViewport();
85
+ }
86
+ updateViewport() {
87
+ if (!this.pdfPage)
88
+ return;
89
+ const totalRotation = (this.rotation + this.pdfPageRotate) % 360;
90
+ this.viewport = this.pdfPage.getViewport({
91
+ scale: this.scale,
92
+ rotation: totalRotation
93
+ });
94
+ this.setDimensions();
95
+ }
96
+ update({ scale, rotation }) {
97
+ if (scale !== undefined) {
98
+ this.scale = scale;
99
+ }
100
+ if (rotation !== undefined) {
101
+ this.rotation = rotation;
102
+ }
103
+ this.updateViewport();
104
+ // Re-render if already rendered
105
+ if (this.renderingState === RenderingStates.FINISHED) {
106
+ // Update text layer - TextLayer.update() handles both scale and rotation
107
+ // Rotation is applied via CSS transforms based on data-main-rotation attribute
108
+ if (this.textLayer && this.textLayerRendered) {
109
+ this.textLayerDiv.hidden = true;
110
+ this.textLayer.update({
111
+ viewport: this.viewport
112
+ });
113
+ this.textLayerDiv.hidden = false;
114
+ }
115
+ // Re-render canvas
116
+ this.resetCanvas();
117
+ this.draw();
118
+ }
119
+ }
120
+ resetCanvas() {
121
+ this.cancelRendering();
122
+ this.renderingState = RenderingStates.INITIAL;
123
+ // Clear canvas only
124
+ if (this.canvas) {
125
+ this.canvas.width = 0;
126
+ this.canvas.height = 0;
127
+ this.canvas.remove();
128
+ this.canvas = null;
129
+ }
130
+ if (this.canvasWrapper) {
131
+ this.canvasWrapper.remove();
132
+ this.canvasWrapper = null;
133
+ }
134
+ }
135
+ reset() {
136
+ this.cancelRendering();
137
+ this.renderingState = RenderingStates.INITIAL;
138
+ // Clear canvas
139
+ if (this.canvas) {
140
+ this.canvas.width = 0;
141
+ this.canvas.height = 0;
142
+ this.canvas.remove();
143
+ this.canvas = null;
144
+ }
145
+ if (this.canvasWrapper) {
146
+ this.canvasWrapper.remove();
147
+ this.canvasWrapper = null;
148
+ }
149
+ // Clear text layer
150
+ if (this.textLayerDiv) {
151
+ this.textLayerDiv.remove();
152
+ this.textLayerDiv = null;
153
+ }
154
+ this.textLayer = null;
155
+ this.textLayerRendered = false;
156
+ this.textDivs = [];
157
+ this.textContentItemsStr = [];
158
+ // Show loading
159
+ if (this.loadingDiv) {
160
+ this.loadingDiv.style.display = '';
161
+ }
162
+ }
163
+ async draw() {
164
+ if (!this.pdfPage || this.renderingState !== RenderingStates.INITIAL) {
165
+ return;
166
+ }
167
+ this.renderingState = RenderingStates.RUNNING;
168
+ try {
169
+ // Hide loading indicator
170
+ if (this.loadingDiv) {
171
+ this.loadingDiv.style.display = 'none';
172
+ }
173
+ // Create canvas wrapper
174
+ this.canvasWrapper = document.createElement('div');
175
+ this.canvasWrapper.className = 'canvasWrapper';
176
+ this.div.appendChild(this.canvasWrapper);
177
+ // Create and render canvas
178
+ this.canvas = document.createElement('canvas');
179
+ this.canvas.className = 'pdf-canvas';
180
+ this.canvasWrapper.appendChild(this.canvas);
181
+ const outputScale = window.devicePixelRatio || 1;
182
+ const { width, height } = this.viewport;
183
+ this.canvas.width = Math.floor(width * outputScale);
184
+ this.canvas.height = Math.floor(height * outputScale);
185
+ this.canvas.style.width = `${Math.floor(width)}px`;
186
+ this.canvas.style.height = `${Math.floor(height)}px`;
187
+ const ctx = this.canvas.getContext('2d');
188
+ ctx.scale(outputScale, outputScale);
189
+ this.renderTask = this.pdfPage.render({
190
+ canvasContext: ctx,
191
+ viewport: this.viewport,
192
+ canvas: this.canvas
193
+ });
194
+ await this.renderTask.promise;
195
+ this.renderTask = null;
196
+ // Render text layer for search (only if not already rendered)
197
+ if (!this.textLayerRendered) {
198
+ await this.renderTextLayer();
199
+ }
200
+ this.renderingState = RenderingStates.FINISHED;
201
+ this.eventBus.dispatch('pagerendered', {
202
+ pageNumber: this.id,
203
+ source: this
204
+ });
205
+ }
206
+ catch (error) {
207
+ if (error.name === 'RenderingCancelledException') {
208
+ return;
209
+ }
210
+ this.renderingState = RenderingStates.INITIAL;
211
+ console.error('Error rendering page:', error);
212
+ }
213
+ }
214
+ async renderTextLayer() {
215
+ if (!this.pdfPage)
216
+ return;
217
+ // If text layer already rendered, just update it
218
+ if (this.textLayerRendered && this.textLayer) {
219
+ this.textLayerDiv.hidden = true;
220
+ this.textLayer.update({ viewport: this.viewport });
221
+ this.textLayerDiv.hidden = false;
222
+ return;
223
+ }
224
+ // Create text layer div
225
+ this.textLayerDiv = document.createElement('div');
226
+ this.textLayerDiv.className = 'textLayer';
227
+ this.div.appendChild(this.textLayerDiv);
228
+ try {
229
+ // Import TextLayer from pdfjs-dist
230
+ const { TextLayer } = await import('pdfjs-dist');
231
+ const textContent = await this.pdfPage.getTextContent();
232
+ this.textDivs = [];
233
+ this.textContentItemsStr = [];
234
+ // Set text layer dimensions using PDF.js utility
235
+ // mustFlip=false because text layer uses raw page coordinates
236
+ // and rotation is handled via CSS transforms
237
+ setLayerDimensions(this.textLayerDiv, this.viewport, /* mustFlip */ false);
238
+ // Use PDF.js TextLayer for proper positioning
239
+ this.textLayer = new TextLayer({
240
+ textContentSource: textContent,
241
+ container: this.textLayerDiv,
242
+ viewport: this.viewport
243
+ });
244
+ await this.textLayer.render();
245
+ this.textLayerRendered = true;
246
+ // Collect text divs and their content for search highlighting
247
+ // We extract text from the rendered spans to ensure 1:1 correspondence
248
+ // between textDivs and textContentItemsStr
249
+ const spans = this.textLayerDiv.querySelectorAll('span:not(.markedContent)');
250
+ spans.forEach((span) => {
251
+ this.textDivs.push(span);
252
+ this.textContentItemsStr.push(span.textContent || '');
253
+ });
254
+ this.eventBus.dispatch('textlayerrendered', {
255
+ pageNumber: this.id,
256
+ source: this
257
+ });
258
+ }
259
+ catch (error) {
260
+ console.error('Error rendering text layer:', error);
261
+ }
262
+ }
263
+ cancelRendering() {
264
+ if (this.renderTask) {
265
+ this.renderTask.cancel();
266
+ this.renderTask = null;
267
+ }
268
+ }
269
+ destroy() {
270
+ this.cancelRendering();
271
+ this.reset();
272
+ this.pdfPage?.cleanup();
273
+ this.div.remove();
274
+ }
275
+ get width() {
276
+ return this.viewport.width;
277
+ }
278
+ get height() {
279
+ return this.viewport.height;
280
+ }
281
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * PDFViewerCore - Main viewer that manages all pages in a scroll container.
3
+ * This is a derivative work based on PDF.js pdf_viewer.js
4
+ */
5
+ import type { PDFDocumentProxy } from 'pdfjs-dist';
6
+ import { EventBus } from './EventBus.js';
7
+ import { PDFPageView } from './PDFPageView.js';
8
+ export interface PDFViewerOptions {
9
+ container: HTMLElement;
10
+ eventBus?: EventBus;
11
+ initialScale?: number;
12
+ initialRotation?: number;
13
+ }
14
+ export declare class PDFViewerCore {
15
+ readonly container: HTMLElement;
16
+ readonly viewer: HTMLDivElement;
17
+ readonly eventBus: EventBus;
18
+ private pdfDocument;
19
+ private pages;
20
+ private currentScale;
21
+ private currentRotation;
22
+ private scrollAbortController;
23
+ private renderingQueue;
24
+ private isRendering;
25
+ constructor(options: PDFViewerOptions);
26
+ private setupScrollListener;
27
+ setDocument(pdfDocument: PDFDocumentProxy): Promise<void>;
28
+ private getVisiblePages;
29
+ private updateVisiblePages;
30
+ private processRenderingQueue;
31
+ get scale(): number;
32
+ set scale(value: number);
33
+ get rotation(): number;
34
+ set rotation(value: number);
35
+ zoomIn(): void;
36
+ zoomOut(): void;
37
+ rotateClockwise(): void;
38
+ rotateCounterClockwise(): void;
39
+ scrollToPage(pageNumber: number): void;
40
+ get pagesCount(): number;
41
+ get currentPageNumber(): number;
42
+ getPageView(pageIndex: number): PDFPageView | undefined;
43
+ cleanup(): void;
44
+ destroy(): void;
45
+ }
@@ -0,0 +1,225 @@
1
+ /* Copyright 2024 Mozilla Foundation
2
+ *
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ */
15
+ import { EventBus } from './EventBus.js';
16
+ import { PDFPageView, RenderingStates } from './PDFPageView.js';
17
+ const DEFAULT_SCALE = 1.0;
18
+ const MIN_SCALE = 0.1;
19
+ const MAX_SCALE = 10.0;
20
+ const DEFAULT_SCALE_DELTA = 0.1;
21
+ // Number of pages to render around the visible ones
22
+ const PAGES_TO_PRERENDER = 2;
23
+ export class PDFViewerCore {
24
+ container;
25
+ viewer;
26
+ eventBus;
27
+ pdfDocument = null;
28
+ pages = [];
29
+ currentScale;
30
+ currentRotation;
31
+ scrollAbortController = null;
32
+ renderingQueue = new Set();
33
+ isRendering = false;
34
+ constructor(options) {
35
+ this.container = options.container;
36
+ this.eventBus = options.eventBus ?? new EventBus();
37
+ this.currentScale = options.initialScale ?? DEFAULT_SCALE;
38
+ this.currentRotation = options.initialRotation ?? 0;
39
+ // Create viewer div inside container
40
+ this.viewer = document.createElement('div');
41
+ this.viewer.className = 'pdfViewer';
42
+ this.container.appendChild(this.viewer);
43
+ // Setup scroll listener for lazy rendering
44
+ this.setupScrollListener();
45
+ }
46
+ setupScrollListener() {
47
+ this.scrollAbortController?.abort();
48
+ this.scrollAbortController = new AbortController();
49
+ let scrollTimeout = null;
50
+ this.container.addEventListener('scroll', () => {
51
+ if (scrollTimeout) {
52
+ clearTimeout(scrollTimeout);
53
+ }
54
+ scrollTimeout = setTimeout(() => {
55
+ this.updateVisiblePages();
56
+ }, 100);
57
+ }, { signal: this.scrollAbortController.signal });
58
+ }
59
+ async setDocument(pdfDocument) {
60
+ // Cleanup previous document
61
+ this.cleanup();
62
+ this.pdfDocument = pdfDocument;
63
+ const numPages = pdfDocument.numPages;
64
+ // Create page views
65
+ for (let i = 1; i <= numPages; i++) {
66
+ const page = await pdfDocument.getPage(i);
67
+ const viewport = page.getViewport({
68
+ scale: this.currentScale,
69
+ rotation: this.currentRotation
70
+ });
71
+ const pageView = new PDFPageView({
72
+ container: this.viewer,
73
+ id: i,
74
+ defaultViewport: viewport,
75
+ eventBus: this.eventBus,
76
+ scale: this.currentScale,
77
+ rotation: this.currentRotation
78
+ });
79
+ pageView.setPdfPage(page);
80
+ this.pages.push(pageView);
81
+ }
82
+ this.eventBus.dispatch('pagesloaded', { pagesCount: numPages });
83
+ // Initial render of visible pages
84
+ this.updateVisiblePages();
85
+ }
86
+ getVisiblePages() {
87
+ const containerRect = this.container.getBoundingClientRect();
88
+ const containerTop = this.container.scrollTop;
89
+ const containerBottom = containerTop + containerRect.height;
90
+ let firstVisible = -1;
91
+ let lastVisible = -1;
92
+ const visibleIds = new Set();
93
+ let currentTop = 0;
94
+ for (let i = 0; i < this.pages.length; i++) {
95
+ const page = this.pages[i];
96
+ const pageBottom = currentTop + page.height + 10; // 10px margin
97
+ if (pageBottom >= containerTop && currentTop <= containerBottom) {
98
+ if (firstVisible === -1) {
99
+ firstVisible = i;
100
+ }
101
+ lastVisible = i;
102
+ visibleIds.add(i + 1); // Page numbers are 1-indexed
103
+ }
104
+ currentTop = pageBottom;
105
+ }
106
+ return {
107
+ first: firstVisible === -1 ? 0 : firstVisible,
108
+ last: lastVisible === -1 ? 0 : lastVisible,
109
+ ids: visibleIds
110
+ };
111
+ }
112
+ updateVisiblePages() {
113
+ if (!this.pdfDocument || this.pages.length === 0)
114
+ return;
115
+ const visible = this.getVisiblePages();
116
+ // Calculate which pages to render (visible + buffer)
117
+ const startPage = Math.max(0, visible.first - PAGES_TO_PRERENDER);
118
+ const endPage = Math.min(this.pages.length - 1, visible.last + PAGES_TO_PRERENDER);
119
+ // Queue pages for rendering
120
+ for (let i = startPage; i <= endPage; i++) {
121
+ const page = this.pages[i];
122
+ if (page.renderingState === RenderingStates.INITIAL) {
123
+ this.renderingQueue.add(i);
124
+ }
125
+ }
126
+ this.processRenderingQueue();
127
+ this.eventBus.dispatch('updateviewarea', {
128
+ location: {
129
+ pageNumber: visible.first + 1,
130
+ scale: this.currentScale,
131
+ rotation: this.currentRotation
132
+ }
133
+ });
134
+ }
135
+ async processRenderingQueue() {
136
+ if (this.isRendering || this.renderingQueue.size === 0)
137
+ return;
138
+ this.isRendering = true;
139
+ while (this.renderingQueue.size > 0) {
140
+ const pageIndex = this.renderingQueue.values().next().value;
141
+ this.renderingQueue.delete(pageIndex);
142
+ const page = this.pages[pageIndex];
143
+ if (page && page.renderingState === RenderingStates.INITIAL) {
144
+ await page.draw();
145
+ }
146
+ }
147
+ this.isRendering = false;
148
+ }
149
+ get scale() {
150
+ return this.currentScale;
151
+ }
152
+ set scale(value) {
153
+ const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, value));
154
+ if (newScale === this.currentScale)
155
+ return;
156
+ this.currentScale = newScale;
157
+ // Update all pages
158
+ for (const page of this.pages) {
159
+ page.update({ scale: newScale });
160
+ }
161
+ this.eventBus.dispatch('scalechanged', { scale: newScale });
162
+ this.updateVisiblePages();
163
+ }
164
+ get rotation() {
165
+ return this.currentRotation;
166
+ }
167
+ set rotation(value) {
168
+ const newRotation = ((value % 360) + 360) % 360;
169
+ if (newRotation === this.currentRotation)
170
+ return;
171
+ this.currentRotation = newRotation;
172
+ // Update all pages
173
+ for (const page of this.pages) {
174
+ page.update({ rotation: newRotation });
175
+ }
176
+ this.eventBus.dispatch('rotationchanged', { rotation: newRotation });
177
+ this.updateVisiblePages();
178
+ }
179
+ zoomIn() {
180
+ this.scale = this.currentScale + DEFAULT_SCALE_DELTA;
181
+ }
182
+ zoomOut() {
183
+ this.scale = this.currentScale - DEFAULT_SCALE_DELTA;
184
+ }
185
+ rotateClockwise() {
186
+ this.rotation = this.currentRotation + 90;
187
+ }
188
+ rotateCounterClockwise() {
189
+ this.rotation = this.currentRotation - 90;
190
+ }
191
+ scrollToPage(pageNumber) {
192
+ if (pageNumber < 1 || pageNumber > this.pages.length)
193
+ return;
194
+ const pageView = this.pages[pageNumber - 1];
195
+ pageView.div.scrollIntoView({ behavior: 'smooth', block: 'start' });
196
+ this.eventBus.dispatch('pagechanged', { pageNumber });
197
+ }
198
+ get pagesCount() {
199
+ return this.pages.length;
200
+ }
201
+ get currentPageNumber() {
202
+ const visible = this.getVisiblePages();
203
+ return visible.first + 1;
204
+ }
205
+ getPageView(pageIndex) {
206
+ return this.pages[pageIndex];
207
+ }
208
+ cleanup() {
209
+ // Cancel all rendering
210
+ for (const page of this.pages) {
211
+ page.destroy();
212
+ }
213
+ this.pages = [];
214
+ this.renderingQueue.clear();
215
+ // Clear viewer
216
+ this.viewer.innerHTML = '';
217
+ this.pdfDocument = null;
218
+ }
219
+ destroy() {
220
+ this.scrollAbortController?.abort();
221
+ this.cleanup();
222
+ this.eventBus.destroy();
223
+ this.viewer.remove();
224
+ }
225
+ }
@@ -0,0 +1,31 @@
1
+ export interface PdfViewerState {
2
+ loading: boolean;
3
+ error: string | null;
4
+ totalPages: number;
5
+ currentPage: number;
6
+ scale: number;
7
+ rotation: number;
8
+ searchQuery: string;
9
+ searchCurrent: number;
10
+ searchTotal: number;
11
+ isSearching: boolean;
12
+ }
13
+ export interface PdfViewerActions {
14
+ zoomIn: () => void;
15
+ zoomOut: () => void;
16
+ setScale: (scale: number) => void;
17
+ rotateClockwise: () => void;
18
+ rotateCounterClockwise: () => void;
19
+ goToPage: (page: number) => void;
20
+ search: (query: string) => Promise<void>;
21
+ searchNext: () => void;
22
+ searchPrevious: () => void;
23
+ clearSearch: () => void;
24
+ }
25
+ export interface PdfViewerContext {
26
+ state: PdfViewerState;
27
+ actions: PdfViewerActions;
28
+ _registerRenderer: (renderer: PdfViewerActions) => void;
29
+ }
30
+ export declare function setPdfViewerContext(ctx: PdfViewerContext): void;
31
+ export declare function getPdfViewerContext(): PdfViewerContext;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * PDF Viewer Context - Shared state between toolbar and renderer
3
+ */
4
+ import { getContext, setContext } from 'svelte';
5
+ const PDF_VIEWER_CONTEXT_KEY = Symbol('pdf-viewer');
6
+ export function setPdfViewerContext(ctx) {
7
+ setContext(PDF_VIEWER_CONTEXT_KEY, ctx);
8
+ }
9
+ export function getPdfViewerContext() {
10
+ const ctx = getContext(PDF_VIEWER_CONTEXT_KEY);
11
+ if (!ctx) {
12
+ throw new Error('PdfToolbar must be used inside a PdfViewer component');
13
+ }
14
+ return ctx;
15
+ }