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,42 @@
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
+ /**
16
+ * Simple event bus for PDF viewer component communication.
17
+ * This is a derivative work based on PDF.js event_utils.js
18
+ */
19
+ export class EventBus {
20
+ listeners = new Map();
21
+ on(eventName, listener) {
22
+ if (!this.listeners.has(eventName)) {
23
+ this.listeners.set(eventName, new Set());
24
+ }
25
+ this.listeners.get(eventName).add(listener);
26
+ }
27
+ off(eventName, listener) {
28
+ this.listeners.get(eventName)?.delete(listener);
29
+ }
30
+ dispatch(eventName, data) {
31
+ const eventListeners = this.listeners.get(eventName);
32
+ if (!eventListeners || eventListeners.size === 0) {
33
+ return;
34
+ }
35
+ for (const listener of eventListeners) {
36
+ listener({ source: this, ...data });
37
+ }
38
+ }
39
+ destroy() {
40
+ this.listeners.clear();
41
+ }
42
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * FindController - Text search functionality for PDF viewer.
3
+ * This is a derivative work based on PDF.js pdf_find_controller.js and text_highlighter.js
4
+ */
5
+ import type { EventBus } from './EventBus.js';
6
+ import type { PDFViewerCore } from './PDFViewerCore.js';
7
+ import type { PDFDocumentProxy } from 'pdfjs-dist';
8
+ export declare const FindState: {
9
+ readonly FOUND: 0;
10
+ readonly NOT_FOUND: 1;
11
+ readonly WRAPPED: 2;
12
+ readonly PENDING: 3;
13
+ };
14
+ export interface FindOptions {
15
+ query: string;
16
+ highlightAll?: boolean;
17
+ caseSensitive?: boolean;
18
+ entireWord?: boolean;
19
+ findPrevious?: boolean;
20
+ }
21
+ export declare class FindController {
22
+ private viewer;
23
+ private eventBus;
24
+ private pdfDocument;
25
+ private query;
26
+ private caseSensitive;
27
+ private entireWord;
28
+ private highlightAll;
29
+ private pageContents;
30
+ private extractTextPromises;
31
+ private pageMatches;
32
+ private pageMatchesLength;
33
+ private allMatches;
34
+ private selectedMatchIndex;
35
+ private matchesCountTotal;
36
+ constructor(viewer: PDFViewerCore, eventBus: EventBus);
37
+ setDocument(pdfDocument: PDFDocumentProxy): void;
38
+ private extractText;
39
+ find(options: FindOptions): Promise<void>;
40
+ findNext(): void;
41
+ findPrevious(): void;
42
+ private highlightAllPages;
43
+ private highlightPage;
44
+ private convertMatches;
45
+ private renderMatches;
46
+ private scrollToSelectedMatch;
47
+ private clearHighlights;
48
+ get matchesCount(): {
49
+ current: number;
50
+ total: number;
51
+ };
52
+ reset(): void;
53
+ }
@@ -0,0 +1,423 @@
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
+ export const FindState = {
16
+ FOUND: 0,
17
+ NOT_FOUND: 1,
18
+ WRAPPED: 2,
19
+ PENDING: 3
20
+ };
21
+ export class FindController {
22
+ viewer;
23
+ eventBus;
24
+ pdfDocument = null;
25
+ query = '';
26
+ caseSensitive = false;
27
+ entireWord = false;
28
+ highlightAll = true;
29
+ // Page text content extracted directly from PDF
30
+ pageContents = [];
31
+ extractTextPromises = [];
32
+ // Matches per page
33
+ pageMatches = [];
34
+ pageMatchesLength = [];
35
+ // Flattened matches for navigation
36
+ allMatches = [];
37
+ selectedMatchIndex = -1;
38
+ matchesCountTotal = 0;
39
+ constructor(viewer, eventBus) {
40
+ this.viewer = viewer;
41
+ this.eventBus = eventBus;
42
+ }
43
+ setDocument(pdfDocument) {
44
+ this.pdfDocument = pdfDocument;
45
+ this.pageContents = [];
46
+ this.extractTextPromises = [];
47
+ this.reset();
48
+ }
49
+ async extractText() {
50
+ if (!this.pdfDocument || this.extractTextPromises.length > 0) {
51
+ return;
52
+ }
53
+ const numPages = this.pdfDocument.numPages;
54
+ for (let i = 0; i < numPages; i++) {
55
+ const promise = (async () => {
56
+ try {
57
+ const page = await this.pdfDocument.getPage(i + 1);
58
+ // Don't use disableNormalization - let PDF.js normalize the text
59
+ // This matches how TextLayer will render it
60
+ const textContent = await page.getTextContent();
61
+ const strBuf = [];
62
+ for (const item of textContent.items) {
63
+ if ('str' in item) {
64
+ strBuf.push(item.str);
65
+ }
66
+ }
67
+ // Join all text items directly (no separator)
68
+ // This creates a searchable string that matches the concatenation
69
+ // of all text spans in the rendered page
70
+ this.pageContents[i] = strBuf.join('');
71
+ }
72
+ catch (e) {
73
+ console.error(`Unable to get text content for page ${i + 1}`, e);
74
+ this.pageContents[i] = '';
75
+ }
76
+ })();
77
+ this.extractTextPromises[i] = promise;
78
+ }
79
+ await Promise.all(this.extractTextPromises);
80
+ }
81
+ async find(options) {
82
+ const { query, highlightAll = true, caseSensitive = false, entireWord = false, findPrevious = false } = options;
83
+ // Clear previous state
84
+ this.clearHighlights();
85
+ if (!query || query.trim() === '') {
86
+ this.query = '';
87
+ this.allMatches = [];
88
+ this.selectedMatchIndex = -1;
89
+ this.matchesCountTotal = 0;
90
+ this.eventBus.dispatch('updatefindmatchescount', {
91
+ matchesCount: { current: 0, total: 0 }
92
+ });
93
+ return;
94
+ }
95
+ this.query = query;
96
+ this.highlightAll = highlightAll;
97
+ this.caseSensitive = caseSensitive;
98
+ this.entireWord = entireWord;
99
+ this.eventBus.dispatch('updatefindcontrolstate', {
100
+ state: FindState.PENDING
101
+ });
102
+ // Extract text from PDF if not already done
103
+ await this.extractText();
104
+ // Search all pages
105
+ this.pageMatches = [];
106
+ this.pageMatchesLength = [];
107
+ this.allMatches = [];
108
+ this.matchesCountTotal = 0;
109
+ const searchQuery = caseSensitive ? query : query.toLowerCase();
110
+ for (let pageIndex = 0; pageIndex < this.pageContents.length; pageIndex++) {
111
+ const pageContent = this.pageContents[pageIndex];
112
+ const searchContent = caseSensitive ? pageContent : pageContent.toLowerCase();
113
+ const matches = [];
114
+ const matchesLength = [];
115
+ let pos = 0;
116
+ while ((pos = searchContent.indexOf(searchQuery, pos)) !== -1) {
117
+ // Check entire word if required
118
+ if (entireWord) {
119
+ const before = pos > 0 ? searchContent[pos - 1] : ' ';
120
+ const after = pos + searchQuery.length < searchContent.length
121
+ ? searchContent[pos + searchQuery.length]
122
+ : ' ';
123
+ if (/\w/.test(before) || /\w/.test(after)) {
124
+ pos++;
125
+ continue;
126
+ }
127
+ }
128
+ matches.push(pos);
129
+ matchesLength.push(searchQuery.length);
130
+ this.allMatches.push({
131
+ pageIndex,
132
+ matchIndex: matches.length - 1
133
+ });
134
+ pos++;
135
+ }
136
+ this.pageMatches[pageIndex] = matches;
137
+ this.pageMatchesLength[pageIndex] = matchesLength;
138
+ this.matchesCountTotal += matches.length;
139
+ }
140
+ // Select first or last match based on direction
141
+ if (this.allMatches.length > 0) {
142
+ this.selectedMatchIndex = findPrevious ? this.allMatches.length - 1 : 0;
143
+ this.highlightAllPages();
144
+ this.scrollToSelectedMatch();
145
+ this.eventBus.dispatch('updatefindcontrolstate', {
146
+ state: FindState.FOUND
147
+ });
148
+ }
149
+ else {
150
+ this.selectedMatchIndex = -1;
151
+ this.eventBus.dispatch('updatefindcontrolstate', {
152
+ state: FindState.NOT_FOUND
153
+ });
154
+ }
155
+ this.eventBus.dispatch('updatefindmatchescount', {
156
+ matchesCount: {
157
+ current: this.selectedMatchIndex + 1,
158
+ total: this.matchesCountTotal
159
+ }
160
+ });
161
+ }
162
+ findNext() {
163
+ if (this.allMatches.length === 0)
164
+ return;
165
+ this.selectedMatchIndex = (this.selectedMatchIndex + 1) % this.allMatches.length;
166
+ this.highlightAllPages();
167
+ this.scrollToSelectedMatch();
168
+ const wrapped = this.selectedMatchIndex === 0;
169
+ this.eventBus.dispatch('updatefindcontrolstate', {
170
+ state: wrapped ? FindState.WRAPPED : FindState.FOUND
171
+ });
172
+ this.eventBus.dispatch('updatefindmatchescount', {
173
+ matchesCount: {
174
+ current: this.selectedMatchIndex + 1,
175
+ total: this.matchesCountTotal
176
+ }
177
+ });
178
+ }
179
+ findPrevious() {
180
+ if (this.allMatches.length === 0)
181
+ return;
182
+ this.selectedMatchIndex =
183
+ (this.selectedMatchIndex - 1 + this.allMatches.length) % this.allMatches.length;
184
+ this.highlightAllPages();
185
+ this.scrollToSelectedMatch();
186
+ const wrapped = this.selectedMatchIndex === this.allMatches.length - 1;
187
+ this.eventBus.dispatch('updatefindcontrolstate', {
188
+ state: wrapped ? FindState.WRAPPED : FindState.FOUND
189
+ });
190
+ this.eventBus.dispatch('updatefindmatchescount', {
191
+ matchesCount: {
192
+ current: this.selectedMatchIndex + 1,
193
+ total: this.matchesCountTotal
194
+ }
195
+ });
196
+ }
197
+ highlightAllPages() {
198
+ this.clearHighlights();
199
+ for (let pageIndex = 0; pageIndex < this.viewer.pagesCount; pageIndex++) {
200
+ this.highlightPage(pageIndex);
201
+ }
202
+ }
203
+ highlightPage(pageIndex) {
204
+ const pageView = this.viewer.getPageView(pageIndex);
205
+ if (!pageView)
206
+ return;
207
+ const matches = this.pageMatches[pageIndex];
208
+ const matchesLength = this.pageMatchesLength[pageIndex];
209
+ if (!matches || matches.length === 0)
210
+ return;
211
+ const textDivs = pageView.textDivs;
212
+ const textContentItemsStr = pageView.textContentItemsStr;
213
+ if (!textDivs || !textContentItemsStr || textContentItemsStr.length === 0)
214
+ return;
215
+ // Convert match positions to div positions
216
+ const convertedMatches = this.convertMatches(matches, matchesLength, textContentItemsStr);
217
+ // Find which match in allMatches corresponds to this page's selected match
218
+ let selectedMatchOnPage = -1;
219
+ if (this.selectedMatchIndex >= 0) {
220
+ const selectedGlobal = this.allMatches[this.selectedMatchIndex];
221
+ if (selectedGlobal.pageIndex === pageIndex) {
222
+ selectedMatchOnPage = selectedGlobal.matchIndex;
223
+ }
224
+ }
225
+ // Render the matches
226
+ this.renderMatches(convertedMatches, textDivs, textContentItemsStr, selectedMatchOnPage, this.highlightAll);
227
+ }
228
+ convertMatches(matches, matchesLength, textContentItemsStr) {
229
+ if (!matches || matches.length === 0) {
230
+ return [];
231
+ }
232
+ const result = [];
233
+ // Build cumulative text length array
234
+ const textLengths = [];
235
+ let totalLen = 0;
236
+ for (const str of textContentItemsStr) {
237
+ textLengths.push(totalLen);
238
+ totalLen += str.length;
239
+ }
240
+ for (let m = 0; m < matches.length; m++) {
241
+ const matchStart = matches[m];
242
+ const matchEnd = matchStart + matchesLength[m];
243
+ // Find the div containing the start of the match
244
+ let beginDivIdx = 0;
245
+ for (let i = 0; i < textLengths.length; i++) {
246
+ if (i === textLengths.length - 1 ||
247
+ (matchStart >= textLengths[i] &&
248
+ matchStart < textLengths[i] + textContentItemsStr[i].length)) {
249
+ beginDivIdx = i;
250
+ break;
251
+ }
252
+ }
253
+ // Find the div containing the end of the match
254
+ let endDivIdx = beginDivIdx;
255
+ for (let i = beginDivIdx; i < textLengths.length; i++) {
256
+ if (i === textLengths.length - 1 ||
257
+ matchEnd <= textLengths[i] + textContentItemsStr[i].length) {
258
+ endDivIdx = i;
259
+ break;
260
+ }
261
+ }
262
+ result.push({
263
+ begin: {
264
+ divIdx: beginDivIdx,
265
+ offset: matchStart - textLengths[beginDivIdx]
266
+ },
267
+ end: {
268
+ divIdx: endDivIdx,
269
+ offset: matchEnd - textLengths[endDivIdx]
270
+ }
271
+ });
272
+ }
273
+ return result;
274
+ }
275
+ renderMatches(matches, textDivs, textContentItemsStr, selectedMatchIdx, highlightAll) {
276
+ if (matches.length === 0)
277
+ return;
278
+ let prevEnd = null;
279
+ const infinity = { divIdx: -1, offset: undefined };
280
+ const beginText = (begin, className) => {
281
+ const divIdx = begin.divIdx;
282
+ textDivs[divIdx].textContent = '';
283
+ appendTextToDiv(divIdx, 0, begin.offset, className);
284
+ };
285
+ const appendTextToDiv = (divIdx, fromOffset, toOffset, className) => {
286
+ const div = textDivs[divIdx];
287
+ if (!div)
288
+ return;
289
+ const text = textContentItemsStr[divIdx];
290
+ const content = text.substring(fromOffset, toOffset);
291
+ if (!content)
292
+ return;
293
+ const node = document.createTextNode(content);
294
+ if (className) {
295
+ const span = document.createElement('span');
296
+ span.className = `${className} appended`;
297
+ span.appendChild(node);
298
+ div.appendChild(span);
299
+ }
300
+ else {
301
+ div.appendChild(node);
302
+ }
303
+ };
304
+ // Determine range of matches to highlight
305
+ let i0 = selectedMatchIdx;
306
+ let i1 = i0 + 1;
307
+ if (highlightAll) {
308
+ i0 = 0;
309
+ i1 = matches.length;
310
+ }
311
+ else if (selectedMatchIdx < 0) {
312
+ return;
313
+ }
314
+ let lastDivIdx = -1;
315
+ let lastOffset = -1;
316
+ for (let i = i0; i < i1; i++) {
317
+ const match = matches[i];
318
+ const begin = match.begin;
319
+ // Skip duplicate matches at the same position (e.g., ligatures)
320
+ if (begin.divIdx === lastDivIdx && begin.offset === lastOffset) {
321
+ continue;
322
+ }
323
+ lastDivIdx = begin.divIdx;
324
+ lastOffset = begin.offset;
325
+ const end = match.end;
326
+ const isSelected = i === selectedMatchIdx;
327
+ const highlightSuffix = isSelected ? ' selected' : '';
328
+ // Match inside new div
329
+ if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
330
+ // If there was a previous div, add remaining text
331
+ if (prevEnd !== null) {
332
+ appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
333
+ }
334
+ // Clear the div and set content until the match start
335
+ beginText(begin);
336
+ }
337
+ else {
338
+ // Same div as previous match - add text between matches
339
+ appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
340
+ }
341
+ if (begin.divIdx === end.divIdx) {
342
+ // Single div match
343
+ appendTextToDiv(begin.divIdx, begin.offset, end.offset, 'highlight' + highlightSuffix);
344
+ }
345
+ else {
346
+ // Multi-div match
347
+ // Highlight from begin to end of first div
348
+ appendTextToDiv(begin.divIdx, begin.offset, infinity.offset, 'highlight begin' + highlightSuffix);
349
+ // Highlight entire middle divs
350
+ for (let n = begin.divIdx + 1; n < end.divIdx; n++) {
351
+ textDivs[n].className = 'highlight middle' + highlightSuffix;
352
+ }
353
+ // Start end div and highlight up to match end
354
+ beginText(end, 'highlight end' + highlightSuffix);
355
+ }
356
+ prevEnd = end;
357
+ }
358
+ // Add remaining text after last match
359
+ if (prevEnd) {
360
+ appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
361
+ }
362
+ }
363
+ scrollToSelectedMatch() {
364
+ if (this.selectedMatchIndex === -1)
365
+ return;
366
+ const match = this.allMatches[this.selectedMatchIndex];
367
+ if (!match)
368
+ return;
369
+ // First scroll to the page
370
+ this.viewer.scrollToPage(match.pageIndex + 1);
371
+ // Then scroll to the highlighted element only if it's not already visible
372
+ setTimeout(() => {
373
+ const selectedElement = this.viewer.container.querySelector('.highlight.selected');
374
+ if (selectedElement) {
375
+ const container = this.viewer.container;
376
+ const containerRect = container.getBoundingClientRect();
377
+ const elementRect = selectedElement.getBoundingClientRect();
378
+ // Check if element is visible within the container
379
+ const isVisible = elementRect.top >= containerRect.top &&
380
+ elementRect.bottom <= containerRect.bottom &&
381
+ elementRect.left >= containerRect.left &&
382
+ elementRect.right <= containerRect.right;
383
+ if (!isVisible) {
384
+ selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
385
+ }
386
+ }
387
+ }, 100);
388
+ }
389
+ clearHighlights() {
390
+ // Clear all highlights from all pages
391
+ for (let pageIndex = 0; pageIndex < this.viewer.pagesCount; pageIndex++) {
392
+ const pageView = this.viewer.getPageView(pageIndex);
393
+ if (!pageView)
394
+ continue;
395
+ const textDivs = pageView.textDivs;
396
+ const textContentItemsStr = pageView.textContentItemsStr;
397
+ if (!textDivs || !textContentItemsStr)
398
+ continue;
399
+ for (let i = 0; i < textDivs.length; i++) {
400
+ const div = textDivs[i];
401
+ if (div) {
402
+ div.textContent = textContentItemsStr[i] || '';
403
+ div.className = '';
404
+ }
405
+ }
406
+ }
407
+ }
408
+ get matchesCount() {
409
+ return {
410
+ current: this.selectedMatchIndex + 1,
411
+ total: this.matchesCountTotal
412
+ };
413
+ }
414
+ reset() {
415
+ this.clearHighlights();
416
+ this.query = '';
417
+ this.allMatches = [];
418
+ this.pageMatches = [];
419
+ this.pageMatchesLength = [];
420
+ this.selectedMatchIndex = -1;
421
+ this.matchesCountTotal = 0;
422
+ }
423
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * PDFPageView - Renders a single PDF page with canvas and text layer.
3
+ * This is a derivative work based on PDF.js pdf_page_view.js
4
+ */
5
+ import type { PDFPageProxy, PageViewport } from 'pdfjs-dist';
6
+ import type { EventBus } from './EventBus.js';
7
+ export interface PDFPageViewOptions {
8
+ container: HTMLElement;
9
+ id: number;
10
+ defaultViewport: PageViewport;
11
+ eventBus: EventBus;
12
+ scale?: number;
13
+ rotation?: number;
14
+ }
15
+ export declare const RenderingStates: {
16
+ readonly INITIAL: 0;
17
+ readonly RUNNING: 1;
18
+ readonly PAUSED: 2;
19
+ readonly FINISHED: 3;
20
+ };
21
+ export type RenderingState = (typeof RenderingStates)[keyof typeof RenderingStates];
22
+ export declare class PDFPageView {
23
+ readonly id: number;
24
+ readonly eventBus: EventBus;
25
+ private container;
26
+ private viewport;
27
+ private pdfPage;
28
+ private scale;
29
+ private rotation;
30
+ private pdfPageRotate;
31
+ div: HTMLDivElement;
32
+ private canvas;
33
+ private canvasWrapper;
34
+ private textLayerDiv;
35
+ private loadingDiv;
36
+ renderingState: RenderingState;
37
+ private renderTask;
38
+ private textLayer;
39
+ private textLayerRendered;
40
+ textDivs: HTMLElement[];
41
+ textContentItemsStr: string[];
42
+ constructor(options: PDFPageViewOptions);
43
+ private setDimensions;
44
+ setPdfPage(pdfPage: PDFPageProxy): void;
45
+ private updateViewport;
46
+ update({ scale, rotation }: {
47
+ scale?: number;
48
+ rotation?: number;
49
+ }): void;
50
+ private resetCanvas;
51
+ reset(): void;
52
+ draw(): Promise<void>;
53
+ private renderTextLayer;
54
+ cancelRendering(): void;
55
+ destroy(): void;
56
+ get width(): number;
57
+ get height(): number;
58
+ }