genus-pdf-viewer 0.2.3 → 0.2.10

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,8 @@
1
- import * as pdfjsLib from "pdfjs-dist";
1
+ import * as pdfjsLib from "pdfjs-dist/legacy/build/pdf.mjs";
2
2
  import { VIEWER_STYLES } from "./styles.js";
3
3
  const GENUS_PDF_VIEWER_TAG = "genus-pdf-viewer";
4
+ export const PDF_WORKER_BOOTSTRAP_ASSET_PATH = "assets/pdf.worker.bootstrap.mjs";
5
+ const IOS_CANVAS_PIXEL_LIMIT = 16_777_216;
4
6
  const DEFAULT_CONFIG = {
5
7
  page: 1,
6
8
  zoom: 1,
@@ -72,21 +74,232 @@ function resolveTitle(config) {
72
74
  }
73
75
  return "PDF viewer";
74
76
  }
75
- async function toDocumentParameters(config) {
77
+ function resolveSourceHref(source) {
78
+ if (typeof source === "string") {
79
+ return source;
80
+ }
81
+ if (source instanceof URL) {
82
+ return source.toString();
83
+ }
84
+ return null;
85
+ }
86
+ function resolveProxyEndpoint(config, sourceHref) {
87
+ if (config.proxyUrl === false) {
88
+ return null;
89
+ }
90
+ if (typeof config.proxyUrl === "string" && config.proxyUrl) {
91
+ return config.proxyUrl;
92
+ }
93
+ return isCrossOriginSource(sourceHref) ? "/__proxy" : null;
94
+ }
95
+ function resolveEffectiveSourceHref(config, proxyPreference = "auto") {
96
+ const sourceHref = resolveSourceHref(config.src);
97
+ if (!sourceHref) {
98
+ return null;
99
+ }
100
+ if (proxyPreference === "never") {
101
+ return sourceHref;
102
+ }
103
+ const proxyEndpoint = resolveProxyEndpoint(config, sourceHref);
104
+ if (!proxyEndpoint) {
105
+ return sourceHref;
106
+ }
107
+ try {
108
+ const proxyUrl = new URL(proxyEndpoint, typeof window === "undefined" ? "http://localhost" : window.location.href);
109
+ proxyUrl.searchParams.set("url", sourceHref);
110
+ return proxyUrl.toString();
111
+ }
112
+ catch {
113
+ const separator = proxyEndpoint.includes("?") ? "&" : "?";
114
+ return `${proxyEndpoint}${separator}url=${encodeURIComponent(sourceHref)}`;
115
+ }
116
+ }
117
+ function isAppleMobileBrowser() {
118
+ if (typeof navigator === "undefined") {
119
+ return false;
120
+ }
121
+ const userAgent = navigator.userAgent ?? "";
122
+ const platform = navigator.platform ?? "";
123
+ return (/iPad|iPhone|iPod/i.test(userAgent) ||
124
+ (/Mac/i.test(platform) && (navigator.maxTouchPoints ?? 0) > 1));
125
+ }
126
+ function getPdfJsRuntimeOptions() {
127
+ if (!isAppleMobileBrowser()) {
128
+ return {};
129
+ }
130
+ return {
131
+ isOffscreenCanvasSupported: false,
132
+ isImageDecoderSupported: false,
133
+ };
134
+ }
135
+ function getCanvasOutputScale(width, height) {
136
+ const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
137
+ if (!isAppleMobileBrowser()) {
138
+ return devicePixelRatio;
139
+ }
140
+ const pixelArea = Math.max(width * height, 1);
141
+ const maxScale = Math.sqrt(IOS_CANVAS_PIXEL_LIMIT / pixelArea);
142
+ return Math.min(devicePixelRatio, maxScale);
143
+ }
144
+ function iconLabelMarkup(svg, label) {
145
+ return `<span class="button-icon" aria-hidden="true">${svg}</span><span class="button-label">${label}</span>`;
146
+ }
147
+ function openUrl(url) {
148
+ const openedWindow = window.open(url, "_blank", "noopener,noreferrer");
149
+ if (!openedWindow) {
150
+ window.location.href = url;
151
+ }
152
+ }
153
+ function releaseObjectUrl(href) {
154
+ window.setTimeout(() => {
155
+ URL.revokeObjectURL(href);
156
+ }, 1_000);
157
+ }
158
+ function triggerDownload(href, filename) {
159
+ const link = document.createElement("a");
160
+ link.href = href;
161
+ link.download = filename;
162
+ link.rel = "noopener";
163
+ document.body.appendChild(link);
164
+ link.click();
165
+ link.remove();
166
+ }
167
+ async function tryNativeFileDownload(blob, filename) {
168
+ if (typeof navigator === "undefined" || typeof File === "undefined") {
169
+ return false;
170
+ }
171
+ if (typeof navigator.share !== "function") {
172
+ return false;
173
+ }
174
+ try {
175
+ const file = new File([blob], filename, { type: blob.type || "application/pdf" });
176
+ if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) {
177
+ return false;
178
+ }
179
+ await navigator.share({
180
+ title: filename,
181
+ files: [file],
182
+ });
183
+ return true;
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ function shouldUseCompactToolbarUi() {
190
+ return isAppleMobileBrowser();
191
+ }
192
+ function ensurePromiseCompat() {
193
+ const promiseCtor = Promise;
194
+ if (typeof promiseCtor.try !== "function") {
195
+ promiseCtor.try = (callback, ...args) => new Promise((resolve, reject) => {
196
+ try {
197
+ resolve(callback(...args));
198
+ }
199
+ catch (error) {
200
+ reject(error);
201
+ }
202
+ });
203
+ }
204
+ if (typeof promiseCtor.withResolvers !== "function") {
205
+ promiseCtor.withResolvers = () => {
206
+ let resolve;
207
+ let reject;
208
+ const promise = new Promise((promiseResolve, promiseReject) => {
209
+ resolve = promiseResolve;
210
+ reject = promiseReject;
211
+ });
212
+ return {
213
+ promise,
214
+ resolve,
215
+ reject,
216
+ };
217
+ };
218
+ }
219
+ }
220
+ function ensureUrlParseCompat() {
221
+ const urlCtor = URL;
222
+ if (typeof URL === "undefined" || typeof urlCtor.parse === "function") {
223
+ return;
224
+ }
225
+ urlCtor.parse = (input, base) => {
226
+ try {
227
+ if (typeof base === "undefined") {
228
+ return new URL(input);
229
+ }
230
+ return new URL(input, base);
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ };
236
+ }
237
+ function ensurePdfJsCompat() {
238
+ ensurePromiseCompat();
239
+ ensureUrlParseCompat();
240
+ }
241
+ function isUnbundledPackageModuleUrl(moduleUrl) {
242
+ try {
243
+ const { pathname } = new URL(moduleUrl);
244
+ return /\/(?:index|viewer)\.js$/.test(pathname);
245
+ }
246
+ catch {
247
+ return false;
248
+ }
249
+ }
250
+ export function resolveDefaultPdfWorkerSrc(options = {}) {
251
+ const moduleUrl = options.moduleUrl ?? import.meta.url;
252
+ const packageRelativeWorkerUrl = new URL("./pdf.worker.bootstrap.mjs", moduleUrl).toString();
253
+ if (isUnbundledPackageModuleUrl(moduleUrl)) {
254
+ return packageRelativeWorkerUrl;
255
+ }
256
+ const baseUri = options.baseUri ??
257
+ (typeof document !== "undefined" ? document.baseURI : undefined) ??
258
+ (typeof location !== "undefined" ? location.href : undefined);
259
+ return baseUri
260
+ ? new URL(PDF_WORKER_BOOTSTRAP_ASSET_PATH, baseUri).toString()
261
+ : packageRelativeWorkerUrl;
262
+ }
263
+ async function createDocumentData(config) {
264
+ if (config.src instanceof Blob) {
265
+ return new Uint8Array(await config.src.arrayBuffer());
266
+ }
267
+ if (config.src instanceof Uint8Array) {
268
+ return new Uint8Array(config.src);
269
+ }
270
+ return null;
271
+ }
272
+ async function toDocumentParameters(config, proxyPreference = "auto") {
273
+ const runtimeOptions = getPdfJsRuntimeOptions();
274
+ const data = await createDocumentData(config);
275
+ if (data) {
276
+ return {
277
+ data,
278
+ disableRange: true,
279
+ disableStream: true,
280
+ disableAutoFetch: true,
281
+ stopAtErrors: true,
282
+ ...runtimeOptions,
283
+ };
284
+ }
76
285
  if (typeof config.src === "string") {
286
+ const url = resolveEffectiveSourceHref(config, proxyPreference);
77
287
  return {
78
- url: config.src,
288
+ url: url ?? config.src,
79
289
  withCredentials: config.withCredentials,
80
290
  httpHeaders: config.httpHeaders,
81
291
  stopAtErrors: true,
292
+ ...runtimeOptions,
82
293
  };
83
294
  }
84
295
  if (config.src instanceof URL) {
296
+ const url = resolveEffectiveSourceHref(config, proxyPreference);
85
297
  return {
86
- url: config.src.toString(),
298
+ url: url ?? config.src.toString(),
87
299
  withCredentials: config.withCredentials,
88
300
  httpHeaders: config.httpHeaders,
89
301
  stopAtErrors: true,
302
+ ...runtimeOptions,
90
303
  };
91
304
  }
92
305
  if (config.src instanceof Blob) {
@@ -96,6 +309,7 @@ async function toDocumentParameters(config) {
96
309
  disableStream: true,
97
310
  disableAutoFetch: true,
98
311
  stopAtErrors: true,
312
+ ...runtimeOptions,
99
313
  };
100
314
  }
101
315
  return {
@@ -104,6 +318,7 @@ async function toDocumentParameters(config) {
104
318
  disableStream: true,
105
319
  disableAutoFetch: true,
106
320
  stopAtErrors: true,
321
+ ...runtimeOptions,
107
322
  };
108
323
  }
109
324
  async function createBlobFromSource(config) {
@@ -113,32 +328,183 @@ async function createBlobFromSource(config) {
113
328
  if (config.src instanceof Uint8Array) {
114
329
  return new Blob([new Uint8Array(config.src)], { type: "application/pdf" });
115
330
  }
116
- const href = typeof config.src === "string"
117
- ? config.src
118
- : config.src instanceof URL
119
- ? config.src.toString()
120
- : null;
331
+ const response = await fetchSourceResponse(config);
332
+ if (!response) {
333
+ return null;
334
+ }
335
+ return response.blob();
336
+ }
337
+ function emitViewerEvent(element, type, detail) {
338
+ element.dispatchEvent(new CustomEvent(`genus-${type}`, {
339
+ bubbles: true,
340
+ detail,
341
+ }));
342
+ }
343
+ function getErrorName(error) {
344
+ if (typeof error === "object" &&
345
+ error !== null &&
346
+ "name" in error &&
347
+ typeof error.name === "string") {
348
+ return error.name;
349
+ }
350
+ return "";
351
+ }
352
+ function getRawErrorMessage(error) {
353
+ if (error instanceof Error && error.message) {
354
+ return error.message;
355
+ }
356
+ if (typeof error === "object" &&
357
+ error !== null &&
358
+ "message" in error &&
359
+ typeof error.message === "string") {
360
+ return error.message;
361
+ }
362
+ if (typeof error === "string") {
363
+ return error;
364
+ }
365
+ return "";
366
+ }
367
+ function isLikelyCorsError(error) {
368
+ const message = getRawErrorMessage(error).toLowerCase();
369
+ const name = getErrorName(error).toLowerCase();
370
+ return (message.includes("access-control") ||
371
+ message.includes("cross-origin") ||
372
+ message.includes("cors") ||
373
+ message.includes("credential is not supported") ||
374
+ message.includes("origin ") ||
375
+ name.includes("cors"));
376
+ }
377
+ function isLikelyNetworkLoadError(error) {
378
+ const name = getErrorName(error).toLowerCase();
379
+ const message = getRawErrorMessage(error).toLowerCase();
380
+ return (name.includes("network") ||
381
+ name === "unknownerrorexception" ||
382
+ message.includes("load failed") ||
383
+ message.includes("failed to fetch") ||
384
+ message.includes("networkerror") ||
385
+ message.includes("unexpected server response (0)") ||
386
+ message.includes("fetch"));
387
+ }
388
+ function isCrossOriginSource(href) {
389
+ if (typeof window === "undefined") {
390
+ return false;
391
+ }
392
+ try {
393
+ return new URL(href, window.location.href).origin !== window.location.origin;
394
+ }
395
+ catch {
396
+ return false;
397
+ }
398
+ }
399
+ function resolveFetchCredentials(href, withCredentials) {
400
+ if (withCredentials) {
401
+ return "include";
402
+ }
403
+ return isCrossOriginSource(href) ? "omit" : "same-origin";
404
+ }
405
+ function resolveFetchMode(href) {
406
+ return isCrossOriginSource(href) ? "cors" : "same-origin";
407
+ }
408
+ async function fetchSourceResponse(config, proxyPreference = "auto") {
409
+ const href = resolveEffectiveSourceHref(config, proxyPreference);
121
410
  if (!href || typeof fetch === "undefined") {
122
411
  return null;
123
412
  }
124
413
  const response = await fetch(href, {
125
414
  headers: config.httpHeaders,
126
- credentials: config.withCredentials ? "include" : "same-origin",
415
+ credentials: resolveFetchCredentials(href, config.withCredentials),
416
+ mode: resolveFetchMode(href),
127
417
  });
128
418
  if (!response.ok) {
129
419
  return null;
130
420
  }
131
- return response.blob();
421
+ return response;
132
422
  }
133
- function emitViewerEvent(element, type, detail) {
134
- element.dispatchEvent(new CustomEvent(`genus-${type}`, {
135
- bubbles: true,
136
- detail,
137
- }));
423
+ async function fetchDocumentDataViaXhr(config, proxyPreference = "auto") {
424
+ const href = resolveEffectiveSourceHref(config, proxyPreference);
425
+ if (!href || typeof XMLHttpRequest === "undefined") {
426
+ return null;
427
+ }
428
+ return new Promise((resolve, reject) => {
429
+ const xhr = new XMLHttpRequest();
430
+ xhr.open("GET", href, true);
431
+ xhr.responseType = "arraybuffer";
432
+ xhr.withCredentials = Boolean(config.withCredentials);
433
+ for (const [key, value] of Object.entries(config.httpHeaders ?? {})) {
434
+ xhr.setRequestHeader(key, value);
435
+ }
436
+ xhr.onload = () => {
437
+ if ((xhr.status === 200 || xhr.status === 206) && xhr.response instanceof ArrayBuffer) {
438
+ resolve(new Uint8Array(xhr.response));
439
+ return;
440
+ }
441
+ resolve(null);
442
+ };
443
+ xhr.onerror = () => {
444
+ reject(new TypeError("Load failed"));
445
+ };
446
+ xhr.onabort = () => {
447
+ reject(new TypeError("Load failed"));
448
+ };
449
+ xhr.send();
450
+ });
451
+ }
452
+ async function fetchDocumentDataFromSource(config, proxyPreference = "auto") {
453
+ try {
454
+ const response = await fetchSourceResponse(config, proxyPreference);
455
+ if (response) {
456
+ return new Uint8Array(await response.arrayBuffer());
457
+ }
458
+ }
459
+ catch {
460
+ // Fall through to the XHR loader, which is often more reliable on Safari.
461
+ }
462
+ return fetchDocumentDataViaXhr(config, proxyPreference);
463
+ }
464
+ function createErrorDetail(config, error) {
465
+ const sourceUrl = resolveSourceHref(config.src) ?? undefined;
466
+ const rawMessage = getRawErrorMessage(error);
467
+ if (sourceUrl && isLikelyNetworkLoadError(error)) {
468
+ if (isCrossOriginSource(sourceUrl) && isLikelyCorsError(error)) {
469
+ return {
470
+ error,
471
+ code: "cors",
472
+ sourceUrl,
473
+ 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.`,
474
+ };
475
+ }
476
+ return {
477
+ error,
478
+ code: "network",
479
+ sourceUrl,
480
+ message: `Failed to load PDF from ${sourceUrl}. Check the URL, auth headers, and network access.`,
481
+ };
482
+ }
483
+ return {
484
+ error,
485
+ code: "unknown",
486
+ sourceUrl,
487
+ message: rawMessage || "Failed to load PDF.",
488
+ };
489
+ }
490
+ function shouldAttemptNativeUrlFallback(config, error) {
491
+ return Boolean(isAppleMobileBrowser() &&
492
+ resolveSourceHref(config.src) &&
493
+ (isLikelyNetworkLoadError(error) || isLikelyCorsError(error)));
138
494
  }
139
495
  export class GenusPdfViewerElement extends HTMLElement {
140
496
  static get observedAttributes() {
141
- return ["src", "title", "page", "zoom", "fit", "continuous", "toolbar", "download"];
497
+ return [
498
+ "src",
499
+ "title",
500
+ "page",
501
+ "zoom",
502
+ "fit",
503
+ "continuous",
504
+ "toolbar",
505
+ "download",
506
+ "proxy-url",
507
+ ];
142
508
  }
143
509
  #config = null;
144
510
  #document = null;
@@ -146,6 +512,8 @@ export class GenusPdfViewerElement extends HTMLElement {
146
512
  #pageCount = 0;
147
513
  #loadVersion = 0;
148
514
  #renderVersion = 0;
515
+ #isUsingNativeViewer = false;
516
+ #resizeObserver = null;
149
517
  #shadow = this.attachShadow({ mode: "open" });
150
518
  #toolbar = document.createElement("div");
151
519
  #navGroup = document.createElement("div");
@@ -162,6 +530,8 @@ export class GenusPdfViewerElement extends HTMLElement {
162
530
  #stage = document.createElement("div");
163
531
  #status = document.createElement("div");
164
532
  #handleResize = () => {
533
+ this.#syncInteractionMode();
534
+ this.#syncContainerSize();
165
535
  if (!this.#document || !this.#config) {
166
536
  return;
167
537
  }
@@ -187,6 +557,11 @@ export class GenusPdfViewerElement extends HTMLElement {
187
557
  };
188
558
  constructor() {
189
559
  super();
560
+ if (typeof ResizeObserver !== "undefined") {
561
+ this.#resizeObserver = new ResizeObserver(() => {
562
+ this.#handleResize();
563
+ });
564
+ }
190
565
  const style = document.createElement("style");
191
566
  style.textContent = VIEWER_STYLES;
192
567
  const shell = document.createElement("div");
@@ -198,33 +573,49 @@ export class GenusPdfViewerElement extends HTMLElement {
198
573
  this.#pageValue.className = "value";
199
574
  this.#zoomValue.className = "value";
200
575
  this.#prevButton.type = "button";
201
- this.#prevButton.textContent = "Prev";
576
+ this.#prevButton.className = "compact";
577
+ this.#prevButton.setAttribute("aria-label", "Previous page");
578
+ this.#prevButton.title = "Previous page";
579
+ 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
580
  this.#prevButton.addEventListener("click", () => {
203
581
  void this.prevPage();
204
582
  });
205
583
  this.#nextButton.type = "button";
206
- this.#nextButton.textContent = "Next";
584
+ this.#nextButton.className = "compact";
585
+ this.#nextButton.setAttribute("aria-label", "Next page");
586
+ this.#nextButton.title = "Next page";
587
+ 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
588
  this.#nextButton.addEventListener("click", () => {
208
589
  void this.nextPage();
209
590
  });
210
591
  this.#zoomOutButton.type = "button";
211
592
  this.#zoomOutButton.textContent = "−";
593
+ this.#zoomOutButton.setAttribute("aria-label", "Zoom out");
594
+ this.#zoomOutButton.title = "Zoom out";
212
595
  this.#zoomOutButton.addEventListener("click", () => {
213
596
  void this.zoomOut();
214
597
  });
215
598
  this.#zoomInButton.type = "button";
216
599
  this.#zoomInButton.textContent = "+";
600
+ this.#zoomInButton.setAttribute("aria-label", "Zoom in");
601
+ this.#zoomInButton.title = "Zoom in";
217
602
  this.#zoomInButton.addEventListener("click", () => {
218
603
  void this.zoomIn();
219
604
  });
220
605
  this.#resetZoomButton.type = "button";
221
- this.#resetZoomButton.textContent = "100%";
606
+ this.#resetZoomButton.className = "compact";
607
+ this.#resetZoomButton.setAttribute("aria-label", "Reset zoom");
608
+ this.#resetZoomButton.title = "Reset zoom";
609
+ 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
610
  this.#resetZoomButton.addEventListener("click", () => {
223
611
  void this.setZoom(1);
224
612
  });
225
613
  this.#downloadButton.type = "button";
226
- this.#downloadButton.textContent = "Download";
227
- this.#downloadButton.className = "primary";
614
+ this.#downloadButton.className = "primary icon-button";
615
+ this.#downloadButton.setAttribute("aria-label", "Download PDF");
616
+ this.#downloadButton.title = "Download PDF";
617
+ this.#downloadButton.innerHTML =
618
+ '<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
619
  this.#downloadButton.addEventListener("click", () => {
229
620
  void this.download();
230
621
  });
@@ -241,6 +632,9 @@ export class GenusPdfViewerElement extends HTMLElement {
241
632
  }
242
633
  connectedCallback() {
243
634
  window.addEventListener("resize", this.#handleResize);
635
+ this.#observeResizeTargets();
636
+ this.#syncInteractionMode();
637
+ this.#syncContainerSize();
244
638
  if (!this.#config) {
245
639
  const attributeConfig = this.#configFromAttributes();
246
640
  if (attributeConfig) {
@@ -257,6 +651,7 @@ export class GenusPdfViewerElement extends HTMLElement {
257
651
  }
258
652
  disconnectedCallback() {
259
653
  window.removeEventListener("resize", this.#handleResize);
654
+ this.#resizeObserver?.disconnect();
260
655
  void this.#teardownDocument();
261
656
  }
262
657
  attributeChangedCallback() {
@@ -285,6 +680,7 @@ export class GenusPdfViewerElement extends HTMLElement {
285
680
  const sourceChanged = !previous ||
286
681
  !isSameSource(previous.src, nextConfig.src) ||
287
682
  previous.workerSrc !== nextConfig.workerSrc ||
683
+ previous.proxyUrl !== nextConfig.proxyUrl ||
288
684
  previous.withCredentials !== nextConfig.withCredentials ||
289
685
  !sameHeaders(previous.httpHeaders, nextConfig.httpHeaders);
290
686
  if (sourceChanged) {
@@ -318,7 +714,7 @@ export class GenusPdfViewerElement extends HTMLElement {
318
714
  await this.#loadAndRender();
319
715
  }
320
716
  async goToPage(page) {
321
- if (!this.#config) {
717
+ if (!this.#config || this.#isUsingNativeViewer) {
322
718
  return;
323
719
  }
324
720
  const nextPage = clampPage(page, this.#pageCount || Number.MAX_SAFE_INTEGER);
@@ -342,13 +738,13 @@ export class GenusPdfViewerElement extends HTMLElement {
342
738
  await this.goToPage((this.#config.page ?? DEFAULT_CONFIG.page) + 1);
343
739
  }
344
740
  async prevPage() {
345
- if (!this.#config) {
741
+ if (!this.#config || this.#isUsingNativeViewer) {
346
742
  return;
347
743
  }
348
744
  await this.goToPage((this.#config.page ?? DEFAULT_CONFIG.page) - 1);
349
745
  }
350
746
  async setZoom(zoom) {
351
- if (!this.#config) {
747
+ if (!this.#config || this.#isUsingNativeViewer) {
352
748
  return;
353
749
  }
354
750
  const nextZoom = clampZoom(zoom, this.#config.minZoom ?? DEFAULT_CONFIG.minZoom, this.#config.maxZoom ?? DEFAULT_CONFIG.maxZoom);
@@ -358,14 +754,14 @@ export class GenusPdfViewerElement extends HTMLElement {
358
754
  emitViewerEvent(this, "zoomchange", { zoom: nextZoom });
359
755
  }
360
756
  async zoomIn() {
361
- if (!this.#config) {
757
+ if (!this.#config || this.#isUsingNativeViewer) {
362
758
  return;
363
759
  }
364
760
  await this.setZoom((this.#config.zoom ?? DEFAULT_CONFIG.zoom) +
365
761
  (this.#config.zoomStep ?? DEFAULT_CONFIG.zoomStep));
366
762
  }
367
763
  async zoomOut() {
368
- if (!this.#config) {
764
+ if (!this.#config || this.#isUsingNativeViewer) {
369
765
  return;
370
766
  }
371
767
  await this.setZoom((this.#config.zoom ?? DEFAULT_CONFIG.zoom) -
@@ -375,26 +771,29 @@ export class GenusPdfViewerElement extends HTMLElement {
375
771
  if (!this.#config?.allowDownload) {
376
772
  return;
377
773
  }
378
- const link = document.createElement("a");
379
774
  const blob = await createBlobFromSource(this.#config);
380
775
  if (blob) {
381
776
  const href = URL.createObjectURL(blob);
382
- link.href = href;
383
- link.download = resolveTitle(this.#config).endsWith(".pdf")
777
+ const filename = resolveTitle(this.#config).endsWith(".pdf")
384
778
  ? resolveTitle(this.#config)
385
779
  : `${resolveTitle(this.#config)}.pdf`;
386
- document.body.appendChild(link);
387
- link.click();
388
- link.remove();
389
- URL.revokeObjectURL(href);
390
- return;
780
+ try {
781
+ if (isAppleMobileBrowser() && (await tryNativeFileDownload(blob, filename))) {
782
+ return;
783
+ }
784
+ triggerDownload(href, filename);
785
+ return;
786
+ }
787
+ finally {
788
+ releaseObjectUrl(href);
789
+ }
391
790
  }
392
791
  if (typeof this.#config.src === "string") {
393
- window.open(this.#config.src, "_blank", "noopener,noreferrer");
792
+ openUrl(this.#config.src);
394
793
  return;
395
794
  }
396
795
  if (this.#config.src instanceof URL) {
397
- window.open(this.#config.src.toString(), "_blank", "noopener,noreferrer");
796
+ openUrl(this.#config.src.toString());
398
797
  }
399
798
  }
400
799
  async destroy() {
@@ -416,6 +815,13 @@ export class GenusPdfViewerElement extends HTMLElement {
416
815
  continuous: parseBooleanAttribute(this.getAttribute("continuous"), DEFAULT_CONFIG.continuous),
417
816
  showToolbar: parseBooleanAttribute(this.getAttribute("toolbar"), DEFAULT_CONFIG.showToolbar),
418
817
  allowDownload: parseBooleanAttribute(this.getAttribute("download"), DEFAULT_CONFIG.allowDownload),
818
+ proxyUrl: (() => {
819
+ const value = this.getAttribute("proxy-url");
820
+ if (value === null) {
821
+ return undefined;
822
+ }
823
+ return value === "false" ? false : value;
824
+ })(),
419
825
  };
420
826
  }
421
827
  async #loadAndRender() {
@@ -424,19 +830,16 @@ export class GenusPdfViewerElement extends HTMLElement {
424
830
  return;
425
831
  }
426
832
  const loadVersion = ++this.#loadVersion;
833
+ this.#isUsingNativeViewer = false;
427
834
  this.#setStatus("Loading PDF...");
428
835
  await this.#teardownDocument();
429
836
  try {
430
837
  const globalWorkerOptions = pdfjsLib.GlobalWorkerOptions;
431
838
  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();
839
+ ensurePdfJsCompat();
840
+ globalWorkerOptions.workerSrc = config.workerSrc ?? resolveDefaultPdfWorkerSrc();
841
+ const documentProxy = await this.#loadDocumentProxy(config, loadVersion);
842
+ if (!documentProxy) {
440
843
  return;
441
844
  }
442
845
  this.#document = documentProxy;
@@ -455,16 +858,79 @@ export class GenusPdfViewerElement extends HTMLElement {
455
858
  emitViewerEvent(this, "ready", detail);
456
859
  }
457
860
  catch (error) {
458
- const detail = {
459
- error,
460
- message: this.#toErrorMessage(error),
461
- };
861
+ if (this.#tryRenderNativeUrlFallback(config, error)) {
862
+ return;
863
+ }
864
+ const detail = createErrorDetail(config, error);
462
865
  this.#setStatus(detail.message);
463
866
  emitViewerEvent(this, "error", detail);
464
867
  }
465
868
  }
869
+ async #loadDocumentProxy(config, loadVersion) {
870
+ try {
871
+ return await this.#loadDocumentProxyFromParams(await toDocumentParameters(config, "auto"), loadVersion);
872
+ }
873
+ catch (error) {
874
+ let currentError = error;
875
+ const directRetryParams = await this.#createDirectUrlRetryParameters(config);
876
+ if (directRetryParams) {
877
+ try {
878
+ return await this.#loadDocumentProxyFromParams(directRetryParams, loadVersion);
879
+ }
880
+ catch (directError) {
881
+ currentError = directError;
882
+ }
883
+ }
884
+ const fallbackParams = await this.#createFetchedDataFallbackParameters(config, currentError, directRetryParams ? "never" : "auto");
885
+ if (!fallbackParams) {
886
+ throw currentError;
887
+ }
888
+ return this.#loadDocumentProxyFromParams(fallbackParams, loadVersion);
889
+ }
890
+ }
891
+ async #loadDocumentProxyFromParams(params, loadVersion) {
892
+ const task = pdfjsLib.getDocument(params);
893
+ this.#loadingTask = task;
894
+ const documentProxy = await task.promise;
895
+ if (loadVersion !== this.#loadVersion) {
896
+ await documentProxy.destroy();
897
+ return null;
898
+ }
899
+ return documentProxy;
900
+ }
901
+ async #createFetchedDataFallbackParameters(config, error, proxyPreference) {
902
+ if (!resolveSourceHref(config.src) || !isLikelyNetworkLoadError(error)) {
903
+ return null;
904
+ }
905
+ try {
906
+ const data = await fetchDocumentDataFromSource(config, proxyPreference);
907
+ if (!data) {
908
+ return null;
909
+ }
910
+ return {
911
+ data,
912
+ disableRange: true,
913
+ disableStream: true,
914
+ disableAutoFetch: true,
915
+ stopAtErrors: true,
916
+ ...getPdfJsRuntimeOptions(),
917
+ };
918
+ }
919
+ catch {
920
+ return null;
921
+ }
922
+ }
923
+ async #createDirectUrlRetryParameters(config) {
924
+ const proxiedHref = resolveEffectiveSourceHref(config, "auto");
925
+ const directHref = resolveEffectiveSourceHref(config, "never");
926
+ if (!proxiedHref || !directHref || proxiedHref === directHref) {
927
+ return null;
928
+ }
929
+ return toDocumentParameters(config, "never");
930
+ }
466
931
  async #teardownDocument() {
467
932
  this.#renderVersion += 1;
933
+ this.#isUsingNativeViewer = false;
468
934
  const loadingTask = this.#loadingTask;
469
935
  this.#loadingTask = null;
470
936
  if (loadingTask) {
@@ -497,16 +963,10 @@ export class GenusPdfViewerElement extends HTMLElement {
497
963
  const scrollSnapshot = config.continuous ? this.#captureScrollSnapshot() : null;
498
964
  this.#setStatus(null);
499
965
  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
- }
966
+ const stageChildren = Array.from(this.#stage.children).filter((child) => child !== this.#status);
506
967
  if (config.continuous) {
507
968
  const stack = document.createElement("div");
508
969
  stack.className = "page-stack";
509
- this.#stage.insertBefore(stack, this.#status);
510
970
  for (let index = 1; index <= documentProxy.numPages; index += 1) {
511
971
  if (renderVersion !== this.#renderVersion) {
512
972
  return;
@@ -516,6 +976,10 @@ export class GenusPdfViewerElement extends HTMLElement {
516
976
  stack.appendChild(wrapper);
517
977
  this.#animatePageEntry(wrapper);
518
978
  }
979
+ for (const child of stageChildren) {
980
+ child.remove();
981
+ }
982
+ this.#stage.insertBefore(stack, this.#status);
519
983
  this.#restoreScrollSnapshot(scrollSnapshot ?? {
520
984
  page: config.page ?? DEFAULT_CONFIG.page,
521
985
  offsetRatio: 0,
@@ -525,6 +989,9 @@ export class GenusPdfViewerElement extends HTMLElement {
525
989
  const pageNumber = clampPage(config.page ?? DEFAULT_CONFIG.page, documentProxy.numPages);
526
990
  const page = await documentProxy.getPage(pageNumber);
527
991
  const wrapper = await this.#renderPage(page, config, pageNumber);
992
+ for (const child of stageChildren) {
993
+ child.remove();
994
+ }
528
995
  this.#stage.insertBefore(wrapper, this.#status);
529
996
  this.#animatePageEntry(wrapper);
530
997
  }
@@ -541,9 +1008,9 @@ export class GenusPdfViewerElement extends HTMLElement {
541
1008
  }
542
1009
  const scale = this.#computeScale(page, config);
543
1010
  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);
1011
+ const outputScale = getCanvasOutputScale(viewport.width, viewport.height);
1012
+ canvas.width = Math.max(1, Math.floor(viewport.width * outputScale));
1013
+ canvas.height = Math.max(1, Math.floor(viewport.height * outputScale));
547
1014
  canvas.style.width = `${Math.floor(viewport.width)}px`;
548
1015
  canvas.style.height = `${Math.floor(viewport.height)}px`;
549
1016
  wrapper.style.width = canvas.style.width;
@@ -554,16 +1021,17 @@ export class GenusPdfViewerElement extends HTMLElement {
554
1021
  canvasContext: context,
555
1022
  viewport,
556
1023
  };
557
- if (dpr !== 1) {
558
- renderContext.transform = [dpr, 0, 0, dpr, 0, 0];
1024
+ if (outputScale !== 1) {
1025
+ renderContext.transform = [outputScale, 0, 0, outputScale, 0, 0];
559
1026
  }
560
1027
  await page.render(renderContext).promise;
561
1028
  return wrapper;
562
1029
  }
563
1030
  #computeScale(page, config) {
564
1031
  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;
1032
+ const { width, height } = this.#getAvailableViewportSize();
1033
+ const availableWidth = Math.max(320, width) - 32;
1034
+ const availableHeight = Math.max(320, height) - 32;
567
1035
  let baseScale = 1;
568
1036
  if (config.fit === "width") {
569
1037
  baseScale = availableWidth / viewport.width;
@@ -577,11 +1045,20 @@ export class GenusPdfViewerElement extends HTMLElement {
577
1045
  const config = this.#config;
578
1046
  const page = config?.page ?? DEFAULT_CONFIG.page;
579
1047
  const zoom = config?.zoom ?? DEFAULT_CONFIG.zoom;
1048
+ const isCompactUi = this.hasAttribute("compact-ui");
1049
+ const isNativeViewer = this.#isUsingNativeViewer;
580
1050
  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;
1051
+ this.#pageValue.textContent = isNativeViewer
1052
+ ? "Browser"
1053
+ : isCompactUi
1054
+ ? `${page}/${this.#pageCount || 0}`
1055
+ : `${page} / ${this.#pageCount || 0}`;
1056
+ this.#zoomValue.textContent = isNativeViewer ? "Auto" : `${Math.round(zoom * 100)}%`;
1057
+ this.#prevButton.disabled = isNativeViewer || page <= 1;
1058
+ this.#nextButton.disabled = isNativeViewer || (this.#pageCount > 0 ? page >= this.#pageCount : true);
1059
+ this.#zoomOutButton.disabled = isNativeViewer;
1060
+ this.#zoomInButton.disabled = isNativeViewer;
1061
+ this.#resetZoomButton.disabled = isNativeViewer;
585
1062
  this.#downloadButton.hidden = !(config?.allowDownload ?? DEFAULT_CONFIG.allowDownload);
586
1063
  }
587
1064
  #setStatus(message) {
@@ -594,17 +1071,13 @@ export class GenusPdfViewerElement extends HTMLElement {
594
1071
  this.#status.textContent = message;
595
1072
  }
596
1073
  #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
- });
1074
+ this.#restoreScrollSnapshot({
1075
+ page,
1076
+ offsetRatio: 0,
1077
+ }, smooth);
605
1078
  }
606
1079
  #captureScrollSnapshot() {
607
- const currentPage = this.#detectVisiblePage();
1080
+ const currentPage = this.#config?.page ?? this.#detectVisiblePage();
608
1081
  if (!currentPage) {
609
1082
  return null;
610
1083
  }
@@ -636,6 +1109,97 @@ export class GenusPdfViewerElement extends HTMLElement {
636
1109
  wrapper.classList.remove("entering");
637
1110
  });
638
1111
  }
1112
+ #observeResizeTargets() {
1113
+ if (!this.#resizeObserver) {
1114
+ return;
1115
+ }
1116
+ this.#resizeObserver.disconnect();
1117
+ this.#resizeObserver.observe(this);
1118
+ if (this.parentElement) {
1119
+ this.#resizeObserver.observe(this.parentElement);
1120
+ }
1121
+ }
1122
+ #syncInteractionMode() {
1123
+ if (shouldUseCompactToolbarUi()) {
1124
+ this.setAttribute("compact-ui", "");
1125
+ return;
1126
+ }
1127
+ this.removeAttribute("compact-ui");
1128
+ }
1129
+ #tryRenderNativeUrlFallback(config, error) {
1130
+ if (!shouldAttemptNativeUrlFallback(config, error)) {
1131
+ return false;
1132
+ }
1133
+ const sourceUrl = resolveSourceHref(config.src);
1134
+ if (!sourceUrl) {
1135
+ return false;
1136
+ }
1137
+ this.#isUsingNativeViewer = true;
1138
+ this.#pageCount = 0;
1139
+ this.#setStatus(null);
1140
+ this.#stage.className = "stage native-stage";
1141
+ const stageChildren = Array.from(this.#stage.children).filter((child) => child !== this.#status);
1142
+ for (const child of stageChildren) {
1143
+ child.remove();
1144
+ }
1145
+ const wrapper = document.createElement("div");
1146
+ wrapper.className = "page native-page";
1147
+ wrapper.dataset.page = "1";
1148
+ const frame = document.createElement("iframe");
1149
+ frame.className = "native-frame";
1150
+ frame.src = sourceUrl;
1151
+ frame.title = resolveTitle(config);
1152
+ const actions = document.createElement("div");
1153
+ actions.className = "native-actions";
1154
+ const note = document.createElement("p");
1155
+ note.className = "native-note";
1156
+ note.textContent = "Using the iOS browser PDF viewer because direct PDF fetching failed.";
1157
+ const link = document.createElement("a");
1158
+ link.className = "native-link";
1159
+ link.href = sourceUrl;
1160
+ link.target = "_blank";
1161
+ link.rel = "noopener noreferrer";
1162
+ link.textContent = "Open PDF in a new tab";
1163
+ actions.append(note, link);
1164
+ wrapper.append(frame, actions);
1165
+ this.#stage.insertBefore(wrapper, this.#status);
1166
+ this.#syncToolbarState();
1167
+ const detail = {
1168
+ page: 1,
1169
+ pageCount: 0,
1170
+ zoom: config.zoom ?? DEFAULT_CONFIG.zoom,
1171
+ };
1172
+ emitViewerEvent(this, "ready", detail);
1173
+ return true;
1174
+ }
1175
+ #syncContainerSize() {
1176
+ const parent = this.parentElement;
1177
+ if (!parent) {
1178
+ return;
1179
+ }
1180
+ const parentWidth = parent.clientWidth || Math.round(parent.getBoundingClientRect().width);
1181
+ const parentHeight = parent.clientHeight || Math.round(parent.getBoundingClientRect().height);
1182
+ this.style.width = parentWidth > 0 ? `${parentWidth}px` : "100%";
1183
+ if (parentHeight > 0) {
1184
+ this.style.height = `${parentHeight}px`;
1185
+ }
1186
+ }
1187
+ #getAvailableViewportSize() {
1188
+ const toolbarHeight = this.#toolbar.hidden ? 0 : this.#toolbar.offsetHeight;
1189
+ const stageWidth = this.#stage.clientWidth;
1190
+ const stageHeight = this.#stage.clientHeight;
1191
+ const hostWidth = this.clientWidth;
1192
+ const hostHeight = this.clientHeight;
1193
+ const parentWidth = this.parentElement?.clientWidth ?? 0;
1194
+ const parentHeight = this.parentElement?.clientHeight ?? 0;
1195
+ return {
1196
+ width: stageWidth || hostWidth || parentWidth || 960,
1197
+ height: stageHeight ||
1198
+ Math.max(hostHeight - toolbarHeight, 0) ||
1199
+ Math.max(parentHeight - toolbarHeight, 0) ||
1200
+ 720,
1201
+ };
1202
+ }
639
1203
  #detectVisiblePage() {
640
1204
  const pages = Array.from(this.#stage.querySelectorAll(".page"));
641
1205
  if (pages.length === 0) {
@@ -650,15 +1214,6 @@ export class GenusPdfViewerElement extends HTMLElement {
650
1214
  const parsed = Number(visible.dataset.page);
651
1215
  return Number.isFinite(parsed) ? parsed : null;
652
1216
  }
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
1217
  }
663
1218
  export function defineGenusPdfViewerElement(tagName = GENUS_PDF_VIEWER_TAG) {
664
1219
  if (!customElements.get(tagName)) {