genus-pdf-viewer 0.2.3 → 0.2.11

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/dist/viewer.js CHANGED
@@ -1,6 +1,45 @@
1
- import * as pdfjsLib from "pdfjs-dist";
1
+ import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs";
2
+ import { PDF_WORKER_SOURCE } from "./pdf-worker-source.generated.js";
2
3
  import { VIEWER_STYLES } from "./styles.js";
3
4
  const GENUS_PDF_VIEWER_TAG = "genus-pdf-viewer";
5
+ export const PDF_WORKER_BOOTSTRAP_ASSET_PATH = "assets/pdf.worker.bootstrap.mjs";
6
+ const PDF_WORKER_BLOB_MIME_TYPE = "text/javascript";
7
+ const PDF_WORKER_COMPAT_SOURCE = `
8
+ if (typeof Promise.try !== "function") {
9
+ Promise.try = (callback, ...args) =>
10
+ new Promise((resolve, reject) => {
11
+ try {
12
+ resolve(callback(...args));
13
+ } catch (error) {
14
+ reject(error);
15
+ }
16
+ });
17
+ }
18
+
19
+ if (typeof Promise.withResolvers !== "function") {
20
+ Promise.withResolvers = () => {
21
+ let resolve;
22
+ let reject;
23
+ const promise = new Promise((promiseResolve, promiseReject) => {
24
+ resolve = promiseResolve;
25
+ reject = promiseReject;
26
+ });
27
+ return { promise, resolve, reject };
28
+ };
29
+ }
30
+
31
+ if (typeof URL.parse !== "function") {
32
+ URL.parse = (url, base) => {
33
+ try {
34
+ return new URL(url, base);
35
+ } catch {
36
+ return null;
37
+ }
38
+ };
39
+ }
40
+ `;
41
+ const IOS_CANVAS_PIXEL_LIMIT = 16_777_216;
42
+ let defaultPdfWorkerSrc = null;
4
43
  const DEFAULT_CONFIG = {
5
44
  page: 1,
6
45
  zoom: 1,
@@ -72,21 +111,261 @@ function resolveTitle(config) {
72
111
  }
73
112
  return "PDF viewer";
74
113
  }
75
- async function toDocumentParameters(config) {
114
+ function resolveSourceHref(source) {
115
+ if (typeof source === "string") {
116
+ return source;
117
+ }
118
+ if (source instanceof URL) {
119
+ return source.toString();
120
+ }
121
+ return null;
122
+ }
123
+ function resolveProxyEndpoint(config, sourceHref) {
124
+ if (config.proxyUrl === false) {
125
+ return null;
126
+ }
127
+ if (typeof config.proxyUrl === "string" && config.proxyUrl) {
128
+ return config.proxyUrl;
129
+ }
130
+ return isCrossOriginSource(sourceHref) ? "/__proxy" : null;
131
+ }
132
+ function resolveEffectiveSourceHref(config, proxyPreference = "auto") {
133
+ const sourceHref = resolveSourceHref(config.src);
134
+ if (!sourceHref) {
135
+ return null;
136
+ }
137
+ if (proxyPreference === "never") {
138
+ return sourceHref;
139
+ }
140
+ const proxyEndpoint = resolveProxyEndpoint(config, sourceHref);
141
+ if (!proxyEndpoint) {
142
+ return sourceHref;
143
+ }
144
+ try {
145
+ const proxyUrl = new URL(proxyEndpoint, typeof window === "undefined" ? "http://localhost" : window.location.href);
146
+ proxyUrl.searchParams.set("url", sourceHref);
147
+ return proxyUrl.toString();
148
+ }
149
+ catch {
150
+ const separator = proxyEndpoint.includes("?") ? "&" : "?";
151
+ return `${proxyEndpoint}${separator}url=${encodeURIComponent(sourceHref)}`;
152
+ }
153
+ }
154
+ function isAppleMobileBrowser() {
155
+ if (typeof navigator === "undefined") {
156
+ return false;
157
+ }
158
+ const userAgent = navigator.userAgent ?? "";
159
+ const platform = navigator.platform ?? "";
160
+ return (/iPad|iPhone|iPod/i.test(userAgent) ||
161
+ (/Mac/i.test(platform) && (navigator.maxTouchPoints ?? 0) > 1));
162
+ }
163
+ function getPdfJsRuntimeOptions() {
164
+ if (!isAppleMobileBrowser()) {
165
+ return {};
166
+ }
167
+ return {
168
+ isOffscreenCanvasSupported: false,
169
+ isImageDecoderSupported: false,
170
+ };
171
+ }
172
+ function getCanvasOutputScale(width, height) {
173
+ const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
174
+ if (!isAppleMobileBrowser()) {
175
+ return devicePixelRatio;
176
+ }
177
+ const pixelArea = Math.max(width * height, 1);
178
+ const maxScale = Math.sqrt(IOS_CANVAS_PIXEL_LIMIT / pixelArea);
179
+ return Math.min(devicePixelRatio, maxScale);
180
+ }
181
+ function iconLabelMarkup(svg, label) {
182
+ return `<span class="button-icon" aria-hidden="true">${svg}</span><span class="button-label">${label}</span>`;
183
+ }
184
+ function openUrl(url) {
185
+ const openedWindow = window.open(url, "_blank", "noopener,noreferrer");
186
+ if (!openedWindow) {
187
+ window.location.href = url;
188
+ }
189
+ }
190
+ function releaseObjectUrl(href) {
191
+ window.setTimeout(() => {
192
+ URL.revokeObjectURL(href);
193
+ }, 1_000);
194
+ }
195
+ function triggerDownload(href, filename) {
196
+ const link = document.createElement("a");
197
+ link.href = href;
198
+ link.download = filename;
199
+ link.rel = "noopener";
200
+ document.body.appendChild(link);
201
+ link.click();
202
+ link.remove();
203
+ }
204
+ async function tryNativeFileDownload(blob, filename) {
205
+ if (typeof navigator === "undefined" || typeof File === "undefined") {
206
+ return false;
207
+ }
208
+ if (typeof navigator.share !== "function") {
209
+ return false;
210
+ }
211
+ try {
212
+ const file = new File([blob], filename, { type: blob.type || "application/pdf" });
213
+ if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
214
+ return false;
215
+ }
216
+ await navigator.share({
217
+ title: filename,
218
+ files: [file],
219
+ });
220
+ return true;
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ function shouldUseCompactToolbarUi() {
227
+ return isAppleMobileBrowser();
228
+ }
229
+ function ensurePromiseCompat() {
230
+ const promiseCtor = Promise;
231
+ if (typeof promiseCtor.try !== "function") {
232
+ promiseCtor.try = (callback, ...args) => new Promise((resolve, reject) => {
233
+ try {
234
+ resolve(callback(...args));
235
+ }
236
+ catch (error) {
237
+ reject(error);
238
+ }
239
+ });
240
+ }
241
+ if (typeof promiseCtor.withResolvers !== "function") {
242
+ promiseCtor.withResolvers = () => {
243
+ let resolve;
244
+ let reject;
245
+ const promise = new Promise((promiseResolve, promiseReject) => {
246
+ resolve = promiseResolve;
247
+ reject = promiseReject;
248
+ });
249
+ return {
250
+ promise,
251
+ resolve,
252
+ reject,
253
+ };
254
+ };
255
+ }
256
+ }
257
+ function ensureUrlParseCompat() {
258
+ const urlCtor = URL;
259
+ if (typeof URL === "undefined" || typeof urlCtor.parse === "function") {
260
+ return;
261
+ }
262
+ urlCtor.parse = (input, base) => {
263
+ try {
264
+ if (typeof base === "undefined") {
265
+ return new URL(input);
266
+ }
267
+ return new URL(input, base);
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ };
273
+ }
274
+ function ensurePdfJsCompat() {
275
+ ensurePromiseCompat();
276
+ ensureUrlParseCompat();
277
+ }
278
+ function isUnbundledPackageModuleUrl(moduleUrl) {
279
+ try {
280
+ const { pathname } = new URL(moduleUrl);
281
+ return /\/(?:index|viewer)\.js$/.test(pathname);
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
287
+ function createInlinePdfWorkerSrc() {
288
+ if (defaultPdfWorkerSrc) {
289
+ return defaultPdfWorkerSrc;
290
+ }
291
+ if (typeof Blob === "undefined" ||
292
+ typeof URL === "undefined" ||
293
+ typeof URL.createObjectURL !== "function" ||
294
+ !PDF_WORKER_SOURCE) {
295
+ return null;
296
+ }
297
+ const workerBlob = new Blob([PDF_WORKER_COMPAT_SOURCE, "\n", PDF_WORKER_SOURCE], {
298
+ type: PDF_WORKER_BLOB_MIME_TYPE,
299
+ });
300
+ defaultPdfWorkerSrc = URL.createObjectURL(workerBlob);
301
+ return defaultPdfWorkerSrc;
302
+ }
303
+ export function releaseDefaultPdfWorkerSrc() {
304
+ if (!defaultPdfWorkerSrc) {
305
+ return;
306
+ }
307
+ if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
308
+ URL.revokeObjectURL(defaultPdfWorkerSrc);
309
+ }
310
+ defaultPdfWorkerSrc = null;
311
+ }
312
+ export function resolveDefaultPdfWorkerSrc(options = {}) {
313
+ const moduleUrl = options.moduleUrl ?? import.meta.url;
314
+ const packageRelativeWorkerUrl = new URL("./pdf.worker.bootstrap.mjs", moduleUrl).toString();
315
+ if (isUnbundledPackageModuleUrl(moduleUrl)) {
316
+ return packageRelativeWorkerUrl;
317
+ }
318
+ const inlineWorkerSrc = createInlinePdfWorkerSrc();
319
+ if (inlineWorkerSrc) {
320
+ return inlineWorkerSrc;
321
+ }
322
+ const baseUri = options.baseUri ??
323
+ (typeof document !== "undefined" ? document.baseURI : undefined) ??
324
+ (typeof location !== "undefined" ? location.href : undefined);
325
+ return baseUri
326
+ ? new URL(PDF_WORKER_BOOTSTRAP_ASSET_PATH, baseUri).toString()
327
+ : packageRelativeWorkerUrl;
328
+ }
329
+ async function createDocumentData(config) {
330
+ if (config.src instanceof Blob) {
331
+ return new Uint8Array(await config.src.arrayBuffer());
332
+ }
333
+ if (config.src instanceof Uint8Array) {
334
+ return new Uint8Array(config.src);
335
+ }
336
+ return null;
337
+ }
338
+ async function toDocumentParameters(config, proxyPreference = "auto") {
339
+ const runtimeOptions = getPdfJsRuntimeOptions();
340
+ const data = await createDocumentData(config);
341
+ if (data) {
342
+ return {
343
+ data,
344
+ disableRange: true,
345
+ disableStream: true,
346
+ disableAutoFetch: true,
347
+ stopAtErrors: true,
348
+ ...runtimeOptions,
349
+ };
350
+ }
76
351
  if (typeof config.src === "string") {
352
+ const url = resolveEffectiveSourceHref(config, proxyPreference);
77
353
  return {
78
- url: config.src,
354
+ url: url ?? config.src,
79
355
  withCredentials: config.withCredentials,
80
356
  httpHeaders: config.httpHeaders,
81
357
  stopAtErrors: true,
358
+ ...runtimeOptions,
82
359
  };
83
360
  }
84
361
  if (config.src instanceof URL) {
362
+ const url = resolveEffectiveSourceHref(config, proxyPreference);
85
363
  return {
86
- url: config.src.toString(),
364
+ url: url ?? config.src.toString(),
87
365
  withCredentials: config.withCredentials,
88
366
  httpHeaders: config.httpHeaders,
89
367
  stopAtErrors: true,
368
+ ...runtimeOptions,
90
369
  };
91
370
  }
92
371
  if (config.src instanceof Blob) {
@@ -96,6 +375,7 @@ async function toDocumentParameters(config) {
96
375
  disableStream: true,
97
376
  disableAutoFetch: true,
98
377
  stopAtErrors: true,
378
+ ...runtimeOptions,
99
379
  };
100
380
  }
101
381
  return {
@@ -104,6 +384,7 @@ async function toDocumentParameters(config) {
104
384
  disableStream: true,
105
385
  disableAutoFetch: true,
106
386
  stopAtErrors: true,
387
+ ...runtimeOptions,
107
388
  };
108
389
  }
109
390
  async function createBlobFromSource(config) {
@@ -113,32 +394,183 @@ async function createBlobFromSource(config) {
113
394
  if (config.src instanceof Uint8Array) {
114
395
  return new Blob([new Uint8Array(config.src)], { type: "application/pdf" });
115
396
  }
116
- const href = typeof config.src === "string"
117
- ? config.src
118
- : config.src instanceof URL
119
- ? config.src.toString()
120
- : null;
397
+ const response = await fetchSourceResponse(config);
398
+ if (!response) {
399
+ return null;
400
+ }
401
+ return response.blob();
402
+ }
403
+ function emitViewerEvent(element, type, detail) {
404
+ element.dispatchEvent(new CustomEvent(`genus-${type}`, {
405
+ bubbles: true,
406
+ detail,
407
+ }));
408
+ }
409
+ function getErrorName(error) {
410
+ if (typeof error === "object" &&
411
+ error !== null &&
412
+ "name" in error &&
413
+ typeof error.name === "string") {
414
+ return error.name;
415
+ }
416
+ return "";
417
+ }
418
+ function getRawErrorMessage(error) {
419
+ if (error instanceof Error && error.message) {
420
+ return error.message;
421
+ }
422
+ if (typeof error === "object" &&
423
+ error !== null &&
424
+ "message" in error &&
425
+ typeof error.message === "string") {
426
+ return error.message;
427
+ }
428
+ if (typeof error === "string") {
429
+ return error;
430
+ }
431
+ return "";
432
+ }
433
+ function isLikelyCorsError(error) {
434
+ const message = getRawErrorMessage(error).toLowerCase();
435
+ const name = getErrorName(error).toLowerCase();
436
+ return (message.includes("access-control") ||
437
+ message.includes("cross-origin") ||
438
+ message.includes("cors") ||
439
+ message.includes("credential is not supported") ||
440
+ message.includes("origin ") ||
441
+ name.includes("cors"));
442
+ }
443
+ function isLikelyNetworkLoadError(error) {
444
+ const name = getErrorName(error).toLowerCase();
445
+ const message = getRawErrorMessage(error).toLowerCase();
446
+ return (name.includes("network") ||
447
+ name === "unknownerrorexception" ||
448
+ message.includes("load failed") ||
449
+ message.includes("failed to fetch") ||
450
+ message.includes("networkerror") ||
451
+ message.includes("unexpected server response (0)") ||
452
+ message.includes("fetch"));
453
+ }
454
+ function isCrossOriginSource(href) {
455
+ if (typeof window === "undefined") {
456
+ return false;
457
+ }
458
+ try {
459
+ return new URL(href, window.location.href).origin !== window.location.origin;
460
+ }
461
+ catch {
462
+ return false;
463
+ }
464
+ }
465
+ function resolveFetchCredentials(href, withCredentials) {
466
+ if (withCredentials) {
467
+ return "include";
468
+ }
469
+ return isCrossOriginSource(href) ? "omit" : "same-origin";
470
+ }
471
+ function resolveFetchMode(href) {
472
+ return isCrossOriginSource(href) ? "cors" : "same-origin";
473
+ }
474
+ async function fetchSourceResponse(config, proxyPreference = "auto") {
475
+ const href = resolveEffectiveSourceHref(config, proxyPreference);
121
476
  if (!href || typeof fetch === "undefined") {
122
477
  return null;
123
478
  }
124
479
  const response = await fetch(href, {
125
480
  headers: config.httpHeaders,
126
- credentials: config.withCredentials ? "include" : "same-origin",
481
+ credentials: resolveFetchCredentials(href, config.withCredentials),
482
+ mode: resolveFetchMode(href),
127
483
  });
128
484
  if (!response.ok) {
129
485
  return null;
130
486
  }
131
- return response.blob();
487
+ return response;
132
488
  }
133
- function emitViewerEvent(element, type, detail) {
134
- element.dispatchEvent(new CustomEvent(`genus-${type}`, {
135
- bubbles: true,
136
- detail,
137
- }));
489
+ async function fetchDocumentDataViaXhr(config, proxyPreference = "auto") {
490
+ const href = resolveEffectiveSourceHref(config, proxyPreference);
491
+ if (!href || typeof XMLHttpRequest === "undefined") {
492
+ return null;
493
+ }
494
+ return new Promise((resolve, reject) => {
495
+ const xhr = new XMLHttpRequest();
496
+ xhr.open("GET", href, true);
497
+ xhr.responseType = "arraybuffer";
498
+ xhr.withCredentials = Boolean(config.withCredentials);
499
+ for (const [key, value] of Object.entries(config.httpHeaders ?? {})) {
500
+ xhr.setRequestHeader(key, value);
501
+ }
502
+ xhr.onload = () => {
503
+ if ((xhr.status === 200 || xhr.status === 206) && xhr.response instanceof ArrayBuffer) {
504
+ resolve(new Uint8Array(xhr.response));
505
+ return;
506
+ }
507
+ resolve(null);
508
+ };
509
+ xhr.onerror = () => {
510
+ reject(new TypeError("Load failed"));
511
+ };
512
+ xhr.onabort = () => {
513
+ reject(new TypeError("Load failed"));
514
+ };
515
+ xhr.send();
516
+ });
517
+ }
518
+ async function fetchDocumentDataFromSource(config, proxyPreference = "auto") {
519
+ try {
520
+ const response = await fetchSourceResponse(config, proxyPreference);
521
+ if (response) {
522
+ return new Uint8Array(await response.arrayBuffer());
523
+ }
524
+ }
525
+ catch {
526
+ // Fall through to the XHR loader, which is often more reliable on Safari.
527
+ }
528
+ return fetchDocumentDataViaXhr(config, proxyPreference);
529
+ }
530
+ function createErrorDetail(config, error) {
531
+ const sourceUrl = resolveSourceHref(config.src) ?? undefined;
532
+ const rawMessage = getRawErrorMessage(error);
533
+ if (sourceUrl && isLikelyNetworkLoadError(error)) {
534
+ if (isCrossOriginSource(sourceUrl) && isLikelyCorsError(error)) {
535
+ return {
536
+ error,
537
+ code: "cors",
538
+ sourceUrl,
539
+ message: `Failed to load PDF from ${sourceUrl}. The remote server blocked this origin. Allow CORS for your app origin or pass Blob/Uint8Array PDF data instead of a URL.`,
540
+ };
541
+ }
542
+ return {
543
+ error,
544
+ code: "network",
545
+ sourceUrl,
546
+ message: `Failed to load PDF from ${sourceUrl}. Check the URL, auth headers, and network access.`,
547
+ };
548
+ }
549
+ return {
550
+ error,
551
+ code: "unknown",
552
+ sourceUrl,
553
+ message: rawMessage || "Failed to load PDF.",
554
+ };
555
+ }
556
+ function shouldAttemptNativeUrlFallback(config, error) {
557
+ return Boolean(isAppleMobileBrowser() &&
558
+ resolveSourceHref(config.src) &&
559
+ (isLikelyNetworkLoadError(error) || isLikelyCorsError(error)));
138
560
  }
139
561
  export class GenusPdfViewerElement extends HTMLElement {
140
562
  static get observedAttributes() {
141
- return ["src", "title", "page", "zoom", "fit", "continuous", "toolbar", "download"];
563
+ return [
564
+ "src",
565
+ "title",
566
+ "page",
567
+ "zoom",
568
+ "fit",
569
+ "continuous",
570
+ "toolbar",
571
+ "download",
572
+ "proxy-url",
573
+ ];
142
574
  }
143
575
  #config = null;
144
576
  #document = null;
@@ -146,6 +578,8 @@ export class GenusPdfViewerElement extends HTMLElement {
146
578
  #pageCount = 0;
147
579
  #loadVersion = 0;
148
580
  #renderVersion = 0;
581
+ #isUsingNativeViewer = false;
582
+ #resizeObserver = null;
149
583
  #shadow = this.attachShadow({ mode: "open" });
150
584
  #toolbar = document.createElement("div");
151
585
  #navGroup = document.createElement("div");
@@ -162,6 +596,8 @@ export class GenusPdfViewerElement extends HTMLElement {
162
596
  #stage = document.createElement("div");
163
597
  #status = document.createElement("div");
164
598
  #handleResize = () => {
599
+ this.#syncInteractionMode();
600
+ this.#syncContainerSize();
165
601
  if (!this.#document || !this.#config) {
166
602
  return;
167
603
  }
@@ -187,6 +623,11 @@ export class GenusPdfViewerElement extends HTMLElement {
187
623
  };
188
624
  constructor() {
189
625
  super();
626
+ if (typeof ResizeObserver !== "undefined") {
627
+ this.#resizeObserver = new ResizeObserver(() => {
628
+ this.#handleResize();
629
+ });
630
+ }
190
631
  const style = document.createElement("style");
191
632
  style.textContent = VIEWER_STYLES;
192
633
  const shell = document.createElement("div");
@@ -198,33 +639,49 @@ export class GenusPdfViewerElement extends HTMLElement {
198
639
  this.#pageValue.className = "value";
199
640
  this.#zoomValue.className = "value";
200
641
  this.#prevButton.type = "button";
201
- this.#prevButton.textContent = "Prev";
642
+ this.#prevButton.className = "compact";
643
+ this.#prevButton.setAttribute("aria-label", "Previous page");
644
+ this.#prevButton.title = "Previous page";
645
+ this.#prevButton.innerHTML = iconLabelMarkup('<svg viewBox="0 0 24 24" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"></path></svg>', "Prev");
202
646
  this.#prevButton.addEventListener("click", () => {
203
647
  void this.prevPage();
204
648
  });
205
649
  this.#nextButton.type = "button";
206
- this.#nextButton.textContent = "Next";
650
+ this.#nextButton.className = "compact";
651
+ this.#nextButton.setAttribute("aria-label", "Next page");
652
+ this.#nextButton.title = "Next page";
653
+ this.#nextButton.innerHTML = iconLabelMarkup('<svg viewBox="0 0 24 24" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"></path></svg>', "Next");
207
654
  this.#nextButton.addEventListener("click", () => {
208
655
  void this.nextPage();
209
656
  });
210
657
  this.#zoomOutButton.type = "button";
211
658
  this.#zoomOutButton.textContent = "−";
659
+ this.#zoomOutButton.setAttribute("aria-label", "Zoom out");
660
+ this.#zoomOutButton.title = "Zoom out";
212
661
  this.#zoomOutButton.addEventListener("click", () => {
213
662
  void this.zoomOut();
214
663
  });
215
664
  this.#zoomInButton.type = "button";
216
665
  this.#zoomInButton.textContent = "+";
666
+ this.#zoomInButton.setAttribute("aria-label", "Zoom in");
667
+ this.#zoomInButton.title = "Zoom in";
217
668
  this.#zoomInButton.addEventListener("click", () => {
218
669
  void this.zoomIn();
219
670
  });
220
671
  this.#resetZoomButton.type = "button";
221
- this.#resetZoomButton.textContent = "100%";
672
+ this.#resetZoomButton.className = "compact";
673
+ this.#resetZoomButton.setAttribute("aria-label", "Reset zoom");
674
+ this.#resetZoomButton.title = "Reset zoom";
675
+ this.#resetZoomButton.innerHTML = iconLabelMarkup('<svg viewBox="0 0 24 24" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3.2-6.9"></path><path d="M21 3v6h-6"></path></svg>', "100%");
222
676
  this.#resetZoomButton.addEventListener("click", () => {
223
677
  void this.setZoom(1);
224
678
  });
225
679
  this.#downloadButton.type = "button";
226
- this.#downloadButton.textContent = "Download";
227
- this.#downloadButton.className = "primary";
680
+ this.#downloadButton.className = "primary icon-button";
681
+ this.#downloadButton.setAttribute("aria-label", "Download PDF");
682
+ this.#downloadButton.title = "Download PDF";
683
+ this.#downloadButton.innerHTML =
684
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11"></path><path d="m7 11 5 5 5-5"></path><path d="M5 21h14"></path></svg>';
228
685
  this.#downloadButton.addEventListener("click", () => {
229
686
  void this.download();
230
687
  });
@@ -241,6 +698,9 @@ export class GenusPdfViewerElement extends HTMLElement {
241
698
  }
242
699
  connectedCallback() {
243
700
  window.addEventListener("resize", this.#handleResize);
701
+ this.#observeResizeTargets();
702
+ this.#syncInteractionMode();
703
+ this.#syncContainerSize();
244
704
  if (!this.#config) {
245
705
  const attributeConfig = this.#configFromAttributes();
246
706
  if (attributeConfig) {
@@ -257,6 +717,7 @@ export class GenusPdfViewerElement extends HTMLElement {
257
717
  }
258
718
  disconnectedCallback() {
259
719
  window.removeEventListener("resize", this.#handleResize);
720
+ this.#resizeObserver?.disconnect();
260
721
  void this.#teardownDocument();
261
722
  }
262
723
  attributeChangedCallback() {
@@ -285,6 +746,7 @@ export class GenusPdfViewerElement extends HTMLElement {
285
746
  const sourceChanged = !previous ||
286
747
  !isSameSource(previous.src, nextConfig.src) ||
287
748
  previous.workerSrc !== nextConfig.workerSrc ||
749
+ previous.proxyUrl !== nextConfig.proxyUrl ||
288
750
  previous.withCredentials !== nextConfig.withCredentials ||
289
751
  !sameHeaders(previous.httpHeaders, nextConfig.httpHeaders);
290
752
  if (sourceChanged) {
@@ -318,7 +780,7 @@ export class GenusPdfViewerElement extends HTMLElement {
318
780
  await this.#loadAndRender();
319
781
  }
320
782
  async goToPage(page) {
321
- if (!this.#config) {
783
+ if (!this.#config || this.#isUsingNativeViewer) {
322
784
  return;
323
785
  }
324
786
  const nextPage = clampPage(page, this.#pageCount || Number.MAX_SAFE_INTEGER);
@@ -342,13 +804,13 @@ export class GenusPdfViewerElement extends HTMLElement {
342
804
  await this.goToPage((this.#config.page ?? DEFAULT_CONFIG.page) + 1);
343
805
  }
344
806
  async prevPage() {
345
- if (!this.#config) {
807
+ if (!this.#config || this.#isUsingNativeViewer) {
346
808
  return;
347
809
  }
348
810
  await this.goToPage((this.#config.page ?? DEFAULT_CONFIG.page) - 1);
349
811
  }
350
812
  async setZoom(zoom) {
351
- if (!this.#config) {
813
+ if (!this.#config || this.#isUsingNativeViewer) {
352
814
  return;
353
815
  }
354
816
  const nextZoom = clampZoom(zoom, this.#config.minZoom ?? DEFAULT_CONFIG.minZoom, this.#config.maxZoom ?? DEFAULT_CONFIG.maxZoom);
@@ -358,14 +820,14 @@ export class GenusPdfViewerElement extends HTMLElement {
358
820
  emitViewerEvent(this, "zoomchange", { zoom: nextZoom });
359
821
  }
360
822
  async zoomIn() {
361
- if (!this.#config) {
823
+ if (!this.#config || this.#isUsingNativeViewer) {
362
824
  return;
363
825
  }
364
826
  await this.setZoom((this.#config.zoom ?? DEFAULT_CONFIG.zoom) +
365
827
  (this.#config.zoomStep ?? DEFAULT_CONFIG.zoomStep));
366
828
  }
367
829
  async zoomOut() {
368
- if (!this.#config) {
830
+ if (!this.#config || this.#isUsingNativeViewer) {
369
831
  return;
370
832
  }
371
833
  await this.setZoom((this.#config.zoom ?? DEFAULT_CONFIG.zoom) -
@@ -375,26 +837,29 @@ export class GenusPdfViewerElement extends HTMLElement {
375
837
  if (!this.#config?.allowDownload) {
376
838
  return;
377
839
  }
378
- const link = document.createElement("a");
379
840
  const blob = await createBlobFromSource(this.#config);
380
841
  if (blob) {
381
842
  const href = URL.createObjectURL(blob);
382
- link.href = href;
383
- link.download = resolveTitle(this.#config).endsWith(".pdf")
843
+ const filename = resolveTitle(this.#config).endsWith(".pdf")
384
844
  ? resolveTitle(this.#config)
385
845
  : `${resolveTitle(this.#config)}.pdf`;
386
- document.body.appendChild(link);
387
- link.click();
388
- link.remove();
389
- URL.revokeObjectURL(href);
390
- return;
846
+ try {
847
+ if (isAppleMobileBrowser() && (await tryNativeFileDownload(blob, filename))) {
848
+ return;
849
+ }
850
+ triggerDownload(href, filename);
851
+ return;
852
+ }
853
+ finally {
854
+ releaseObjectUrl(href);
855
+ }
391
856
  }
392
857
  if (typeof this.#config.src === "string") {
393
- window.open(this.#config.src, "_blank", "noopener,noreferrer");
858
+ openUrl(this.#config.src);
394
859
  return;
395
860
  }
396
861
  if (this.#config.src instanceof URL) {
397
- window.open(this.#config.src.toString(), "_blank", "noopener,noreferrer");
862
+ openUrl(this.#config.src.toString());
398
863
  }
399
864
  }
400
865
  async destroy() {
@@ -416,6 +881,13 @@ export class GenusPdfViewerElement extends HTMLElement {
416
881
  continuous: parseBooleanAttribute(this.getAttribute("continuous"), DEFAULT_CONFIG.continuous),
417
882
  showToolbar: parseBooleanAttribute(this.getAttribute("toolbar"), DEFAULT_CONFIG.showToolbar),
418
883
  allowDownload: parseBooleanAttribute(this.getAttribute("download"), DEFAULT_CONFIG.allowDownload),
884
+ proxyUrl: (() => {
885
+ const value = this.getAttribute("proxy-url");
886
+ if (value === null) {
887
+ return undefined;
888
+ }
889
+ return value === "false" ? false : value;
890
+ })(),
419
891
  };
420
892
  }
421
893
  async #loadAndRender() {
@@ -424,19 +896,16 @@ export class GenusPdfViewerElement extends HTMLElement {
424
896
  return;
425
897
  }
426
898
  const loadVersion = ++this.#loadVersion;
899
+ this.#isUsingNativeViewer = false;
427
900
  this.#setStatus("Loading PDF...");
428
901
  await this.#teardownDocument();
429
902
  try {
430
903
  const globalWorkerOptions = pdfjsLib.GlobalWorkerOptions;
431
904
  globalWorkerOptions.workerPort = null;
432
- globalWorkerOptions.workerSrc =
433
- config.workerSrc ?? new URL("./pdf.worker.min.mjs", import.meta.url).toString();
434
- const params = await toDocumentParameters(config);
435
- const task = pdfjsLib.getDocument(params);
436
- this.#loadingTask = task;
437
- const documentProxy = await task.promise;
438
- if (loadVersion !== this.#loadVersion) {
439
- await documentProxy.destroy();
905
+ ensurePdfJsCompat();
906
+ globalWorkerOptions.workerSrc = config.workerSrc ?? resolveDefaultPdfWorkerSrc();
907
+ const documentProxy = await this.#loadDocumentProxy(config, loadVersion);
908
+ if (!documentProxy) {
440
909
  return;
441
910
  }
442
911
  this.#document = documentProxy;
@@ -455,16 +924,79 @@ export class GenusPdfViewerElement extends HTMLElement {
455
924
  emitViewerEvent(this, "ready", detail);
456
925
  }
457
926
  catch (error) {
458
- const detail = {
459
- error,
460
- message: this.#toErrorMessage(error),
461
- };
927
+ if (this.#tryRenderNativeUrlFallback(config, error)) {
928
+ return;
929
+ }
930
+ const detail = createErrorDetail(config, error);
462
931
  this.#setStatus(detail.message);
463
932
  emitViewerEvent(this, "error", detail);
464
933
  }
465
934
  }
935
+ async #loadDocumentProxy(config, loadVersion) {
936
+ try {
937
+ return await this.#loadDocumentProxyFromParams(await toDocumentParameters(config, "auto"), loadVersion);
938
+ }
939
+ catch (error) {
940
+ let currentError = error;
941
+ const directRetryParams = await this.#createDirectUrlRetryParameters(config);
942
+ if (directRetryParams) {
943
+ try {
944
+ return await this.#loadDocumentProxyFromParams(directRetryParams, loadVersion);
945
+ }
946
+ catch (directError) {
947
+ currentError = directError;
948
+ }
949
+ }
950
+ const fallbackParams = await this.#createFetchedDataFallbackParameters(config, currentError, directRetryParams ? "never" : "auto");
951
+ if (!fallbackParams) {
952
+ throw currentError;
953
+ }
954
+ return this.#loadDocumentProxyFromParams(fallbackParams, loadVersion);
955
+ }
956
+ }
957
+ async #loadDocumentProxyFromParams(params, loadVersion) {
958
+ const task = pdfjsLib.getDocument(params);
959
+ this.#loadingTask = task;
960
+ const documentProxy = await task.promise;
961
+ if (loadVersion !== this.#loadVersion) {
962
+ await documentProxy.destroy();
963
+ return null;
964
+ }
965
+ return documentProxy;
966
+ }
967
+ async #createFetchedDataFallbackParameters(config, error, proxyPreference) {
968
+ if (!resolveSourceHref(config.src) || !isLikelyNetworkLoadError(error)) {
969
+ return null;
970
+ }
971
+ try {
972
+ const data = await fetchDocumentDataFromSource(config, proxyPreference);
973
+ if (!data) {
974
+ return null;
975
+ }
976
+ return {
977
+ data,
978
+ disableRange: true,
979
+ disableStream: true,
980
+ disableAutoFetch: true,
981
+ stopAtErrors: true,
982
+ ...getPdfJsRuntimeOptions(),
983
+ };
984
+ }
985
+ catch {
986
+ return null;
987
+ }
988
+ }
989
+ async #createDirectUrlRetryParameters(config) {
990
+ const proxiedHref = resolveEffectiveSourceHref(config, "auto");
991
+ const directHref = resolveEffectiveSourceHref(config, "never");
992
+ if (!proxiedHref || !directHref || proxiedHref === directHref) {
993
+ return null;
994
+ }
995
+ return toDocumentParameters(config, "never");
996
+ }
466
997
  async #teardownDocument() {
467
998
  this.#renderVersion += 1;
999
+ this.#isUsingNativeViewer = false;
468
1000
  const loadingTask = this.#loadingTask;
469
1001
  this.#loadingTask = null;
470
1002
  if (loadingTask) {
@@ -497,16 +1029,10 @@ export class GenusPdfViewerElement extends HTMLElement {
497
1029
  const scrollSnapshot = config.continuous ? this.#captureScrollSnapshot() : null;
498
1030
  this.#setStatus(null);
499
1031
  this.#stage.className = config.continuous ? "stage" : "stage single";
500
- const stageChildren = Array.from(this.#stage.children);
501
- for (const child of stageChildren) {
502
- if (child !== this.#status) {
503
- child.remove();
504
- }
505
- }
1032
+ const stageChildren = Array.from(this.#stage.children).filter((child) => child !== this.#status);
506
1033
  if (config.continuous) {
507
1034
  const stack = document.createElement("div");
508
1035
  stack.className = "page-stack";
509
- this.#stage.insertBefore(stack, this.#status);
510
1036
  for (let index = 1; index <= documentProxy.numPages; index += 1) {
511
1037
  if (renderVersion !== this.#renderVersion) {
512
1038
  return;
@@ -516,6 +1042,10 @@ export class GenusPdfViewerElement extends HTMLElement {
516
1042
  stack.appendChild(wrapper);
517
1043
  this.#animatePageEntry(wrapper);
518
1044
  }
1045
+ for (const child of stageChildren) {
1046
+ child.remove();
1047
+ }
1048
+ this.#stage.insertBefore(stack, this.#status);
519
1049
  this.#restoreScrollSnapshot(scrollSnapshot ?? {
520
1050
  page: config.page ?? DEFAULT_CONFIG.page,
521
1051
  offsetRatio: 0,
@@ -525,6 +1055,9 @@ export class GenusPdfViewerElement extends HTMLElement {
525
1055
  const pageNumber = clampPage(config.page ?? DEFAULT_CONFIG.page, documentProxy.numPages);
526
1056
  const page = await documentProxy.getPage(pageNumber);
527
1057
  const wrapper = await this.#renderPage(page, config, pageNumber);
1058
+ for (const child of stageChildren) {
1059
+ child.remove();
1060
+ }
528
1061
  this.#stage.insertBefore(wrapper, this.#status);
529
1062
  this.#animatePageEntry(wrapper);
530
1063
  }
@@ -541,9 +1074,9 @@ export class GenusPdfViewerElement extends HTMLElement {
541
1074
  }
542
1075
  const scale = this.#computeScale(page, config);
543
1076
  const viewport = page.getViewport({ scale });
544
- const dpr = window.devicePixelRatio || 1;
545
- canvas.width = Math.floor(viewport.width * dpr);
546
- canvas.height = Math.floor(viewport.height * dpr);
1077
+ const outputScale = getCanvasOutputScale(viewport.width, viewport.height);
1078
+ canvas.width = Math.max(1, Math.floor(viewport.width * outputScale));
1079
+ canvas.height = Math.max(1, Math.floor(viewport.height * outputScale));
547
1080
  canvas.style.width = `${Math.floor(viewport.width)}px`;
548
1081
  canvas.style.height = `${Math.floor(viewport.height)}px`;
549
1082
  wrapper.style.width = canvas.style.width;
@@ -554,16 +1087,17 @@ export class GenusPdfViewerElement extends HTMLElement {
554
1087
  canvasContext: context,
555
1088
  viewport,
556
1089
  };
557
- if (dpr !== 1) {
558
- renderContext.transform = [dpr, 0, 0, dpr, 0, 0];
1090
+ if (outputScale !== 1) {
1091
+ renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0];
559
1092
  }
560
1093
  await page.render(renderContext).promise;
561
1094
  return wrapper;
562
1095
  }
563
1096
  #computeScale(page, config) {
564
1097
  const viewport = page.getViewport({ scale: 1 });
565
- const availableWidth = Math.max(320, this.#stage.clientWidth || 960) - 32;
566
- const availableHeight = Math.max(320, this.#stage.clientHeight || 720) - 32;
1098
+ const { width, height } = this.#getAvailableViewportSize();
1099
+ const availableWidth = Math.max(320, width) - 32;
1100
+ const availableHeight = Math.max(320, height) - 32;
567
1101
  let baseScale = 1;
568
1102
  if (config.fit === "width") {
569
1103
  baseScale = availableWidth / viewport.width;
@@ -577,11 +1111,20 @@ export class GenusPdfViewerElement extends HTMLElement {
577
1111
  const config = this.#config;
578
1112
  const page = config?.page ?? DEFAULT_CONFIG.page;
579
1113
  const zoom = config?.zoom ?? DEFAULT_CONFIG.zoom;
1114
+ const isCompactUi = this.hasAttribute("compact-ui");
1115
+ const isNativeViewer = this.#isUsingNativeViewer;
580
1116
  this.#toolbar.hidden = !(config?.showToolbar ?? DEFAULT_CONFIG.showToolbar);
581
- this.#pageValue.textContent = `${page} / ${this.#pageCount || 0}`;
582
- this.#zoomValue.textContent = `${Math.round(zoom * 100)}%`;
583
- this.#prevButton.disabled = page <= 1;
584
- this.#nextButton.disabled = this.#pageCount > 0 ? page >= this.#pageCount : true;
1117
+ this.#pageValue.textContent = isNativeViewer
1118
+ ? "Browser"
1119
+ : isCompactUi
1120
+ ? `${page}/${this.#pageCount || 0}`
1121
+ : `${page} / ${this.#pageCount || 0}`;
1122
+ this.#zoomValue.textContent = isNativeViewer ? "Auto" : `${Math.round(zoom * 100)}%`;
1123
+ this.#prevButton.disabled = isNativeViewer || page <= 1;
1124
+ this.#nextButton.disabled = isNativeViewer || (this.#pageCount > 0 ? page >= this.#pageCount : true);
1125
+ this.#zoomOutButton.disabled = isNativeViewer;
1126
+ this.#zoomInButton.disabled = isNativeViewer;
1127
+ this.#resetZoomButton.disabled = isNativeViewer;
585
1128
  this.#downloadButton.hidden = !(config?.allowDownload ?? DEFAULT_CONFIG.allowDownload);
586
1129
  }
587
1130
  #setStatus(message) {
@@ -594,17 +1137,13 @@ export class GenusPdfViewerElement extends HTMLElement {
594
1137
  this.#status.textContent = message;
595
1138
  }
596
1139
  #scrollToPage(page, smooth) {
597
- const target = this.#stage.querySelector(`.page[data-page="${page}"]`);
598
- if (!target) {
599
- return;
600
- }
601
- target.scrollIntoView({
602
- block: "start",
603
- behavior: smooth ? "smooth" : "auto",
604
- });
1140
+ this.#restoreScrollSnapshot({
1141
+ page,
1142
+ offsetRatio: 0,
1143
+ }, smooth);
605
1144
  }
606
1145
  #captureScrollSnapshot() {
607
- const currentPage = this.#detectVisiblePage();
1146
+ const currentPage = this.#config?.page ?? this.#detectVisiblePage();
608
1147
  if (!currentPage) {
609
1148
  return null;
610
1149
  }
@@ -636,6 +1175,97 @@ export class GenusPdfViewerElement extends HTMLElement {
636
1175
  wrapper.classList.remove("entering");
637
1176
  });
638
1177
  }
1178
+ #observeResizeTargets() {
1179
+ if (!this.#resizeObserver) {
1180
+ return;
1181
+ }
1182
+ this.#resizeObserver.disconnect();
1183
+ this.#resizeObserver.observe(this);
1184
+ if (this.parentElement) {
1185
+ this.#resizeObserver.observe(this.parentElement);
1186
+ }
1187
+ }
1188
+ #syncInteractionMode() {
1189
+ if (shouldUseCompactToolbarUi()) {
1190
+ this.setAttribute("compact-ui", "");
1191
+ return;
1192
+ }
1193
+ this.removeAttribute("compact-ui");
1194
+ }
1195
+ #tryRenderNativeUrlFallback(config, error) {
1196
+ if (!shouldAttemptNativeUrlFallback(config, error)) {
1197
+ return false;
1198
+ }
1199
+ const sourceUrl = resolveSourceHref(config.src);
1200
+ if (!sourceUrl) {
1201
+ return false;
1202
+ }
1203
+ this.#isUsingNativeViewer = true;
1204
+ this.#pageCount = 0;
1205
+ this.#setStatus(null);
1206
+ this.#stage.className = "stage native-stage";
1207
+ const stageChildren = Array.from(this.#stage.children).filter((child) => child !== this.#status);
1208
+ for (const child of stageChildren) {
1209
+ child.remove();
1210
+ }
1211
+ const wrapper = document.createElement("div");
1212
+ wrapper.className = "page native-page";
1213
+ wrapper.dataset.page = "1";
1214
+ const frame = document.createElement("iframe");
1215
+ frame.className = "native-frame";
1216
+ frame.src = sourceUrl;
1217
+ frame.title = resolveTitle(config);
1218
+ const actions = document.createElement("div");
1219
+ actions.className = "native-actions";
1220
+ const note = document.createElement("p");
1221
+ note.className = "native-note";
1222
+ note.textContent = "Using the iOS browser PDF viewer because direct PDF fetching failed.";
1223
+ const link = document.createElement("a");
1224
+ link.className = "native-link";
1225
+ link.href = sourceUrl;
1226
+ link.target = "_blank";
1227
+ link.rel = "noopener noreferrer";
1228
+ link.textContent = "Open PDF in a new tab";
1229
+ actions.append(note, link);
1230
+ wrapper.append(frame, actions);
1231
+ this.#stage.insertBefore(wrapper, this.#status);
1232
+ this.#syncToolbarState();
1233
+ const detail = {
1234
+ page: 1,
1235
+ pageCount: 0,
1236
+ zoom: config.zoom ?? DEFAULT_CONFIG.zoom,
1237
+ };
1238
+ emitViewerEvent(this, "ready", detail);
1239
+ return true;
1240
+ }
1241
+ #syncContainerSize() {
1242
+ const parent = this.parentElement;
1243
+ if (!parent) {
1244
+ return;
1245
+ }
1246
+ const parentWidth = parent.clientWidth || Math.round(parent.getBoundingClientRect().width);
1247
+ const parentHeight = parent.clientHeight || Math.round(parent.getBoundingClientRect().height);
1248
+ this.style.width = parentWidth > 0 ? `${parentWidth}px` : "100%";
1249
+ if (parentHeight > 0) {
1250
+ this.style.height = `${parentHeight}px`;
1251
+ }
1252
+ }
1253
+ #getAvailableViewportSize() {
1254
+ const toolbarHeight = this.#toolbar.hidden ? 0 : this.#toolbar.offsetHeight;
1255
+ const stageWidth = this.#stage.clientWidth;
1256
+ const stageHeight = this.#stage.clientHeight;
1257
+ const hostWidth = this.clientWidth;
1258
+ const hostHeight = this.clientHeight;
1259
+ const parentWidth = this.parentElement?.clientWidth ?? 0;
1260
+ const parentHeight = this.parentElement?.clientHeight ?? 0;
1261
+ return {
1262
+ width: stageWidth || hostWidth || parentWidth || 960,
1263
+ height: stageHeight ||
1264
+ Math.max(hostHeight - toolbarHeight, 0) ||
1265
+ Math.max(parentHeight - toolbarHeight, 0) ||
1266
+ 720,
1267
+ };
1268
+ }
639
1269
  #detectVisiblePage() {
640
1270
  const pages = Array.from(this.#stage.querySelectorAll(".page"));
641
1271
  if (pages.length === 0) {
@@ -650,15 +1280,6 @@ export class GenusPdfViewerElement extends HTMLElement {
650
1280
  const parsed = Number(visible.dataset.page);
651
1281
  return Number.isFinite(parsed) ? parsed : null;
652
1282
  }
653
- #toErrorMessage(error) {
654
- if (error instanceof Error && error.message) {
655
- return error.message;
656
- }
657
- if (typeof error === "string") {
658
- return error;
659
- }
660
- return "Failed to load PDF.";
661
- }
662
1283
  }
663
1284
  export function defineGenusPdfViewerElement(tagName = GENUS_PDF_VIEWER_TAG) {
664
1285
  if (!customElements.get(tagName)) {